Capítulo 24, Intermediário
Testes com pytest
Uma API sem testes quebra em produção, e o FastAPI facilita testar: o `TestClient` chama a aplicação em memória, e as dependências podem ser trocadas por versões de teste.
O básico: chamar e conferir
O TestClient simula um cliente HTTP, sem subir servidor. Um teste faz uma requisição e confere o status e o corpo:
from fastapi import FastAPI
from fastapi.testclient import TestClient
app_simples = FastAPI()
@app_simples.get("/soma")
def soma(a: int, b: int):
return {"resultado": a + b}
def test_soma():
cliente = TestClient(app_simples)
resposta = cliente.get("/soma?a=5&b=3")
assert resposta.status_code == 200
assert resposta.json() == {"resultado": 8}
def test_soma_com_valor_invalido():
resposta = TestClient(app_simples).get("/soma?a=cinco&b=3")
assert resposta.status_code == 422
test_soma()
test_soma_com_valor_invalido()
print("ok")
ok
Com o pytest, você guarda funções test_* em arquivos test_*.py e roda uv run pytest. Aqui eu chamo as funções diretamente para mostrar o resultado, e o capítulo 34 roda o pytest de verdade.
O problema: testar uma API com banco e autenticação
Uma rota real depende de um banco de dados e de um usuário autenticado. Em um teste, você não quer o banco de produção nem fazer login de verdade. O FastAPI resolve isso com app.dependency_overrides: um dicionário em que você troca uma dependência por outra, só para o teste.
from typing import Annotated
from fastapi import Depends, HTTPException
from sqlalchemy import create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
from sqlalchemy.pool import StaticPool
class Base(DeclarativeBase):
pass
class Nota(Base):
__tablename__ = "notas"
id: Mapped[int] = mapped_column(primary_key=True)
texto: Mapped[str]
dono: Mapped[str]
engine_producao = create_engine("sqlite:///./producao.db")
def obter_sessao():
with Session(engine_producao) as sessao:
yield sessao
def usuario_atual() -> str:
raise HTTPException(status_code=401, detail="Não autenticado")
Sessao = Annotated[Session, Depends(obter_sessao)]
Usuario = Annotated[str, Depends(usuario_atual)]
app = FastAPI()
@app.post("/notas", status_code=201)
def criar_nota(texto: str, usuario: Usuario, sessao: Sessao):
nota = Nota(texto=texto, dono=usuario)
sessao.add(nota)
sessao.commit()
return {"id": nota.id, "texto": nota.texto}
@app.get("/notas")
def listar_notas(usuario: Usuario, sessao: Sessao):
consulta = select(Nota).where(Nota.dono == usuario).order_by(Nota.id)
return [{"id": n.id, "texto": n.texto} for n in sessao.scalars(consulta)]
Um cliente de teste isolado
A função abaixo monta, para cada teste, um banco em memória novo e vazio, e troca as duas dependências. O isolamento é a regra de ouro: um teste nunca pode depender do que outro deixou no banco, senão a ordem de execução muda o resultado.
def criar_cliente(usuario: str | None = "ana") -> TestClient:
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
fabrica = sessionmaker(engine, expire_on_commit=False)
def sessao_de_teste():
with fabrica() as sessao:
yield sessao
app.dependency_overrides[obter_sessao] = sessao_de_teste
if usuario is not None:
app.dependency_overrides[usuario_atual] = lambda: usuario
else:
app.dependency_overrides.pop(usuario_atual, None)
return TestClient(app)
def test_criar_e_listar():
cliente = criar_cliente("ana")
criada = cliente.post("/notas", params={"texto": "comprar pão"})
assert criada.status_code == 201
assert [n["texto"] for n in cliente.get("/notas").json()] == ["comprar pão"]
def test_cada_teste_comeca_com_o_banco_vazio():
cliente = criar_cliente("ana")
assert cliente.get("/notas").json() == []
def test_sem_login_nega_acesso():
cliente = criar_cliente(usuario=None)
assert cliente.get("/notas").status_code == 401
for teste in (test_criar_e_listar, test_cada_teste_comeca_com_o_banco_vazio, test_sem_login_nega_acesso):
teste()
print("passou:", teste.__name__)
app.dependency_overrides.clear()
passou: test_criar_e_listar
passou: test_cada_teste_comeca_com_o_banco_vazio
passou: test_sem_login_nega_acesso
O segundo teste passa justamente porque o primeiro gravou uma nota e o banco do segundo é outro. O terceiro testa o caminho de erro: sem o override do usuário, a dependência real roda e recusa.
O mesmo, escrito para o pytest
Em um projeto, o criar_cliente vira uma fixture em conftest.py, e o pytest a injeta pelo nome do parâmetro. O yield limpa os overrides no fim de cada teste:
import pytest
from fastapi.testclient import TestClient
from app.main import app, obter_sessao, usuario_atual
@pytest.fixture
def cliente():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
fabrica = sessionmaker(engine, expire_on_commit=False)
def sessao_de_teste():
with fabrica() as sessao:
yield sessao
app.dependency_overrides[obter_sessao] = sessao_de_teste
app.dependency_overrides[usuario_atual] = lambda: "ana"
yield TestClient(app)
app.dependency_overrides.clear()
import pytest
def test_criar_nota(cliente):
resposta = cliente.post("/notas", params={"texto": "oi"})
assert resposta.status_code == 201
@pytest.mark.parametrize("quantidade", [0, 1, 5])
def test_listar_varias(cliente, quantidade):
for i in range(quantidade):
cliente.post("/notas", params={"texto": f"nota {i}"})
assert len(cliente.get("/notas").json()) == quantidade
O parametrize roda o mesmo teste com vários valores, e cada um aparece separado no relatório.
O que testar
| Camada | Exemplo |
|---|---|
| Caminho feliz | Criar e ler devolve o esperado |
| Validação | Campo faltando ou de tipo errado dá 422 |
| Erros de negócio | Não encontrado (404), duplicado (409), sem permissão (403) |
| Autenticação | Sem token dá 401, token vencido dá 401 |
| Isolamento | Um usuário não vê os dados de outro |
| Efeitos colaterais | A rota realmente gravou no banco |
Testes assíncronos
Para testar rotas
async defcom um cliente assíncrono, ohttpx2.AsyncClientcomASGITransport(como no capítulo 18) é a ferramenta, junto de uma extensão do pytest para testes assíncronos (pytest-anyiooupytest-asyncio). OTestClientsíncrono, porém, também testa rotasasyncsem nada extra, e é o que eu uso na maioria dos casos.
Exercício 1
Um usuário não vê as notas de outro
Escreva test_isolamento_entre_usuarios: com o mesmo banco, ana cria uma nota e bia lista as dela. A lista de bia deve vir vazia. Dica: troque o override de usuario_atual entre as chamadas.