Test suites go bad in a predictable way. They start fast and isolated, then somebody adds a test that leaves a row behind, and within a few months the suite only passes if you run it in the right order.

Almost all of that is preventable with four fixtures written at the start.

1. A database session that always rolls back

This is the important one. Each test runs inside a transaction that is never committed, so the database is identical before and after.

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

@pytest.fixture(scope="session")
def engine():
    engine = create_engine(TEST_DATABASE_URL)
    Base.metadata.create_all(engine)
    yield engine
    Base.metadata.drop_all(engine)
    engine.dispose()

@pytest.fixture
def db(engine):
    connection = engine.connect()
    transaction = connection.begin()
    session = sessionmaker(bind=connection)()
    try:
        yield session
    finally:
        session.close()
        transaction.rollback()
        connection.close()

The mechanism: the session is bound to a connection with an open transaction, not to the engine. Anything the test commits commits inside that outer transaction, and the final rollback() discards all of it.

Tests become order-independent, and you never write cleanup code again.

Note that schema creation is scope="session", so it happens once, while the transaction is per test. Creating tables for every test is the most common reason a suite is slow.

2. A client with dependency overrides

Your app’s database dependency has to use the test session, or the test and the endpoint end up looking at different data.

from fastapi.testclient import TestClient

@pytest.fixture
def client(db):
    def override_get_db():
        yield db

    app.dependency_overrides[get_db] = override_get_db
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()

dependency_overrides.clear() in teardown matters. app is module-level and shared, so an override left behind leaks into every test that follows.

3. Authenticated clients, one per role

Write this once and every permission test becomes two lines.

@pytest.fixture
def auth_client(client, db):
    def _make(role: str = "viewer", **kwargs):
        user = UserFactory(role=role, **kwargs)
        db.add(user)
        db.flush()
        token = make_access_token(user)
        client.headers["Authorization"] = f"Bearer {token}"
        client.user = user
        return client
    return _make

A factory fixture rather than a fixed one, so a test asks for what it needs:

def test_viewer_cannot_delete(auth_client):
    c = auth_client(role="viewer")
    assert c.delete("/v1/datasets/1").status_code == 403

def test_admin_can_delete(auth_client, dataset):
    c = auth_client(role="admin")
    assert c.delete(f"/v1/datasets/{dataset.id}").status_code == 204

db.flush() rather than db.commit(): the row gets an id and is visible inside the transaction, and still disappears on rollback.

4. Factories instead of fixture pyramids

The trap is a fixture per entity, each depending on the last. Six tests in, you have a dependency tree nobody can follow, and a test that needs a slightly different user has nowhere to put that.

Use a factory and let the test state only what it cares about:

import factory

class UserFactory(factory.Factory):
    class Meta:
        model = User

    email = factory.Sequence(lambda n: f"user{n}@example.test")
    role = "viewer"
    is_active = True
def test_inactive_user_is_rejected(client, db):
    user = UserFactory(is_active=False)
    db.add(user); db.flush()
    ...

The test names is_active=False because that is the point of the test. Everything else is a default nobody has to read.

Two habits that keep it healthy

Freeze time when time matters. Any test involving expiry, scheduling, or “created in the last 7 days” should control the clock with freezegun or an injectable clock. Tests that depend on the real time fail on a Tuesday in March for reasons nobody can reproduce.

Assert behaviour, not implementation. A test that mocks three internal functions and checks they were called is testing that the code is the shape it currently is. Send a request, check the status and the body, check the database row. That test survives a refactor, which is the only reason it exists.

What this buys

A suite you can run in any order, in parallel, on a fresh checkout, that fails only when something is actually broken.

Which is the point. A test suite people trust gets run. One that is flaky gets ignored, and then it is just a slow build step.

Related

Next step

Working on something this touches?

If this post lines up with a problem you have, I would rather hear about the problem than the post. Tell me what you are building.

  • Replies: Within two working days
  • Based in: the United States
  • Open to: full time, contract, remote or hybrid

Pages

Projects

Writing

Actions