Skip to content

    Chapter 24, Intermediate

    Tests with pytest

    An API without tests breaks in production, and FastAPI makes testing easy: the `TestClient` calls the application in memory, and dependencies can be swapped for test versions.

    The basics: call and check

    The TestClient simulates an HTTP client, without starting a server. A test makes a request and checks the status and the body:

    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")
    
    Output
    ok
    

    With the pytest, you store functions test_* in files test_*.py and run uv run pytest. Here I call the functions directly to show the result, and chapter 34 runs the pytest for real.

    The problem: testing an API with database and authentication

    A real route depends on a database and an authenticated user. In a test, you don't want the production database or to log in for real. FastAPI solves this with app.dependency_overrides: a dictionary where you swap one dependency for another, just for the test.

    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)]
    

    An isolated test client

    The function below sets up, for each test, a new and empty in-memory database , and swaps the two dependencies. Isolation is the golden rule: a test should never depend on what another left in the database, otherwise the order of execution changes the result.The second test passes

    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()
    
    Output
    passou: test_criar_e_listar
    passou: test_cada_teste_comeca_com_o_banco_vazio
    passou: test_sem_login_nega_acesso
    

    precisely because the first wrote a note and the second's database is different. The third tests the error path: without the of the user, the real dependency runs and rejects. override In a project, the

    The same, written for pytest

    becomes a criar_cliente fixture in , and pytest injects it by the conftest.pyname of the parameter. The cleans the yield at the end of each test: overrides runs the same test with various values, and each one appears separately in the report.

    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
    

    The parametrize Layer

    What to test

    ExampleHappy path
    Create and read returns the expectedMissing field or wrong type gives
    ValidationBusiness errors 422
    Not found (), duplicate (404), no permission (409Authentication403)
    No token gives, expired token gives 401, expired token gives 401
    IsolationA user does not see another user's data
    Side effectsThe route actually wrote to the database

    Asynchronous tests

    To test routes async def with an asynchronous client, the httpx2.AsyncClient with ASGITransport (as in chapter 18) is the tool, along with a pytest extension for asynchronous tests (pytest-anyio or pytest-asyncio). The TestClient synchronous, however, also tests routes async without anything extra, and that is what I use in most cases.

    Exercise 1

    A user does not see another user's notes

    Write test_isolamento_entre_usuarios: with the same database, ana creates a note and bia lists theirs. The list of bia should come empty. Hint: swap the override of usuario_atual between the calls.