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:
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
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.
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
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
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.
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
The parametrize Layer
What to test
| Example | Happy path |
|---|---|
| Create and read returns the expected | Missing field or wrong type gives |
| Validation | Business errors 422 |
| Not found ( | ), duplicate (404), no permission (409Authentication403) |
| No token gives | , expired token gives 401, expired token gives 401 |
| Isolation | A user does not see another user's data |
| Side effects | The route actually wrote to the database |
Asynchronous tests
To test routes
async defwith an asynchronous client, thehttpx2.AsyncClientwithASGITransport(as in chapter 18) is the tool, along with a pytest extension for asynchronous tests (pytest-anyioorpytest-asyncio). TheTestClientsynchronous, however, also tests routesasyncwithout 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.