Database Fixtures with Pytest
We need a database for our tests.
The fixture in tests/conftest.py gives every test its own clean SQLite instance, an isolated Session, a TestClient wired to override the production session, and a few of pre-populated rows for tests that need a starting state.
The Session Fixture
The session fixture builds a fresh in-memory database, creates every table from SQLModel.metadata, and yields a Session bound to it.
@pytest.fixture(name="session")
def session_fixture() -> Generator[Session]:
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
The Client Fixture
TestClient is a synchronous HTTP client from FastAPI's test utilities; under the hood, it wraps the ASGI app and lets tests issue client.get(...), client.post(...), and so on. The client fixture wires it up so every request inside a test uses the per-test session, not the real database.
@pytest.fixture(name="client")
def client_fixture(session: Session) -> Generator[TestClient]:
def get_session_override():
yield session
app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)
yield client
app.dependency_overrides.clear()
get_engine.cache_clear()
Sample Data Fixtures
Most tests need at least one project to act on, and most task tests need at least one task. Two helper fixtures keep that boilerplate out of the tests themselves:
@pytest.fixture()
def sample_project_id(client: TestClient) -> int:
response = client.post(
"/projects/",
json={
"name": "Release Platform",
"description": "Coordinates planning for the production release.",
},
)
assert response.status_code == 201
return response.json()["id"]
@pytest.fixture()
def sample_task_id(client: TestClient, sample_project_id: int) -> int:
response = client.post(
f"/projects/{sample_project_id}/tasks",
json={
"title": "Wire up the dashboard",
"details": "Connect the API to the static frontend.",
"status": "planned",
"priority": "medium",
},
)
assert response.status_code == 201
return response.json()["id"]
Reading the Tests
A test written with these fixtures stays focused on the behavior we're testing:
def test_get_task_not_found(client: TestClient):
response = client.get("/tasks/9999")
assert response.status_code == 404
assert response.json() == {"detail": "Task not found"}
def test_list_tasks_filter_by_status(
client: TestClient, sample_project_id: int
):
client.post(
f"/projects/{sample_project_id}/tasks",
json={"title": "Planned task", "status": "planned"},
)
client.post(
f"/projects/{sample_project_id}/tasks",
json={"title": "Done task", "status": "done"},
)
response = client.get("/tasks", params={"status": "done"})
assert response.status_code == 200
titles = [task["title"] for task in response.json()]
assert titles == ["Done task"]
Fixture scope
Every fixture in this chapter uses the default function scope, which means a fresh database per test. That's the safest default.
Faster scopes (module, session) reuse state between tests, which is a useful optimization when the test suite gets large but introduces ordering dependencies that can cause confusing failures.