Dezenas de Testes de Backend em Um PR: Nossa Estratégia de Testes para FastAPI
A descrição do pull request dizia: “Adicionar suíte de testes – 52 testes cobrindo auth, camada de dados e superfície da API.” Ele chegou três meses após o backend FastAPI que cobria já estar em produção. Não é assim que os testes deveriam funcionar, e não é algo para comemorar. Mas vale examinar honestamente, porque “testes adicionados tarde” é uma situação materialmente diferente de “testes nunca adicionados”, e a distância entre eles é exatamente a distância entre uma base de código que você consegue manter e uma que eventualmente você vai reescrever.
Este post descreve a arquitetura de testes que adotamos, os padrões específicos que se mostraram duráveis em um projeto FastAPI real, e o que o PR realmente contém além da contagem de testes.
O contexto do projeto
O backend é um serviço FastAPI que gerencia operações voltadas ao cliente para um operador de e-commerce: gestão de pedidos, consultas de estoque, regras de precificação e uma integração com uma API de logística terceirizada. O banco de dados é PostgreSQL via SQLAlchemy com suporte assíncrono. A autenticação é baseada em JWT, com tokens validados contra um serviço de autenticação separado. Três APIs externas são chamadas durante a operação normal: o provedor de logística, um serviço de conversão de câmbio e um feed de dados de produtos.
Três meses de operação em produção significaram três meses de correções de bugs, adições de funcionalidades e mudanças incrementais – tudo sem testes. A base de código não era impossível de testar. Havia sido construída com testabilidade em mente: dependências injetadas, sem estado global, sessões de banco de dados criadas por requisição via sistema de injeção de dependência do FastAPI. Os testes simplesmente nunca tinham sido escritos. Um PR focado foi o veículo certo – não porque enviar cobertura de testes em lote seja um padrão a recomendar, mas porque “um PR com testes” é revisado, mesclado e mantido. “Testes adicionados ao longo do tempo” tende a permanecer em um backlog indefinidamente.
A hierarquia de fixtures
O sistema de fixtures do pytest recompensa um design deliberado. A hierarquia natural para um projeto FastAPI com um banco de dados real segue três escopos: session, module e function.
Fixtures com escopo de sessão executam uma vez por rodada de testes. Criação do banco de dados, migração de esquema via Alembic e preenchimento de tabelas com dados de referência que não mudam entre os testes – estes pertencem aqui. O engine de banco de dados com escopo de sessão é compartilhado entre todos os testes, o que significa que o custo de aplicar as migrações é pago uma vez, não uma vez por arquivo de teste.
Fixtures com escopo de função fornecem a cada teste seu próprio estado isolado. A fixture de rollback de transação é a crítica: ela abre uma transação no início de cada teste e faz rollback ao final. Qualquer escrita que o teste fizer – criar usuários, inserir pedidos, atualizar preços – é revertida automaticamente. Os testes são isolados sem precisar truncar tabelas entre execuções.
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
@pytest_asyncio.fixture(scope="session")
async def engine():
engine = create_async_engine(settings.TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(engine):
"""Each test gets a transaction that rolls back on completion."""
async with engine.begin() as conn:
await conn.begin_nested()
session_factory = sessionmaker(
conn, class_=AsyncSession, expire_on_commit=False
)
async with session_factory() as session:
yield session
await session.rollback()
A chamada begin_nested() cria um savepoint dentro da transação externa. O código do teste pode fazer commit livremente dentro da sua sessão, mas o rollback da transação externa no teardown desfaz tudo. Esse padrão funciona com a camada assíncrona do SQLAlchemy, embora exija atenção ao testar código que explicitamente chama session.commit() – esses commits são concluídos dentro do savepoint e ainda são revertidos pela transação externa.
Simulando APIs externas
Três APIs externas significam três fontes potenciais de instabilidade em testes que acessam a rede. A solução padrão – simular o cliente HTTP – é direta com respx, que intercepta requisições httpx na camada de transporte.
import respx
import httpx
import pytest
@pytest.mark.asyncio
async def test_shipping_estimate_returns_carrier_data(test_client, valid_token):
mock_response = {
"carrier": "DHL",
"estimated_days": 3,
"cost_eur": 8.50
}
with respx.mock:
respx.post("https://api.logistics-provider.com/v2/rates").mock(
return_value=httpx.Response(200, json=mock_response)
)
response = await test_client.post(
"/api/v1/shipping/estimate",
json={"destination_zip": "10115", "weight_kg": 2.5},
headers={"Authorization": f"Bearer {valid_token}"}
)
assert response.status_code == 200
assert response.json()["estimated_days"] == 3
O gerenciador de contexto respx.mock roteia todas as requisições httpx de saída pela camada de mock. Qualquer requisição não registrada explicitamente gera um erro – que é o padrão correto. Testes que acidentalmente alcançam APIs de produção são uma categoria de bugs difícil de detectar e custosa quando causa efeitos colaterais.
Para o serviço de conversão de câmbio, chamado a cada pedido com denominação não EUR, uma fixture compartilhada fornece um roteador respx pré-configurado com taxas de câmbio padrão. Testes que cobrem lógica de câmbio sobrescrevem pares de taxas específicos; testes que cobrem comportamento não relacionado, mas que por acaso ativa a dependência de câmbio, usam os padrões da fixture sem precisar pensar nisso. Essa separação mantém a intenção do teste clara: testes sobre envio são sobre envio, não sobre a taxa de câmbio configurada.
Testando fluxos de autenticação
O sistema de injeção de dependência do FastAPI é um dos seus pontos mais fortes para testes. A autenticação neste projeto é uma dependência: cada rota protegida declara current_user: User = Depends(get_current_user), onde get_current_user valida um JWT e retorna um objeto de usuário. Nos testes, essa dependência é sobrescrita no nível da aplicação:
from app.main import app
from app.dependencies import get_current_user
from app.models import User
def make_user(role: str = "operator") -> User:
return User(id="test-user-id", email="[email protected]", role=role)
@pytest.fixture
def operator_client(db_session):
app.dependency_overrides[get_current_user] = lambda: make_user("operator")
client = TestClient(app)
yield client
app.dependency_overrides.clear()
@pytest.fixture
def admin_client(db_session):
app.dependency_overrides[get_current_user] = lambda: make_user("admin")
client = TestClient(app)
yield client
app.dependency_overrides.clear()
Fixtures separadas para funções separadas tornam explícitos os testes de fronteiras de permissão. Um teste que afirma uma resposta 403 quando um operador tenta uma ação de admin lê-se tão claramente quanto a regra de negócio que aplica. Quando as fixtures são chamadas operator_client e admin_client, a intenção do teste é visível sem ler seu corpo.
Um pequeno conjunto de testes exercita o caminho de validação JWT diretamente – tokens expirados, tokens malformados, tokens com claims de audience incorretos. Esses testes não usam a fixture de sobrescrita; eles passam tokens brutos pelo cliente de teste e verificam respostas 401 ou 403. A separação é deliberada: a maioria dos testes se preocupa com o comportamento após a autenticação, então usam a sobrescrita. Os testes de autenticação se preocupam com a lógica de autenticação em si. Misturar os dois obscurece o que cada teste está realmente cobrindo.
Testando caminhos de erro deliberadamente
Testes de caminho feliz são simples de escrever e são frequentemente os únicos testes escritos sob pressão de tempo. Os bugs que chegam à produção raramente estão no caminho feliz. Para esta base de código, o comportamento não testado mais consequente estava no tratamento de erros: o que acontece quando a API de logística retorna 503? O que a API retorna quando campos obrigatórios da requisição estão ausentes? O que acontece quando uma restrição de banco de dados é violada por uma modificação concorrente?
Para cada mock de API externa, incluímos pelo menos um caso de erro ao lado do caso de sucesso. A fixture da API de logística tem uma variante que retorna 503, confirmando que o endpoint retorna uma resposta de erro estruturada com um código HTTP adequado em vez de deixar a exceção se propagar como 500. Testes de restrição de banco de dados tentam inserções conflitantes e confirmam que a API retorna 409 em vez de um erro de integridade não tratado.
Essa cobertura não emerge naturalmente ao escrever testes a posteriori. Requer enumeração deliberada dos modos de falha durante o design dos testes, o que é um argumento honesto para escrever testes junto com o código em vez de depois – os modos de falha são mais fáceis de enumerar quando a implementação é recente e os casos extremos ainda estão na memória de trabalho.
O ROI de testar tarde
Testar três meses tarde é melhor do que nunca testar, por razões que não são filosóficas. O PR identificou três bugs existentes durante a escrita: uma verificação de nulo ausente em um campo de endereço opcional que causava um 500 em pedidos sem uma segunda linha de endereço de cobrança, um erro de um a mais na lógica de paginação que só se manifestava com conjuntos de resultados vazios, e um código HTTP incorreto (200 em vez de 201) em um endpoint de criação de recurso. Esses bugs estavam em produção há meses sem gerar uma reclamação de usuário – que é precisamente por que tinham passado despercebidos. Os testes os encontraram em uma tarde.
O retorno mais duradouro é a documentação comportamental. Uma suíte de testes que cobre fronteiras de autenticação, interações com APIs externas e condições de erro é uma especificação do que a API faz que permanece precisa enquanto os testes passam. O teste de “operador não pode acessar endpoint de admin” não apenas captura regressões – ele comunica a intenção a quem ler a base de código em seguida. Para um serviço que será mantido por alguém diferente do seu autor original – o que eventualmente é verdade para todo serviço – essa especificação vale consideravelmente mais do que os bugs que captura no momento da escrita.
O argumento contra escrever testes tarde é que o custo é concentrado no início: um PR grande, um esforço de revisão concentrado, tempo que poderia ter ido para funcionalidades. Isso é preciso. Mas a alternativa – uma base de código que permanece sem testes porque o momento certo para adicioná-los nunca chega – carrega um custo composto que raramente é medido porque está distribuído em cada mudança subsequente. Uma suíte de testes tardia é cara uma vez. Nenhuma suíte de testes é cara continuamente.