Testes — Garantia de Qualidade Ponta-a-Ponta com pytest
Testar é como uma rede de segurança — você não a vê enquanto caminha na corda bamba (escrevendo código), mas se escorregar (encontrar um bug), ela é sua única salvação. Uma apresentação sem rede de segurança está fadada a terminar em acidente mais cedo ou mais tarde.
1. O Que Você Vai Aprender
- Fundamentos do
TestClient(httpx): Cliente de Teste Síncrono/Assíncrono - Isolamento do banco de dados de teste: Usar um banco de dados separado para cada teste, gerenciado via fixtures
- Sobrescrita de Dependência:
app.dependency_overridesSubstituir Dependências de DB/Autenticação - Pirâmide de Testes: Uma Estratégia em Camadas de Testes Unitários → Integração → E2E
- Cenário Alice: Suíte de Testes Completa do PriceTracker
2. A História Real da Alice
(1) Problema: Descobrindo Bugs Apenas Após o Lançamento
Depois que Alice implantou o PriceTracker, descobriu que o tempo de expiração do token JWT estava configurado para 1 segundo, importações em lote estavam pulando todas as validações, e a limitação de taxa para usuários Pro era a mesma que para usuários Free. Toda vez que corrigia um bug, introduzia um novo, e Bob reclamava: "O recurso que funcionava na semana passada não funciona mais esta semana." Alice não tinha testes automatizados; dependia inteiramente de clicar manualmente pelo Swagger UI, e cada teste de regressão levava duas horas.
(2) Soluções para Testes Automatizados com pytest
pytest + httpx TestClient garante que cada endpoint da API tenha testes automatizados; dependency_overrides substitui o banco de dados de produção pelo banco de dados de teste; e toda vez que git push é executado, todos os testes são executados automaticamente, detectando todos os problemas de regressão em 2 minutos.
def test_create_product(client):
response = client.post("/api/v1/products", json={"name": "Widget", "price": 9.99})
assert response.status_code == 201
assert response.json()["name"] == "Widget"
(3) Resultado
O teste de regressão passou de um processo manual de 2 horas para um automatizado de 2 minutos; um bug de expiração JWT foi capturado durante os testes na fase de desenvolvimento; e após a implantação, o número de bugs caiu de 5 por semana para 1 por mês.
3. Fundamentos do TestClient
(1) A Pirâmide de Testes
graph TD
E2E[Testes E2E - Poucos] --> INT[Testes de Integração - Médio]
INT --> UNIT[Testes Unitários - Muitos]
UNIT --- U1[Validação de Modelos Pydantic]
UNIT --- U2[Funções do Repository]
UNIT --- U3[Lógica de Serviço]
INT --- I1[Endpoint da API + DB]
INT --- I2[Fluxo de Autenticação]
INT --- I3[Operações CRUD]
E2E --- E1[Jornada Completa do Usuário]
E2E --- E2[WebSocket + API]
| Nível | Quantidade | Velocidade | Dependências |
|---|---|---|---|
| Testes Unitários | Muitos (100+) | Rápido (< 1 ms) | Sem Dependências Externas |
| Testes de Integração | Médio (30-50) | Médio (10-100 ms) | Banco de Dados de Teste |
| Testes E2E | Poucos (5-10) | Lento (1-5 s) | Ambiente Completo |
(1) ▶Exemplo: Fixture Básica do TestClient
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.core.deps import get_db
# Sobrescrever dependência do banco de dados
def get_test_db():
# Usar SQLite em memória para testes
engine = create_async_engine("sqlite+aiosqlite:///test.db")
# ... configuração da sessão
yield session
# ... limpeza
@pytest.fixture
def client():
app.dependency_overrides[get_db] = get_test_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
Saída:
# Função definida com sucesso
(2) ▶Exemplo: Teste Básico de Endpoint
# tests/test_health.py
def test_health_check(client):
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "service" in data
def test_openapi_docs_available(client):
response = client.get("/docs")
assert response.status_code == 200
Saída:
# Função definida com sucesso
4. Isolamento do Banco de Dados de Teste
(1) Um banco de dados separado para cada teste
(1) ▶Exemplo: Fixture do banco de dados de teste
# tests/conftest.py
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.models import Base
TEST_DATABASE_URL = "sqlite+aiosqlite:///test_pricetracker.db"
@pytest.fixture(scope="function")
async def test_db():
# Criar banco de dados de teste novo para cada teste
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
TestSession = async_sessionmaker(engine, expire_on_commit=False)
async with TestSession() as session:
yield session
# Remover todas as tabelas após o teste
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
Saída:
# Função definida com sucesso
(2) ▶Exemplo: Cliente de Teste Assíncrono
# tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def async_client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
Saída:
# Função definida com sucesso
(2) Comparação de Estratégias de Isolamento
| Estratégia | Velocidade | Isolamento | Aplicabilidade |
|---|---|---|---|
| Criar nova tabela para cada teste | Lento | Totalmente isolado | Teste de integridade de dados |
| Rollback de Transação | Rápido | Bom | Maioria dos testes de integração |
| SQLite em Memória | Rápido | Bom | Testes que não dependem de recursos do PostgreSQL |
5. Técnicas para Sobrescrever Dependências
(1) Substituir a autenticação e o banco de dados
(1) ▶Exemplo: Sobrescrevendo Dependências de Autenticação
# tests/conftest.py
from app.core.deps import get_current_user, get_db
def get_test_user():
"""Usuário autenticado simulado para testes"""
return {"id": 1, "email": "alice@test.com", "role": "admin", "subscription": "pro"}
@pytest.fixture
def auth_client(client):
# Sobrescrever autenticação - sem JWT real necessário
app.dependency_overrides[get_current_user] = get_test_user
yield client
app.dependency_overrides.clear()
Saída:
# Função definida com sucesso
(2) ▶Exemplo: Testando Endpoints Protegidos
# tests/test_products.py
def test_list_products_unauthorized(client):
"""Sem token de autenticação, deve retornar 401"""
response = client.get("/api/v1/products")
assert response.status_code == 401
def test_list_products_authorized(auth_client):
"""Com autenticação simulada, deve retornar 200"""
response = auth_client.get("/api/v1/products")
assert response.status_code == 200
def test_create_product(auth_client):
response = auth_client.post(
"/api/v1/products",
json={"name": "Widget", "category": "electronics", "base_price": 29.99},
)
assert response.status_code == 201
assert response.json()["name"] == "Widget"
Saída:
# Função definida com sucesso
(3) ▶Exemplo: Sobrescrevendo Permissões de Teste por Nível de Assinatura
def get_free_user():
return {"id": 2, "email": "free@test.com", "role": "user", "subscription": "free"}
def get_pro_user():
return {"id": 3, "email": "pro@test.com", "role": "user", "subscription": "pro"}
def test_bulk_import_free_user_limited(client):
"""Usuários Free podem importar no máximo 1000 preços"""
app.dependency_overrides[get_current_user] = get_free_user
prices = [{"product_id": i, "price": 9.99} for i in range(1500)]
response = client.post("/api/v1/prices/bulk", json=prices)
assert response.status_code == 403
assert "limit" in response.json()["detail"].lower()
app.dependency_overrides.clear()
def test_bulk_import_pro_user(client):
"""Usuários Pro podem importar até 100000 preços"""
app.dependency_overrides[get_current_user] = get_pro_user
prices = [{"product_id": i, "price": 9.99} for i in range(5000)]
response = client.post("/api/v1/prices/bulk", json=prices)
assert response.status_code == 201
app.dependency_overrides.clear()
Saída:
# Função definida com sucesso
6. Exemplo de uma Suíte de Testes Completa
(1) ▶Exemplo: Teste Unitário de Modelos Pydantic
# tests/test_models.py
import pytest
from pydantic import ValidationError
from app.schemas import ProductCreate, PriceCreate
def test_product_create_valid():
p = ProductCreate(name="Widget", category="electronics", base_price=29.99)
assert p.name == "Widget"
assert p.base_price == 29.99
def test_product_create_negative_price():
with pytest.raises(ValidationError) as exc:
ProductCreate(name="Widget", category="electronics", base_price=-1)
assert "greater than 0" in str(exc.value)
def test_product_create_empty_name():
with pytest.raises(ValidationError):
ProductCreate(name="", category="electronics", base_price=9.99)
def test_price_create_rounds_precision():
p = PriceCreate(product_id=1, price=9.999, currency="USD", source="test")
assert p.price == 10.0 # Arredondado para 2 casas decimais
def test_price_create_invalid_currency():
with pytest.raises(ValidationError):
PriceCreate(product_id=1, price=9.99, currency="XYZ", source="test")
Saída:
# Função definida com sucesso
(2) ▶Exemplo: Teste de Integração para Operações CRUD
# tests/test_crud.py
import pytest
from fastapi.testclient import TestClient
def test_product_crud_lifecycle(auth_client):
# Criar
create_resp = auth_client.post(
"/api/v1/products",
json={"name": "Test Widget", "category": "electronics", "base_price": 19.99},
)
assert create_resp.status_code == 201
product_id = create_resp.json()["id"]
# Ler
get_resp = auth_client.get(f"/api/v1/products/{product_id}")
assert get_resp.status_code == 200
assert get_resp.json()["name"] == "Test Widget"
# Atualizar
update_resp = auth_client.put(
f"/api/v1/products/{product_id}",
json={"name": "Updated Widget", "base_price": 24.99},
)
assert update_resp.status_code == 200
assert update_resp.json()["base_price"] == 24.99
# Deletar
delete_resp = auth_client.delete(f"/api/v1/products/{product_id}")
assert delete_resp.status_code == 200
# Verificar se foi deletado
get_resp2 = auth_client.get(f"/api/v1/products/{product_id}")
assert get_resp2.status_code == 404
Saída:
# Função definida com sucesso
❓Perguntas Frequentes
function (recriado para cada teste) é o mais seguro; session (compartilhado em toda a sessão de teste) é o mais rápido mas oferece isolamento inferior. Use function para o banco de dados e function para o TestClient.AsyncClient.websocket_connect() para estabelecer uma conexão, e envie e receba mensagens para verificar seu comportamento.pytest-xdist para execução paralela (pytest -n auto), use rollback de transação em vez de criar tabelas, e use testes unitários em vez de testes de integração.dependency_overrides afetará outros testes?app.dependency_overrides.clear() após o yield na fixture.📖Resumo
- Pirâmide de Testes: Testes Unitários (Frequentes e Rápidos) → Testes de Integração (Moderados) → E2E (Menos Frequentes e Mais Lentos)
- TestClient é usado para testar endpoints síncronos simples, enquanto AsyncClient é usado para testar lógica assíncrona (como WebSockets)
- Cada teste usa um banco de dados separado; fixtures gerenciam criação e limpeza
app.dependency_overridesSubstitui dependências de autenticação e banco de dados; sem JWT ou PG real necessário- Cobertura abrangente de testes: validação Pydantic (unitário), fluxos de trabalho CRUD (integração), controle de acesso (integração)
📝Exercícios
- Exercício Básico (Dificuldade ⭐): Use TestClient para escrever um teste para o endpoint
/healthpara verificar se ele retorna um código de status 200 e a estrutura JSON correta. Dica:TestClient(app)+client.get("/health") - Problema Avançado (Dificuldade ⭐⭐): Escreva testes de ciclo de vida CRUD — criar, consultar, atualizar e deletar produtos — e verifique os códigos de status e dados retornados em cada etapa. Use
dependency_overridespara pular a autenticação. Dica:app.dependency_overrides[get_current_user] = mock_fn - Desafio (Dificuldade ⭐⭐⭐): Implemente uma suíte de testes completa: testes de validação Pydantic (dados inválidos geram
ValidationError), testes de autenticação (401 sem token, 403 para usuários Free, 200 para usuários Pro), e testes de paginação (retorno correto dos valoresskipelimit). Dica:pytest.raises(ValidationError)+ múltiplas fixturesdependency_overrides
---|



