Automação de Testes de API e Dados
Depois dos ficheiros locais, os dados que vivem fora do teu computador.
Por Telmo Silva
Ir para o teu progresso ↓Porque escrevi este curso
O Curso 1 trabalhava sempre com dados que já tinhas à mão: um CSV local, um ficheiro de log na tua própria pasta. A maior parte do trabalho real de QA automation, porém, envolve dados que vivem fora do teu computador: uma API que outro sistema expõe, uma base de dados que a aplicação lê e escreve sem tu veres. Este curso é sobre chegar lá.
E é sobre organizar o que já sabes fazer. Os teus scripts do Curso 1 corriam do início ao fim, com um assert a seguir ao outro, e paravam à primeira falha. O pytest, no fim deste curso, resolve exatamente essa limitação: cada verificação corre isolada, uma falha não trava as outras, e no fim tens um resumo claro, ou um relatório em HTML para quem não vai abrir um terminal.
Uma honestidade sobre o que aqui está
É o segundo de quatro cursos que escrevi; o Curso 1 já está publicado aqui, os cursos 3 e 4 ainda não. Pressupõe que já fizeste o Curso 1, ou que já sabes o equivalente: variáveis, funções, condicionais, loops, ler e escrever CSV.
Serve para quem já automatizou verificações simples e quer dar o próximo passo: testar sistemas a sério, não só ficheiros locais.
O que já funcionava não desaparece: agora corre isolado, e sabes exatamente o que passou e o que falhou.
Por onde começar, consoante a tua situação
Índice
10 capítulos, com código a sério em cada um. Pressupõe o Curso 1 feito; se ainda não o fizeste, começa por lá.
01A Biblioteca Requests, e o Formato JSON
requests
Até ao Curso 1, os teus scripts trabalhavam sempre com dados que já tinhas à mão: um CSV local, um ficheiro de log na tua própria pasta. Uma API (a forma de dois programas comunicarem entre si) abre a porta a dados que vivem fora do teu computador, nalgum servidor que não controlas. Em Python, quem trata desses pedidos por ti é a biblioteca requests. Não vem instalada por definição:
bash
pip install requestsCom isto feito, um pedido GET (pedir informação, sem alterar nada do lado do servidor) resume-se a isto:
python
import requests
resposta = requests.get("https://jsonplaceholder.typicode.com/posts/1")
print(resposta.status_code) # 200
print(resposta.text) # o corpo da resposta, como textoO URL que passaste a requests.get() é o que se chama um endpoint: o endereço específico de uma API a que pedes uma ação. Além de get, a biblioteca tem um método por cada tipo de pedido HTTP; o que vais usar mais a seguir é post, para enviar dados novos:
python
dados_novos = {"title": "Novo post", "body": "Conteúdo do post", "userId": 1}
resposta = requests.post("https://jsonplaceholder.typicode.com/posts", json=dados_novos)
print(resposta.status_code) # 201O argumento json=dados_novos faz duas coisas: converte o dicionário Python para o formato que a API espera, e define automaticamente o cabeçalho que diz "isto é JSON". Não precisas de fazer isso à mão.
Formato JSON
resposta.text dá-te o corpo da resposta como uma única string, correto mas pouco prático. O que recebeste é JSON: um formato de texto para organizar dados, o mais usado por APIs em toda a indústria, propositadamente parecido com dicionários e listas de Python. Um objeto JSON corresponde a um dicionário, uma lista JSON corresponde a uma lista. Em vez de leres esse texto à mão, o objeto Response já sabe converter isto por ti:
python
import requests
resposta = requests.get("https://jsonplaceholder.typicode.com/posts/1")
post = resposta.json() # converte o JSON da resposta num dicionário Python
print(type(post)) # <class 'dict'>
print(post["title"])
print(post["userId"]) # 1resposta.json() (com parênteses, porque é um método) dá-te logo a estrutura Python pronta a usar. Quando o endpoint devolve vários registos em vez de um, resposta.json() devolve uma lista de dicionários, a mesma forma que já conheces de ler um CSV com csv.DictReader, só que agora veio de uma API.
02Status Codes, e Assertions
Status codes HTTP
Toda a resposta HTTP vem com um número que diz, ainda antes de olhares para o conteúdo, se o pedido correu bem ou mal.
- 200 OK: o pedido correu bem (o mais comum a seguir a um GET).
- 201 Created: um recurso novo foi criado com sucesso (o mais comum a seguir a um POST).
- 400 Bad Request: o pedido está mal formado, falta um campo obrigatório, ou um valor tem o tipo errado.
- 404 Not Found: o recurso pedido não existe.
- 500 Internal Server Error: algo correu mal do lado do servidor; a culpa não é do teu pedido.
O primeiro dígito já te diz a categoria: 2xx é sucesso, 4xx é erro de quem pediu, 5xx é erro do servidor. Não vale a pena decorar todos os códigos, vale a pena decorar esta lógica de categorias.
Repara que requests.get() não dá erro por teres recebido um 404: isso é uma resposta válida como outra qualquer. Quem decide se um 404 é "normal" ou "um problema" és tu, a pensar no que estavas mesmo à espera. Se esperavas o post 1 e recebeste 404, tens um problema; e é aqui que entram as assertions.
Assertions simples
Uma assertion é uma verificação automática dentro do próprio código: confirma que algo é verdadeiro e, se não for, interrompe o programa ali mesmo. Em Python, usa-se assert:
python
resposta = requests.get("https://jsonplaceholder.typicode.com/posts/1")
assert resposta.status_code == 200
print("Pedido correu bem.")Se resposta.status_code for mesmo 200, o assert não faz nada visível e o script continua. Se não for, o Python levanta um AssertionError e o script para ali. Podes (e deves) acrescentar uma mensagem depois de uma vírgula:
python
assert resposta.status_code == 200, f"esperava status 200, recebi {resposta.status_code}"
# se a condição for falsa (ex.: a API devolveu 404), o Python para com:
# AssertionError: esperava status 200, recebi 404print avisa, assert para
Um print() só avisa; o script continua a correr como se nada tivesse acontecido, e um aviso a meio de muita saída é fácil de não reparar. Um assert que falha para o script de imediato. É essa interrupção brusca que torna as assertions úteis como testes.
python
post = resposta.json()
assert post["userId"] == 1, f"esperava userId 1, recebi {post['userId']}"
assert isinstance(post["title"], str), "title deveria ser texto"Um script com uma sequência de assertions é, no fundo, um teste automatizado em miniatura, exatamente o que vais construir no exercício deste módulo, e a base sobre a qual o pytest, no capítulo 8, constrói tudo o resto.
03Prática: a Tua Primeira Suite de API
Exercício 1: Testar endpoints GET e POST de uma API pública
Objetivo: Escrever testes_api.py, um script que faz pedidos GET e POST a uma API pública real, e imprime o que recebe de volta.
- Usa a API pública gratuita jsonplaceholder.typicode.com (sem registo nem chave de acesso).
- Faz um GET ao endpoint /posts/1 e imprime o status code, e os campos id, userId e title.
- Faz um GET a /posts filtrado por userId=1 (usa o argumento params de requests.get), e imprime quantos posts vieram.
- Faz um POST a /posts a criar um post novo, e imprime o status code e o id atribuído.
- Usa sempre .json() para leres a resposta, nunca .text.
Dica: O argumento params de requests.get(url, params={...}) monta o URL com ?chave=valor por ti.
Ver solução
import requests
def testar_get_post_unico():
resposta = requests.get("https://jsonplaceholder.typicode.com/posts/1")
print(f"GET /posts/1 -> status {resposta.status_code}")
post = resposta.json()
print(f" id: {post['id']}")
print(f" userId: {post['userId']}")
print(f" title: {post['title']}")
def testar_get_lista_filtrada():
resposta = requests.get(
"https://jsonplaceholder.typicode.com/posts",
params={"userId": 1},
)
print(f"GET /posts?userId=1 -> status {resposta.status_code}")
posts = resposta.json()
print(f" {len(posts)} posts encontrados para o utilizador 1")
def testar_post_criar():
dados_novos = {
"title": "Teste de API automatizado",
"body": "Conteúdo criado durante o módulo de testes de API.",
"userId": 10,
}
resposta = requests.post("https://jsonplaceholder.typicode.com/posts", json=dados_novos)
print(f"POST /posts -> status {resposta.status_code}")
post_criado = resposta.json()
print(f" id atribuído: {post_criado['id']}")
print(f" title: {post_criado['title']}")
def main():
testar_get_post_unico()
testar_get_lista_filtrada()
testar_post_criar()
if __name__ == "__main__":
main()Exercício 2: Validar respostas automaticamente
Objetivo: Pegar na lógica do exercício anterior e escrever testes_api_validacao.py: em vez de só imprimires o que a API devolve, confirmas automaticamente, com assert, que a resposta é mesmo a esperada.
- Reaproveita os três pedidos do exercício anterior, um por função.
- Para cada um, acrescenta assertions que confirmem o status code esperado e pelo menos dois campos específicos.
- Acrescenta um quarto pedido: um GET a um post que não existe (/posts/99999), a confirmar status 404.
- Em cada assert, inclui uma mensagem que diga claramente o que esperavas e o que recebeste.
- Se o script correr do início ao fim sem erro, imprime uma mensagem final a confirmar que tudo passou.
Dica: Um assert que falha interrompe logo o script: não vais ver um resumo tipo "3 de 4 passaram". É exatamente essa lacuna que o pytest, no capítulo 8, vem resolver.
Ver solução
import requests
def testar_get_post_unico():
resposta = requests.get("https://jsonplaceholder.typicode.com/posts/1")
post = resposta.json()
assert resposta.status_code == 200, f"esperava status 200, recebi {resposta.status_code}"
assert post["id"] == 1, f"esperava id 1, recebi {post['id']}"
assert post["userId"] == 1, f"esperava userId 1, recebi {post['userId']}"
assert isinstance(post["title"], str) and post["title"] != "", "title deveria ser texto não vazio"
print("OK GET /posts/1 devolve o post correto, com os campos esperados")
def testar_get_lista_filtrada():
resposta = requests.get(
"https://jsonplaceholder.typicode.com/posts",
params={"userId": 1},
)
posts = resposta.json()
assert resposta.status_code == 200, f"esperava status 200, recebi {resposta.status_code}"
assert len(posts) == 10, f"esperava 10 posts, recebi {len(posts)}"
assert all(post["userId"] == 1 for post in posts), "há posts de outro utilizador na lista"
print("OK GET /posts?userId=1 devolve só posts do utilizador 1")
def testar_post_criar():
dados_novos = {
"title": "Teste de API automatizado",
"body": "Conteúdo criado durante o módulo de testes de API.",
"userId": 10,
}
resposta = requests.post("https://jsonplaceholder.typicode.com/posts", json=dados_novos)
post_criado = resposta.json()
assert resposta.status_code == 201, f"esperava status 201, recebi {resposta.status_code}"
assert post_criado["title"] == dados_novos["title"], "o title devolvido não corresponde ao enviado"
assert post_criado["userId"] == dados_novos["userId"], "o userId devolvido não corresponde ao enviado"
assert "id" in post_criado, "a resposta devia incluir um novo id atribuído"
print("OK POST /posts cria o recurso e devolve um novo id")
def testar_post_inexistente():
resposta = requests.get("https://jsonplaceholder.typicode.com/posts/99999")
assert resposta.status_code == 404, f"esperava status 404, recebi {resposta.status_code}"
print("OK GET /posts/99999 devolve 404 (recurso não encontrado)")
def main():
testar_get_post_unico()
testar_get_lista_filtrada()
testar_post_criar()
testar_post_inexistente()
print("\nTodas as verificações passaram.")
if __name__ == "__main__":
main()04SQL Básico: SELECT, WHERE, JOIN
Todo o código que escreveste até agora falava com ficheiros ou com uma API. A partir de agora falas diretamente com uma base de dados, e para lá chegares precisas de SQL: a linguagem usada para pedir ou alterar dados guardados numa base de dados. SQL descreve que dados queres, não os passos para lá chegares. Três palavras-chave, SELECT, WHERE e JOIN, já respondem à maioria das perguntas que um QA faz a uma base de dados.
Para os exemplos, imagina uma base de dados com duas tabelas, clientes e encomendas, já criada e preenchida:
sql
CREATE TABLE clientes (
id INTEGER PRIMARY KEY,
nome TEXT,
email TEXT
);
CREATE TABLE encomendas (
id INTEGER PRIMARY KEY,
cliente_id INTEGER,
produto TEXT,
quantidade INTEGER,
preco REAL
);
INSERT INTO clientes (id, nome, email) VALUES
(1, 'Marta Sousa', 'marta.sousa@example.com'),
(2, 'Rui Pinto', 'rui.pinto@example.com'),
(3, 'Sofia Lima', 'sofia.lima@example.com');
INSERT INTO encomendas (id, cliente_id, produto, quantidade, preco) VALUES
(1, 1, 'Rato sem fios', 2, 9.90),
(2, 2, 'Monitor', 1, 149.90),
(3, 1, 'Teclado', 1, 29.90),
(4, 3, 'Webcam', 1, 39.90);No dia a dia de QA é raro seres tu a escrever CREATE TABLE: a base de dados já existe, criada por quem desenvolveu a aplicação. O que vais escrever constantemente é o SELECT que vem a seguir.
SELECT
SELECT escolhe que colunas queres ver de uma tabela. SELECT * devolve todas; listar os nomes devolve só essas:
sql
SELECT nome, email FROM clientes;
-- Marta Sousa|marta.sousa@example.com
-- Rui Pinto|rui.pinto@example.com
-- Sofia Lima|sofia.lima@example.comFROM clientes diz de que tabela vêm os dados. O * é cómodo para explorares uma tabela pela primeira vez, mas em código real prefere sempre listar as colunas que precisas.
WHERE
WHERE filtra as linhas por uma condição, tal como um if só deixa passar o que for verdadeiro:
sql
SELECT * FROM encomendas WHERE cliente_id = 1 AND preco < 20;
-- 1|1|Rato sem fios|2|9.9A encomenda 3 também é do cliente 1, mas custa 29.90€; fica de fora, porque não cumpre as duas condições ao mesmo tempo (combina-se mais do que uma condição com AND e OR).
JOIN
A pergunta mais comum em QA é do tipo "a que cliente pertence esta encomenda?", e essa resposta está espalhada por duas tabelas. JOIN junta linhas de duas tabelas com base numa coluna em comum:
sql
SELECT clientes.nome, encomendas.produto, encomendas.preco
FROM encomendas
JOIN clientes ON encomendas.cliente_id = clientes.id
WHERE clientes.nome = 'Marta Sousa';
-- Marta Sousa|Rato sem fios|9.9
-- Marta Sousa|Teclado|29.9ON diz ao SQL como ligar as duas tabelas: sem isto, não há forma de saber que cliente_id (em encomendas) e id (em clientes) representam a mesma coisa. JOIN e WHERE combinam-se sem problema na mesma query: primeiro junta as tabelas, só depois filtra o resultado.
05Ligar Python a uma Base de Dados
O objetivo em QA automation não é abrires uma consola SQL à mão cada vez que precisas de confirmar um dado. É o teu script fazer isso sozinho. Para ligares Python a uma base de dados real, vais usar sqlite3: uma base de dados simples, guardada num único ficheiro. Ao contrário do requests, não precisas de pip install nenhum: o módulo sqlite3 já vem embutido no Python.
python
import sqlite3
conn = sqlite3.connect("loja.db")
cursor = conn.cursor()
cursor.execute("SELECT nome, email FROM clientes")
resultado = cursor.fetchall()
print(resultado)
# [('Marta Sousa', 'marta.sousa@example.com'), ('Rui Pinto', 'rui.pinto@example.com'), ('Sofia Lima', 'sofia.lima@example.com')]
conn.close()sqlite3.connect("loja.db") abre uma ligação ao ficheiro: se loja.db não existisse ainda, o Python cria-o na hora, vazio, sem se queixar. Um pormenor a reconheceres: se correres o script na pasta errada, não vês um erro óbvio de "ficheiro não encontrado"; vês antes OperationalError: no such table, porque a base de dados acabada de criar está mesmo vazia. cursor é o objeto que corre as queries; .execute() corre a query; .fetchall() traz todas as linhas como uma lista de tuplos.
Uma última prática que vale a pena adotares já: quando um valor da query vem de fora (de uma variável), usa ? como marcador de posição, em vez de construíres a query com f-string:
python
cliente_id = 2
# Evita isto:
cursor.execute(f"SELECT nome FROM clientes WHERE id = {cliente_id}")
# Prefere isto:
cursor.execute("SELECT nome FROM clientes WHERE id = ?", (cliente_id,))Não é só estética
Construir a query por f-string, com um valor que podia vir de um utilizador ou de outro sistema, abre a porta a SQL injection, um valor disfarçado de dado normal que na verdade altera a query em si. Passar o valor à parte deixa o próprio sqlite3 tratar do valor com segurança.
06Validação de Dados Entre Base de Dados e Aplicação
Testar uma API, como no capítulo 2, confirma o que entra e sai do sistema. Não confirma o que fica para trás: os dados guardados na base de dados. Validação de dados é confirmar que a informação guardada é consistente com a que a aplicação mostra ao utilizador, um tipo de problema que testes de API sozinhos não apanham: a resposta pode ter tudo com bom aspeto e ainda assim esconder um valor desatualizado ou mal calculado.
A primeira técnica é pedir à própria base de dados para te apontar inconsistências. Um JOIN normal só devolve linhas que encontram correspondência nas duas tabelas: se uma encomenda tiver um cliente_id que não existe, essa encomenda desaparece silenciosamente do resultado. Para isso usa-se LEFT JOIN: mantém todas as linhas da tabela à esquerda, mesmo as que não encontram correspondência:
sql
SELECT encomendas.id, encomendas.cliente_id
FROM encomendas
LEFT JOIN clientes ON encomendas.cliente_id = clientes.id
WHERE clientes.id IS NULL;WHERE clientes.id IS NULL fica só com as linhas onde o LEFT JOIN não encontrou correspondência: as encomendas "órfãs". Repara em IS NULL, não = NULL: em SQL, NULL significa "desconhecido", e nada é = NULL.
A segunda técnica é comparar diretamente, em Python, o que a base de dados diz com o que a aplicação mostra, a mesma lógica de "esperado vs. obtido" do Curso 1, só que agora o "esperado" vem de uma query SQL:
python
resultado_bd = {
1: {"produto": "Rato sem fios", "total": 19.80},
}
saida_aplicacao = [
{"id": 1, "produto": "Rato sem fios", "total": 18.00},
]
for encomenda in saida_aplicacao:
id_encomenda = encomenda["id"]
esperado = resultado_bd[id_encomenda]
if encomenda["total"] != esperado["total"]:
print(f"DIFERENÇA id={id_encomenda}: BD diz {esperado['total']}, aplicação mostra {encomenda['total']}")
# DIFERENÇA id=1: BD diz 19.8, aplicação mostra 18.007Prática: Integridade e Validação
Antes deste exercício, cria um ficheiro criar_bd_exemplo.sql na mesma pasta, com este conteúdo. É uma loja simples: clientes e as encomendas que fizeram, com dois problemas de integridade escondidos de propósito.
sql
DROP TABLE IF EXISTS encomendas;
DROP TABLE IF EXISTS clientes;
CREATE TABLE clientes (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT NOT NULL
);
CREATE TABLE encomendas (
id INTEGER PRIMARY KEY,
cliente_id INTEGER NOT NULL,
produto TEXT NOT NULL,
quantidade INTEGER NOT NULL,
preco REAL NOT NULL
);
INSERT INTO clientes (id, nome, email) VALUES
(1, 'Ana Ferreira', 'ana.ferreira@example.com'),
(2, 'Bruno Costa', 'bruno.costa@example.com'),
(3, 'Carla Santos', 'carla.santos@example.com'),
(4, 'Diogo Almeida', 'diogo.almeida@example.com');
INSERT INTO encomendas (id, cliente_id, produto, quantidade, preco) VALUES
(1, 1, 'Teclado mecânico', 1, 29.90),
(2, 1, 'Rato sem fios', 2, 9.90),
(3, 2, 'Monitor 24"', 1, 139.90),
(4, 2, 'Auscultadores bluetooth', 1, 59.90),
(5, 3, 'Webcam HD', 1, 45.50),
(6, 4, 'Hub USB-C', 3, 19.90),
-- Problema de integridade propositado: não existe nenhum cliente com id 99.
(7, 99, 'Teclado mecânico', 1, 29.90),
-- Problema de integridade propositado: quantidade negativa não faz sentido.
(8, 3, 'Suporte para portátil', -1, 24.90);Para criar loja.db a partir deste ficheiro: sqlite3 loja.db < criar_bd_exemplo.sql no terminal. Sem sqlite3 instalado, o próprio script do exercício já trata disso na primeira linha, a partir do Python.
Exercício 1: Encontrar problemas de integridade
Objetivo: Escrever verificar_integridade.py, um script que corre queries sobre loja.db para detetar automaticamente encomendas com problemas de integridade.
- Liga-te a loja.db a partir do teu script, com o módulo sqlite3.
- Escreve uma query que encontre encomendas cujo cliente_id não corresponde a nenhum cliente: usa LEFT JOIN e WHERE ... IS NULL.
- Escreve uma segunda query que encontre encomendas com quantidade inválida (zero ou negativa): usa só WHERE.
- Para cada problema encontrado, imprime o id da encomenda e uma frase clara a dizer qual é o problema.
- No final, imprime um resumo com o número total de problemas encontrados.
Dica: As duas queries são independentes uma da outra: escreve, testa e confirma cada uma em separado; só depois as chamas as duas a partir do main().
Ver solução
import sqlite3
sqlite3.connect("loja.db").executescript(open("criar_bd_exemplo.sql", encoding="utf-8").read())
def encontrar_encomendas_orfas(cursor):
cursor.execute("""
SELECT encomendas.id, encomendas.cliente_id
FROM encomendas
LEFT JOIN clientes ON encomendas.cliente_id = clientes.id
WHERE clientes.id IS NULL
""")
return cursor.fetchall()
def encontrar_quantidades_invalidas(cursor):
cursor.execute("""
SELECT id, cliente_id, quantidade
FROM encomendas
WHERE quantidade <= 0
""")
return cursor.fetchall()
def main():
conn = sqlite3.connect("loja.db")
cursor = conn.cursor()
total_problemas = 0
for id_encomenda, cliente_id in encontrar_encomendas_orfas(cursor):
print(f"PROBLEMA encomenda id={id_encomenda}: cliente_id={cliente_id} não existe em clientes")
total_problemas += 1
for id_encomenda, cliente_id, quantidade in encontrar_quantidades_invalidas(cursor):
print(f"PROBLEMA encomenda id={id_encomenda}: quantidade inválida ({quantidade})")
total_problemas += 1
conn.close()
print(f"\n{total_problemas} problema(s) de integridade encontrado(s)")
if __name__ == "__main__":
main()Exercício 2: Comparar a base de dados com o output da aplicação
Objetivo: Escrever validacao_bd_aplicacao.py, um script que lê os dados reais de loja.db e compara-os, campo a campo, com o que uma aplicação mostra ao utilizador.
- Liga-te a loja.db e corre uma query com JOIN que devolva, por cada encomenda com cliente válido: id, nome do cliente, produto, quantidade e preço.
- No script, calcula o total de cada encomenda (quantidade × preço): não está guardado em nenhuma coluna.
- Usa uma lista fixa no teu script a simular o que a aplicação mostra numa página "As minhas encomendas".
- Para cada encomenda, compara cliente, produto, quantidade e total com o valor da base de dados. Reporta qualquer diferença campo a campo.
- No final, imprime um resumo: quantas encomendas coincidem, quantas têm diferenças.
Dica: Organiza primeiro os resultados da base de dados num dicionário indexado por id, a mesma ideia do comparador do Curso 1. Não presumas que só há um tipo de erro escondido.
Ver solução
import sqlite3
output_aplicacao = [
{"id": 1, "cliente": "Ana Ferreira", "produto": "Teclado mecânico", "quantidade": 1, "total": 29.90},
{"id": 2, "cliente": "Ana Ferreira", "produto": "Rato sem fios", "quantidade": 2, "total": 19.80},
]
def obter_encomendas_bd():
conn = sqlite3.connect("loja.db")
cursor = conn.cursor()
cursor.execute("""
SELECT encomendas.id, clientes.nome, encomendas.produto,
encomendas.quantidade, encomendas.preco
FROM encomendas
JOIN clientes ON encomendas.cliente_id = clientes.id
ORDER BY encomendas.id
""")
linhas = cursor.fetchall()
conn.close()
encomendas_bd = {}
for id_encomenda, cliente, produto, quantidade, preco in linhas:
encomendas_bd[id_encomenda] = {
"cliente": cliente,
"produto": produto,
"quantidade": quantidade,
"total": round(quantidade * preco, 2),
}
return encomendas_bd
def comparar(encomendas_bd, output_aplicacao):
iguais = 0
diferentes = 0
for encomenda_app in output_aplicacao:
id_encomenda = encomenda_app["id"]
encomenda_bd = encomendas_bd.get(id_encomenda)
if encomenda_bd is None:
print(f"DIFERENÇA id={id_encomenda}: a aplicação mostra uma encomenda que não existe na BD")
diferentes += 1
continue
diferencas_campos = []
for campo in ("cliente", "produto", "quantidade", "total"):
if encomenda_bd[campo] != encomenda_app[campo]:
diferencas_campos.append(campo)
if diferencas_campos:
print(f"DIFERENÇA id={id_encomenda}:")
for campo in diferencas_campos:
print(f" {campo}: BD={encomenda_bd[campo]!r}, aplicação={encomenda_app[campo]!r}")
diferentes += 1
else:
iguais += 1
return iguais, diferentes
def main():
encomendas_bd = obter_encomendas_bd()
iguais, diferentes = comparar(encomendas_bd, output_aplicacao)
print(f"\n{iguais} iguais, {diferentes} diferente(s)")
if __name__ == "__main__":
main()08Pytest: Estrutura Básica de Testes
Até aqui, cada teste que escreveste foi um script: uma função que só corria porque tu a chamavas dentro de um main(), um assert a seguir ao outro. O pytest é a ferramenta mais usada em Python para organizar e correr testes automaticamente: encontra os teus testes sozinho, corre cada um de forma independente, e no fim diz-te exatamente quantos passaram e quantos falharam.
bash
pip install pytestO pytest reconhece testes por convenção de nomes. Um ficheiro chama-se test_algo.py, e cada teste é uma função cujo nome começa por test_:
python
# test_operacoes.py
def dobrar(numero):
return numero * 2
def test_dobrar_numero_positivo():
assert dobrar(4) == 8
def test_dobrar_zero():
assert dobrar(0) == 0Repara no que falta: não há main(), não há chamadas manuais às funções de teste. Basta as funções existirem, com o prefixo certo, para o pytest as encontrar. E repara no que não mudou: assert é o mesmo assert do Curso 1, sem sintaxe nova para aprenderes.
bash
pytest========================= test session starts =========================
collected 2 items
test_operacoes.py .. [100%]
========================== 2 passed in 0.01s ==========================Acrescenta um terceiro teste, a falhar de propósito: assert dobrar(10) == 21. Repara em duas coisas no resultado: primeiro, o pytest mostra os valores exatos em causa sem precisares de escrever mensagem nenhuma; segundo, e mais importante, os outros dois testes continuaram a correr e a passar. Cada função test_ corre isolada das outras, e uma falhar não impede as restantes de chegarem ao fim, exatamente a lacuna que um assert solto (Curso 2, capítulo 2) não resolvia.
test_operacoes.py::test_dobrar_dez_valor_errado FAILED [100%]
_________________________ test_dobrar_dez_valor_errado _________________________
def test_dobrar_dez_valor_errado():
> assert dobrar(10) == 21
E assert 20 == 21
E + where 20 = dobrar(10)
1 failed, 2 passed in 0.02s09Relatórios de Execução, e Logging
Relatórios em HTML
O resumo no terminal chega-te a ti, mas não chega a um gestor de projeto ou a um colega que só quer confirmar o resultado sem correr nada. Vais usar pytest-html, um plugin que gera um relatório automaticamente a partir da própria corrida do pytest, sem tocares no código dos testes:
bash
pip install pytest-htmlbash
pytest --html=relatorio.html --self-contained-html--html=relatorio.html diz onde gravar; --self-contained-html garante que todo o estilo e comportamento fica embutido dentro desse único ficheiro. Sem essa flag, o pytest-html cria também uma pasta assets/ ao lado do relatório. Com ela, é um único ficheiro que podes anexar a um email.
Logging básico
Um print() diz-te uma coisa no momento em que corre, e desaparece assim que fechas o terminal. Numa suite com centenas de testes a correr todas as noites, precisas de um registo que fique guardado. É para isso que serve logging, já embutido no Python, tal como o sqlite3.
- DEBUG: detalhe só útil enquanto investigas algo de perto.
- INFO: progresso normal, ex.: "a iniciar testes de API".
- WARNING: algo estranho, mas que não impede o programa de continuar.
- ERROR: algo falhou mesmo.
- CRITICAL: o programa não consegue continuar de todo.
python
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
logging.debug("A validar linha 1") # não aparece: está abaixo do nível configurado
logging.info("A iniciar testes de API")
logging.warning("Resposta demorou 4.2s")
logging.error("Endpoint devolveu 500")Armadilha comum: se nunca chamares basicConfig(), o nível por definição é WARNING; logging.info(...) e logging.debug(...) não mostram nada, e o script parece não estar a fazer log nenhum.
Uma última ligação: o pytest aplica esta mesma ideia de nível aos teus logs dentro de testes. Por definição, só mostra o log de um teste que falhou, e só a partir de WARNING para cima. Um teste que passa não polui a saída; um teste que falha mostra-o automaticamente.
10Prática: o Teu Mini-Framework
Exercício 1: Reorganizar tudo como testes pytest
Objetivo: Pegar na suite de testes de API do capítulo 3 e nas verificações de integridade SQL do capítulo 7, e reorganizá-las como testes pytest: test_api.py e test_bd.py.
- Em test_api.py, reorganiza os 4 pedidos como 4 funções test_*, uma por pedido, sem main() nem print de confirmação.
- Em test_bd.py, recria uma versão pequena da base de dados com sqlite3.connect(":memory:"), sem ficheiro nenhum para criar ou limpar.
- Escreve 4 funções test_* sobre esses dados: encomendas órfãs, quantidades inválidas, um JOIN devolve o nome certo, e o total está calculado corretamente.
- Corre pytest e confirma que os 8 testes passam. Corre pytest -v e confirma que identificas, só pelo nome, o que cada teste verifica.
Dica: Isto é um exercício de organização, não de lógica nova. Dá a cada verificação uma função própria, com um nome que descreva o que testa.
Ver solução
# test_api.py
import requests
def test_get_post_unico():
resposta = requests.get("https://jsonplaceholder.typicode.com/posts/1")
post = resposta.json()
assert resposta.status_code == 200
assert post["id"] == 1
assert post["userId"] == 1
assert isinstance(post["title"], str) and post["title"] != ""
def test_get_lista_filtrada():
resposta = requests.get(
"https://jsonplaceholder.typicode.com/posts",
params={"userId": 1},
)
posts = resposta.json()
assert resposta.status_code == 200
assert len(posts) == 10
assert all(post["userId"] == 1 for post in posts)
def test_post_criar():
dados_novos = {"title": "Teste", "body": "Conteúdo", "userId": 10}
resposta = requests.post("https://jsonplaceholder.typicode.com/posts", json=dados_novos)
post_criado = resposta.json()
assert resposta.status_code == 201
assert post_criado["title"] == dados_novos["title"]
assert "id" in post_criado
def test_get_post_inexistente():
resposta = requests.get("https://jsonplaceholder.typicode.com/posts/99999")
assert resposta.status_code == 404
# test_bd.py
import sqlite3
def obter_conexao_teste():
conn = sqlite3.connect(":memory:")
cursor = conn.cursor()
cursor.executescript("""
CREATE TABLE clientes (id INTEGER PRIMARY KEY, nome TEXT, email TEXT);
CREATE TABLE encomendas (id INTEGER PRIMARY KEY, cliente_id INTEGER, produto TEXT, quantidade INTEGER, preco REAL);
INSERT INTO clientes (id, nome, email) VALUES
(1, 'Marta Sousa', 'marta.sousa@example.com'),
(2, 'Rui Pinto', 'rui.pinto@example.com');
INSERT INTO encomendas (id, cliente_id, produto, quantidade, preco) VALUES
(1, 1, 'Rato sem fios', 2, 9.90),
(2, 2, 'Monitor', 1, 149.90);
""")
conn.commit()
return conn
def test_todas_as_encomendas_tem_cliente_valido():
conn = obter_conexao_teste()
cursor = conn.cursor()
cursor.execute("""
SELECT encomendas.id FROM encomendas
LEFT JOIN clientes ON encomendas.cliente_id = clientes.id
WHERE clientes.id IS NULL
""")
orfas = cursor.fetchall()
conn.close()
assert orfas == [], f"há encomendas com cliente inexistente: {orfas}"
def test_todas_as_quantidades_sao_validas():
conn = obter_conexao_teste()
cursor = conn.cursor()
cursor.execute("SELECT id FROM encomendas WHERE quantidade <= 0")
invalidas = cursor.fetchall()
conn.close()
assert invalidas == [], f"há encomendas com quantidade inválida: {invalidas}"
def test_join_devolve_nome_do_cliente_correto():
conn = obter_conexao_teste()
cursor = conn.cursor()
cursor.execute("""
SELECT clientes.nome FROM encomendas
JOIN clientes ON encomendas.cliente_id = clientes.id
WHERE encomendas.id = 1
""")
resultado = cursor.fetchone()
conn.close()
assert resultado[0] == "Marta Sousa"
def test_total_da_encomenda_e_quantidade_vezes_preco():
conn = obter_conexao_teste()
cursor = conn.cursor()
cursor.execute("SELECT quantidade, preco FROM encomendas WHERE id = 1")
quantidade, preco = cursor.fetchone()
conn.close()
total = round(quantidade * preco, 2)
assert total == 19.80, f"esperava 19.80, calculei {total}"Exercício 2: Gerar relatório automático de execução em HTML
Objetivo: Instalar o pytest-html e gerar, a partir dos testes do exercício anterior, um relatório HTML autocontido da corrida completa.
- Instala o plugin: pip install pytest-html.
- A partir da pasta com test_api.py e test_bd.py, corre pytest --html=relatorio.html --self-contained-html.
- Confirma que relatorio.html foi criado, e que não aparece nenhuma pasta assets/ ao lado dele.
- Cria um ficheiro temporário test_demo_falha.py com uma função que falhe de propósito, gera o relatório outra vez, confirma no browser que a falha aparece destacada, e depois apaga esse ficheiro e gera o relatório uma última vez, já limpo.
- Corre uma vez sem a flag --self-contained-html e confirma que aparece mesmo uma pasta assets/.
Dica: Não precisas de servidor nem de configuração para abrires o relatório; é um ficheiro .html como outro qualquer.
Ver solução
pip install pytest-html
pytest --html=relatorio.html --self-contained-html
# test_demo_falha.py (apagar depois)
# def test_falha_de_proposito():
# assert 1 == 2, "falha de propósito, só para ver o relatório"
pytest --html=relatorio.html --self-contained-html
# 1 failed, 8 passed in 1.05s
# sem a flag --self-contained-html, aparece uma pasta assets/ ao lado:
pytest --html=relatorio_sem_flag.htmlO teu progresso
Marca os exercícios à medida que os fores fazendo a sério, não só a ler a solução. Fica guardado só neste browser.
Conclusão
Em seis semanas: chamar uma API e validar o que ela devolve, escrever SQL a sério, ligar Python a uma base de dados, apanhar inconsistências entre o que está guardado e o que a aplicação mostra, e organizar tudo com pytest, com relatório e logging incluídos. É o suficiente para testares um sistema real, não só ficheiros na tua própria pasta.
Se fizeste os seis exercícios a sério, tens agora um mini-framework de testes: dois ficheiros pytest, oito testes, um relatório HTML gerado automaticamente. É literalmente o que a saída deste curso promete.
Se ficares só com uma decisão deste curso, fica com esta: da próxima vez que escreveres um script de verificação avulso, pergunta-te se não devia antes ser uma função test_ dentro de um ficheiro que o pytest já sabe encontrar.
Se ainda não fizeste o primeiro, está aqui: Fundamentos de Automação de Testes com Python. E o Curso 3 já está aqui: Automação Avançada: UI, CI/CD e Performance.
Isto ajudou-te?
Fontes, e o que é só observação minha
O resto
- É o segundo de quatro cursos que escrevi, a continuar o programa a partir do Curso 1. Não é investigação, é o programa com que ensino automação de testes a sério, calibrado módulo a módulo.