Por que Testar APIs com Python?
Testar uma API é tão importante quanto desenvolvê-la. Um código sem testes é como dirigir de olhos fechados: você pode chegar ao destino, mas as chances de bater são enormes. Quando falamos de APIs REST, os testes garantem que seus endpoints respondem corretamente, tratam erros adequadamente e mantêm a compatibilidade com clientes que dependem deles.
A combinação de pytest, httpx e TestClient do FastAPI oferece um toolkit poderoso e elegante para isso. O pytest fornece a estrutura de testes limpa e extensível; o httpx é um cliente HTTP assíncrono moderno que funciona perfeitamente com código async; e o TestClient é a ferramenta nativa do FastAPI que permite testar sua aplicação sem precisar subir um servidor real. Juntos, eles eliminam a fricção entre desenvolvimento e testes, permitindo que você valide comportamentos complexos com poucas linhas de código.
Configuração do Ambiente e Dependências
Antes de escrever o primeiro teste, você precisa preparar o ambiente corretamente. A instalação é simples, mas cada dependência tem um propósito bem definido.
Instalando as Dependências Necessárias
Comece criando um arquivo requirements.txt ou usando pip diretamente:
pip install fastapi uvicorn pytest pytest-asyncio httpx
Se você usa poetry (recomendado para projetos maiores), o comando é:
poetry add fastapi uvicorn pytest pytest-asyncio httpx
O pytest-asyncio é crucial porque permite que o pytest execute corrotinas async nativamente. Sem ele, seus testes assincronos falharão.
Estrutura de Diretórios
Organize seu projeto assim:
projeto/
├── app/
│ ├── __init__.py
│ ├── main.py # Sua aplicação FastAPI
│ └── models.py # Modelos Pydantic
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Configurações compartilhadas
│ └── test_api.py # Seus testes
├── requirements.txt
└── pytest.ini
Crie um arquivo pytest.ini na raiz do projeto para configurar comportamentos padrão:
[pytest]
asyncio_mode = auto
python_files = test_*.py
python_classes = Test*
python_functions = test_*
A opção asyncio_mode = auto permite que o pytest detecte automaticamente quais testes são assincronos.
Escrevendo Sua Primeira API com FastAPI
Antes de testar, você precisa de algo para testar. Vamos criar uma API simples mas realista.
Exemplo: API de Tarefas
Crie o arquivo app/main.py:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Optional
app = FastAPI(title="API de Tarefas")
class Tarefa(BaseModel):
id: Optional[int] = None
titulo: str
descricao: str = ""
concluida: bool = False
# Simulando um banco de dados em memória
tarefas_db = []
tarefa_id_counter = 1
@app.get("/")
async def root():
return {"mensagem": "Bem-vindo à API de Tarefas"}
@app.post("/tarefas", response_model=Tarefa)
async def criar_tarefa(tarefa: Tarefa):
global tarefa_id_counter
tarefa.id = tarefa_id_counter
tarefa_id_counter += 1
tarefas_db.append(tarefa)
return tarefa
@app.get("/tarefas", response_model=List[Tarefa])
async def listar_tarefas(concluida: Optional[bool] = None):
if concluida is None:
return tarefas_db
return [t for t in tarefas_db if t.concluida == concluida]
@app.get("/tarefas/{tarefa_id}", response_model=Tarefa)
async def obter_tarefa(tarefa_id: int):
for tarefa in tarefas_db:
if tarefa.id == tarefa_id:
return tarefa
raise HTTPException(status_code=404, detail="Tarefa não encontrada")
@app.put("/tarefas/{tarefa_id}", response_model=Tarefa)
async def atualizar_tarefa(tarefa_id: int, tarefa_atualizada: Tarefa):
for i, tarefa in enumerate(tarefas_db):
if tarefa.id == tarefa_id:
tarefa_atualizada.id = tarefa_id
tarefas_db[i] = tarefa_atualizada
return tarefa_atualizada
raise HTTPException(status_code=404, detail="Tarefa não encontrada")
@app.delete("/tarefas/{tarefa_id}")
async def deletar_tarefa(tarefa_id: int):
for i, tarefa in enumerate(tarefas_db):
if tarefa.id == tarefa_id:
tarefas_db.pop(i)
return {"mensagem": "Tarefa deletada com sucesso"}
raise HTTPException(status_code=404, detail="Tarefa não encontrada")
Agora você tem uma API CRUD completa. Os endpoints cobrem casos de sucesso e erro, o que é perfeito para demonstrar testes robustos.
Testando com pytest, httpx e TestClient
Aqui vem a parte essencial: como testar esses endpoints de forma profissional.
Configurando o TestClient do FastAPI
Crie o arquivo tests/conftest.py:
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture(scope="function")
def client():
"""
Fixture que fornece um TestClient para cada teste.
scope="function" garante que o estado é resetado entre testes.
"""
return TestClient(app)
@pytest.fixture(autouse=True)
def limpar_banco_dados():
"""
Fixture que limpa o banco de dados em memória antes de cada teste.
autouse=True faz isso rodar automaticamente.
"""
from app.main import tarefas_db
yield # Executa o teste
tarefas_db.clear() # Limpa após o teste
O conftest.py é um arquivo especial do pytest. As fixtures definidas aqui estão disponíveis para todos os testes do projeto. O TestClient é uma wrapper do httpx que sabe como testar FastAPI sem precisar de um servidor real em execução.
Testando Endpoints com Casos de Sucesso
Crie o arquivo tests/test_api.py:
import pytest
from fastapi.testclient import TestClient
def test_root(client):
"""Testa se a rota raiz retorna 200 e a mensagem correta."""
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"mensagem": "Bem-vindo à API de Tarefas"}
def test_criar_tarefa(client):
"""Testa criação de uma tarefa com dados válidos."""
payload = {
"titulo": "Estudar FastAPI",
"descricao": "Aprender testes com pytest",
"concluida": False
}
response = client.post("/tarefas", json=payload)
assert response.status_code == 200
data = response.json()
assert data["titulo"] == "Estudar FastAPI"
assert data["id"] == 1 # ID atribuído automaticamente
assert data["concluida"] is False
def test_listar_tarefas_vazio(client):
"""Testa listagem quando nenhuma tarefa existe."""
response = client.get("/tarefas")
assert response.status_code == 200
assert response.json() == []
def test_listar_tarefas_com_dados(client):
"""Testa listagem após criar tarefas."""
# Cria duas tarefas
client.post("/tarefas", json={"titulo": "Tarefa 1", "descricao": ""})
client.post("/tarefas", json={"titulo": "Tarefa 2", "descricao": ""})
response = client.get("/tarefas")
assert response.status_code == 200
tarefas = response.json()
assert len(tarefas) == 2
assert tarefas[0]["titulo"] == "Tarefa 1"
assert tarefas[1]["titulo"] == "Tarefa 2"
def test_obter_tarefa_especifica(client):
"""Testa recuperação de uma tarefa pelo ID."""
# Cria uma tarefa
criar_response = client.post("/tarefas", json={"titulo": "Tarefa Test"})
tarefa_id = criar_response.json()["id"]
# Recupera essa tarefa
response = client.get(f"/tarefas/{tarefa_id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == tarefa_id
assert data["titulo"] == "Tarefa Test"
def test_atualizar_tarefa(client):
"""Testa atualização de uma tarefa existente."""
# Cria uma tarefa
criar_response = client.post("/tarefas", json={"titulo": "Original", "concluida": False})
tarefa_id = criar_response.json()["id"]
# Atualiza a tarefa
payload = {
"titulo": "Atualizada",
"descricao": "Descrição nova",
"concluida": True
}
response = client.put(f"/tarefas/{tarefa_id}", json=payload)
assert response.status_code == 200
data = response.json()
assert data["titulo"] == "Atualizada"
assert data["concluida"] is True
def test_deletar_tarefa(client):
"""Testa exclusão de uma tarefa."""
# Cria uma tarefa
criar_response = client.post("/tarefas", json={"titulo": "Para Deletar"})
tarefa_id = criar_response.json()["id"]
# Deleta a tarefa
response = client.delete(f"/tarefas/{tarefa_id}")
assert response.status_code == 200
# Verifica que foi realmente deletada
get_response = client.get(f"/tarefas/{tarefa_id}")
assert get_response.status_code == 404
Estes testes cobrem o "caminho feliz" — quando tudo funciona corretamente. Note que cada teste é independente graças à fixture limpar_banco_dados.
Testando Casos de Erro e Validação
Agora vamos testar o que acontece quando algo dá errado:
def test_obter_tarefa_inexistente(client):
"""Testa erro 404 ao tentar acessar tarefa que não existe."""
response = client.get("/tarefas/999")
assert response.status_code == 404
data = response.json()
assert "detail" in data
assert "não encontrada" in data["detail"]
def test_atualizar_tarefa_inexistente(client):
"""Testa erro 404 ao tentar atualizar tarefa que não existe."""
payload = {"titulo": "Nova", "descricao": "", "concluida": False}
response = client.put("/tarefas/999", json=payload)
assert response.status_code == 404
def test_deletar_tarefa_inexistente(client):
"""Testa erro 404 ao tentar deletar tarefa que não existe."""
response = client.delete("/tarefas/999")
assert response.status_code == 404
def test_criar_tarefa_campo_obrigatorio_faltando(client):
"""Testa validação: titulo é obrigatório."""
payload = {"descricao": "Sem titulo"}
response = client.post("/tarefas", json=payload)
assert response.status_code == 422 # Validation Error
data = response.json()
assert "detail" in data
def test_listar_tarefas_filtro_concluidas(client):
"""Testa filtro por status de conclusão."""
# Cria tarefas com status diferentes
client.post("/tarefas", json={"titulo": "Completa", "concluida": True})
client.post("/tarefas", json={"titulo": "Incompleta", "concluida": False})
# Filtra apenas as completas
response = client.get("/tarefas?concluida=true")
assert response.status_code == 200
tarefas = response.json()
assert len(tarefas) == 1
assert tarefas[0]["titulo"] == "Completa"
Observe que os testes de erro verificam não apenas o código de status, mas também a estrutura da resposta. Isso garante que seu cliente consiga interpretar o erro corretamente.
Organizando Testes com Classes e Parametrização
Conforme seu projeto cresce, agrupamentos lógicos ficam essenciais. Use classes para organizar testes relacionados:
class TestCriacaoDeTarefas:
"""Agrupa testes relacionados à criação de tarefas."""
def test_criar_com_todos_campos(self, client):
payload = {
"titulo": "Tarefa Completa",
"descricao": "Com descrição",
"concluida": False
}
response = client.post("/tarefas", json=payload)
assert response.status_code == 200
assert all(key in response.json() for key in ["id", "titulo", "descricao", "concluida"])
def test_criar_com_apenas_titulo(self, client):
payload = {"titulo": "Minimalista"}
response = client.post("/tarefas", json=payload)
assert response.status_code == 200
data = response.json()
assert data["descricao"] == ""
assert data["concluida"] is False
class TestFiltrosDeTarefas:
"""Agrupa testes relacionados a filtros e listagem."""
@pytest.mark.parametrize("concluida,esperado", [
(True, 2),
(False, 1),
(None, 3),
])
def test_filtro_por_conclusao(self, client, concluida, esperado):
"""Parametrizado para testar múltiplos valores de filtro."""
# Setup: cria 2 concluídas e 1 não concluída
client.post("/tarefas", json={"titulo": "T1", "concluida": True})
client.post("/tarefas", json={"titulo": "T2", "concluida": True})
client.post("/tarefas", json={"titulo": "T3", "concluida": False})
# Act
url = "/tarefas" if concluida is None else f"/tarefas?concluida={str(concluida).lower()}"
response = client.get(url)
# Assert
assert response.status_code == 200
assert len(response.json()) == esperado
A parametrização com @pytest.mark.parametrize executa o mesmo teste múltiplas vezes com diferentes valores, reduzindo duplicação de código significativamente.
Testes Assincronos com httpx
Até agora usamos TestClient, que é síncrono. Para cenários mais avançados — como testar múltiplas requisições em paralelo ou integrar com código que usa asyncio nativamente — você pode usar httpx diretamente.
Usando httpx para Requisições Assincronos
Crie um arquivo tests/test_api_async.py:
import pytest
import httpx
from app.main import app, tarefas_db
@pytest.fixture
async def async_client():
"""Fixture que fornece um cliente httpx assincronista."""
async with httpx.AsyncClient(app=app, base_url="http://test") as client:
yield client
@pytest.mark.asyncio
async def test_criar_multiplas_tarefas_paralelo(async_client):
"""
Demonstra requisições assincronistas em paralelo.
Isso é muito mais rápido do que fazer requisições sequenciais.
"""
# Cria 5 tarefas simultaneamente
tasks = [
async_client.post("/tarefas", json={"titulo": f"Tarefa {i}"})
for i in range(5)
]
responses = await asyncio.gather(*tasks)
# Verifica que todas foram criadas
assert all(r.status_code == 200 for r in responses)
assert len(tarefas_db) == 5
@pytest.mark.asyncio
async def test_listar_e_obter_sequencial(async_client):
"""Exemplo de múltiplas requisições que dependem uma da outra."""
# Cria uma tarefa
criar_response = await async_client.post(
"/tarefas",
json={"titulo": "Async Test", "descricao": "Testando async"}
)
tarefa_id = criar_response.json()["id"]
# Lista todas as tarefas
listar_response = await async_client.get("/tarefas")
assert len(listar_response.json()) == 1
# Obtém a tarefa específica
obter_response = await async_client.get(f"/tarefas/{tarefa_id}")
assert obter_response.status_code == 200
assert obter_response.json()["titulo"] == "Async Test"
Note que você precisa fazer import asyncio no topo do arquivo. A fixture async_client usa um context manager (async with) para garantir que a conexão seja fechada corretamente após cada teste.
Quando Usar httpx vs TestClient
- Use TestClient para a maioria dos testes de API FastAPI. É mais simples, mais rápido e integrado perfeitamente.
- Use httpx quando precisar testar requisições paralelas, quando quiser testar contra servidores externos, ou quando estiver testando código que é inerentemente assincronista.
A regra de ouro: comece com TestClient e migre para httpx apenas se tiver uma razão específica.
Executando os Testes e Interpretando Resultados
Você já tem os testes prontos. Agora precisa executá-los e entender os resultados.
Executando com pytest
Na raiz do projeto, execute:
# Executa todos os testes
pytest
# Executa com output detalhado
pytest -v
# Executa um arquivo específico
pytest tests/test_api.py
# Executa uma classe específica
pytest tests/test_api.py::TestCriacaoDeTarefas
# Executa um teste específico
pytest tests/test_api.py::TestCriacaoDeTarefas::test_criar_com_todos_campos
# Executa com coverage (se tiver pytest-cov instalado)
pytest --cov=app --cov-report=html
Interpretando Falhas
Quando um teste falha, o pytest mostra:
FAILED tests/test_api.py::test_criar_tarefa - AssertionError: assert 201 == 200
Isso diz que o status code retornou 201 (Created) quando você esperava 200 (OK). Verifique sua implementação. Este é um exemplo fictício, mas demonstra como o pytest é descritivo.
Melhores Práticas de Assertion
Escreva assertions claras e específicas:
# ❌ Ruim: pouco informativo
assert response
# ✅ Bom: específico e claro
assert response.status_code == 200
assert response.json()["titulo"] == "Esperado"
assert len(response.json()) > 0
Use pytest.raises para testar exceções (em casos raros onde isso faz sentido):
@pytest.mark.asyncio
async def test_erro_validacao_com_fastapi():
"""Testa se FastAPI levanta erro de validação corretamente."""
client = TestClient(app)
response = client.post("/tarefas", json={})
assert response.status_code == 422
Conclusão
Dominar testes de APIs em Python com pytest, httpx e TestClient do FastAPI é uma habilidade que transforma sua confiança no código. Você aprendeu que: (1) o TestClient oferece a forma mais prática de testar FastAPI, eliminando a necessidade de servidor externo; (2) fixtures do pytest são poderosas para reutilizar setup e teardown entre testes, reduzindo duplicação; (3) a parametrização e organização com classes mantêm seus testes escaláveis conforme o projeto cresce.
A jornada não termina aqui. Explore integração contínua (CI/CD) para rodar esses testes automaticamente, implemente cobertura de código com pytest-cov para medir quantas linhas estão sendo testadas, e considere usar ferramentas como Postman ou Bruno para validar comportamentos complexos em endpoints. Os testes que você escreve hoje são a base sólida para refatorações confiantes amanhã.