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.