Pular para o conteúdo

    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:

    intermediario/cap24_testes_api.pylinhas 10 a 35
    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")
    
    Saída
    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.

    intermediario/cap24_testes_api.pylinhas 40 a 88
    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.

    intermediario/cap24_testes_api.pylinhas 93 a 130
    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()
    
    Saída
    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:

    conftest.py
    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()
    
    test_notas.py
    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

    CamadaExemplo
    Caminho felizCriar e ler devolve o esperado
    ValidaçãoCampo faltando ou de tipo errado dá 422
    Erros de negócioNão encontrado (404), duplicado (409), sem permissão (403)
    AutenticaçãoSem token dá 401, token vencido dá 401
    IsolamentoUm usuário não vê os dados de outro
    Efeitos colateraisA rota realmente gravou no banco

    Testes assíncronos

    Para testar rotas async def com um cliente assíncrono, o httpx2.AsyncClient com ASGITransport (como no capítulo 18) é a ferramenta, junto de uma extensão do pytest para testes assíncronos (pytest-anyio ou pytest-asyncio). O TestClient síncrono, porém, também testa rotas async sem 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.