FastAPI – Testing the Whole Stack

October 11, 202513 min readUpdated 8/23/2026

FastAPI is unusually easy to test, mostly because dependency injection puts a seam everywhere you need one. This lesson covers the two levels that matter — rules tested directly, and the whole stack tested through HTTP — how to run against a real database without leaving a trace, and a class of bug that only the second level can find.

Two levels, and how to choose

StayHub's suite splits by what is under test rather than by module, and the split is informative:

tests/
├── conftest.py                 shared fixtures — the rollback session
├── test_pricing.py             a pure function, no database
├── test_cancellation_policy.py a pure function, no database
├── test_booking_service.py     service rules, real database
├── test_security.py            JWT and hashing
├── test_api_admin.py           through HTTP — pagination, authorization
├── test_api_uploads.py         through HTTP — every upload guard
└── test_api_middleware.py      through HTTP — things only the stack can show

The rule of thumb: test a rule at the lowest level that can express it. Pricing is a pure function, so testing it through HTTP would add a database, a token and a router to a test about arithmetic — slower, and it fails for more reasons than the one you care about.

What genuinely needs HTTP is narrower than people assume: status codes, headers, serialisation, authentication and authorization, and anything that lives in configuration. That last category is the one worth the effort, and it is covered at the end.

A database, and no trace

The awkward question is what to test against. Mocking the session means testing your mocks; a separate test database means more setup. StayHub takes a third option:

@pytest.fixture
def db() -> Session:
    """A session whose work is always rolled back.

    ⚠️ The rollback is bound to an OUTER transaction on the connection, not to the session. Code
    under test calls `db.commit()` — that commits the session's nested work, but the outer
    transaction here still owns it, so the final rollback undoes everything regardless.
    """
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection, join_transaction_mode="create_savepoint")
    try:
        yield session
    finally:
        session.close()
        transaction.rollback()
        connection.close()

This is the most useful fixture in the file, so it is worth understanding rather than copying.

A transaction is opened on the connection. The session is bound to that connection with join_transaction_mode="create_savepoint", so when code under test calls commit() — and it does, because services commit — that releases a savepoint inside the outer transaction rather than making anything durable. The final transaction.rollback() discards all of it.

What you get: real Postgres, real constraints, real SQL, and nothing left behind. The exclusion constraint that prevents double-booking is genuinely exercised, which a mock could never do. Verify it if you doubt it — a row count before and after a full run is unchanged.

The trade is that tests share one database and cannot run in parallel against it. For a suite that finishes in under three seconds, that is a good deal.

Fixtures that do not collide

@pytest.fixture
def host(db) -> User:
    user = User(
        email=f"host-{datetime.now(UTC).timestamp()}@stayhub.test",
        password_hash="x", first_name="Test", last_name="Host",
        role=UserRole.CUSTOMER, is_host=True,
    )
    db.add(user)
    db.flush()
    return user

Three details worth copying. The email carries a timestamp, so a unique constraint cannot fire even if something did leak between runs. password_hash="x" because this test does not care about hashing and bcrypt is deliberately slow — hashing a real password in every fixture adds seconds to the suite for nothing. And flush() rather than commit(), so the row has an id and stays inside the transaction.

Bookings are placed far in the future for a related reason:

        check_in = TODAY + timedelta(days=400 + i * 3)  # far out, so nothing collides with seed data

The database has seed data in it. A test booking landing on the same dates as a fixture booking hits the real exclusion constraint and fails for a reason that has nothing to do with the test.

Through HTTP

TestClient runs the whole ASGI stack in-process — no server, no port, no network:

@pytest.fixture
def client(db, admin) -> TestClient:
    """A client whose requests run inside the test's transaction, authenticated as `admin`."""
    app.dependency_overrides[get_db] = lambda: db
    app.dependency_overrides[get_current_user] = lambda: admin
    yield TestClient(app)
    app.dependency_overrides.clear()

Two overrides and you have a real HTTP client that runs inside the test's transaction and is authenticated as a user the test created — no login, no password hashing, no token. The application is untouched; there is no test flag anywhere in it.

The first override is the one people get subtly wrong:

    # Yield the SAME session the fixtures wrote into. Handing the route a fresh SessionLocal
    # instead would put it outside this transaction, so it would not see the rows above — and the
    # test would fail with an empty page that looks like a broken query.

Two ways overrides silently do nothing

⚠️ The override targets the FUNCTION OBJECT, not its name or its path. `app.dependency_overrides`
is a plain dict keyed by the callable itself, so importing `get_db` from a different module than
the app did — or overriding `deps.get_current_user` when routes actually depend on the
`CurrentUser` alias built from it — silently does nothing. The test then hits the real dependency,
which usually fails in a way that looks like a bug in the route.

There is no error and no warning — the key simply never matches. If an override appears to have no effect, check you are overriding the same object the application imported.

The second is leakage:

    # ⚠️ Always clear. The dict lives on the app object, which is module-level and shared by every
    # test file in the run — a leftover override leaks into unrelated tests as a phantom failure.

Clear in a fixture teardown or a finally, never at the end of a test body, where a failed assertion skips it and poisons everything after.

Testing what refuses

Overrides make negative authorization tests almost free, and they are the ones people skip:

    def test_a_non_admin_is_refused(self, db, admin):
        """The override is what makes this cheap to test: swap in a plain customer and the same
        route must now refuse."""
            assert TestClient(app).get("/api/v1/admin/users").status_code == 403
            assert TestClient(app).get("/api/v1/admin/users").status_code == 401

A test that a route works when you are allowed fails loudly in development. A test that it refuses when you are not is the only thing standing between a missing decorator and a data breach, and nothing else will catch it.

The same applies to every guard. StayHub's upload tests are one per rejection — forged content type, oversize, path traversal — on the principle that a security control never observed to reject anything is a comment.

The bugs only this level can see

This is the argument for API-level tests, and it is not "more coverage".

⚠️ The bug was invisible to every service-level test in this suite, because it was not in any
service — it was in the ORDER two middlewares were registered in. Nothing below `TestClient`
can see it.

Middleware order, CORS headers, exception handler registration, response model filtering, serialisation, status codes — none of those live in a function you can call. They live in the assembled application, and the only way to test an assembled application is to send it a request:

    def test_a_500_still_carries_cors_headers(self, client, probe_routes):
        """⚠️ THE regression test. Reordering the two add_middleware calls in main.py breaks this
        and nothing else in the suite."""
        r = client.get("/__test/boom", headers={"Origin": ORIGIN})
        assert r.status_code == 500
        assert r.headers.get("access-control-allow-origin") == ORIGIN

Two things make that test work. raise_server_exceptions=False makes TestClient behave like a real server and return the 500 instead of re-raising it into the test — with the default, the assertions are unreachable. And the probe routes are registered on the real app:

    """Two routes that exist only for these tests.

    Registered on the real `app` so they go through the real middleware stack — a second app
    assembled by hand would be testing a copy of the configuration rather than the configuration.
    """

They are removed afterwards, because a stray /__test route in a later run's OpenAPI schema is exactly the leftover the suite exists to avoid:

    app.router.routes = [
        r for r in app.router.routes if not getattr(r, "path", "").startswith("/__test")
    ]

A test that lied

Worth telling, because the failure is not obvious and the fix is a technique.

An access-log line was coming out with request_id="-" instead of the real id. The first replacement test used pytest's caplog and asserted on record.request_id — and it passed even with the bug reintroduced.

The reason: a LogRecord is one object shared by every handler. The application's own root handler carries a filter that stamps the id onto the record, and it ran before caplog's handler saw it. The test was reading the filter's work and reporting it as the middleware's.

The honest version removes the confound by replacing the root handlers for the duration:

    stream = io.StringIO()
    handler = logging.StreamHandler(stream)
    handler.setFormatter(JsonFormatter())  # NOTE: no RequestIdFilter, on purpose

    root = logging.getLogger()
    saved, saved_level = root.handlers[:], root.level
    root.handlers = [handler]
    root.setLevel(logging.INFO)
    try:
        yield stream
    finally:
        root.handlers, root.level = saved, saved_level

Now nothing can annotate the record behind the assertion's back, and the JSON captured is genuinely what the middleware produced.

The general lesson is bigger than logging: a test that passes when you break the thing it tests is worse than no test, because it is also a claim that the thing is covered. The only way to know is to break it deliberately and watch the test fail.

Async tests

TestClient handles async routes without any ceremony — it runs the event loop for you. To test an async function directly, you need a plugin:

pytest-asyncio==0.24.0
asyncio_default_fixture_loop_scope = function

That setting in pytest.ini is not optional in the sense that matters: without it pytest-asyncio emits a deprecation warning about a default that is changing, and setting it explicitly is the fix the warning asks for. Pinning the loop scope also avoids a class of confusing failure where an async fixture and an async test end up on different event loops.

Faking what you do not own

Some dependencies should not be exercised in a test at all — a payment provider, an email service, an SMS gateway. Three approaches, and the choice matters more than it looks.

Override the dependency, when the boundary is already a dependency. This is the cleanest option and the reason to route external clients through Depends in the first place:

app.dependency_overrides[get_stripe] = lambda: FakeStripe()

Write to a stand-in, when the side effect is small. StayHub's notifications do this — "sending" writes a JSON file, so a test asserts a file appeared and reads it:

    OUTBOX.mkdir(exist_ok=True)
    stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%f")
    path = OUTBOX / f"{stamp}-{to.replace('@', '_at_')}.json"

That is often the least effort for the most confidence: the real code path runs end to end, and the assertion is on its actual output rather than on a mock having been called.

Monkeypatch, as the last resort, when the call is buried and not injected. It works and it couples the test to the implementation's import structure, so it breaks when somebody moves a function that still behaves identically.

The rule worth holding: assert on outcomes, not on calls. mock.assert_called_once_with(...) passes when the function is called correctly and does the wrong thing. Asserting that a booking is now CONFIRMED tests what you care about.

Test data that does not fight you

Fixtures accumulate, and there are two shapes that keep them manageable.

A factory function with sensible defaults, so each test states only what it cares about:

def make_listing(db, host, **overrides) -> Property:
    defaults = dict(
        title="Test Listing", description="x", city="Testville",
        country="United States", price_per_night=Decimal("100.00"),
        max_guests=4, status=PropertyStatus.PUBLISHED,
    )
    prop = Property(host_id=host.id, **{**defaults, **overrides})
    db.add(prop)
    db.flush()
    return prop

A test about guest limits then writes make_listing(db, host, max_guests=2), and the one relevant value is the only one visible. Compare that with a fixture where every field is spelled out and the significant one is buried among twelve.

Helpers that encode the awkward parts, so nobody has to remember them:

def book(db, listing, guest, start_offset: int, nights: int, status=BookingStatus.CONFIRMED):
    check_in = TODAY + timedelta(days=start_offset)
    check_out = check_in + timedelta(days=nights)

Offsets from today rather than fixed dates, because a test with hardcoded 2026 dates starts failing in 2027 — and a booking in the past is rejected by a rule that has nothing to do with what is being tested.

What makes a suite stay fast

StayHub's hundred tests run in under three seconds against a real Postgres. Four things keep it there, and losing any one of them is how suites become the thing people skip.

No hashing in fixtures. password_hash="x", because bcrypt is deliberately slow and only the auth tests care.

Transactions rather than truncation. A rollback is nearly free; deleting and re-seeding tables between tests is not.

Session-scoped clients where the state allows. The middleware tests share one TestClient, because none of them writes anything.

The database stays warm. Tests run against a container that is already up, so nothing pays startup cost per run.

The thing to protect is the habit, not the number. A suite that takes ten seconds gets run on every save; one that takes four minutes gets run before a commit, if that.

Coverage that means something

pytest -q --cov=app --cov-report=term-missing

Coverage is a good tool for finding what you forgot and a bad target to optimise. A suite that calls every line and asserts nothing reports 100%.

The lines actually worth chasing are the ones nothing exercises by accident: error branches, authorization refusals, the except that cleans up a partial file. Those are exactly the paths that never run in development, so a coverage report is genuinely useful for finding them — just do not confuse the number with the confidence.

Testing the database rules themselves

Running against real Postgres buys one thing a mock never can: the constraints are live. That makes a class of test possible that is otherwise theoretical.

"""Booking rules against the real database — availability, and the exclusion constraint."""

The interesting one is the race. The application checks availability, then inserts; two requests can both pass the check. The only thing that stops the second is the database, so the test asserts the database stops it — by inserting directly, bypassing the friendly check entirely:

from sqlalchemy.exc import IntegrityError

That test fails if somebody drops the constraint while leaving the application check in place — which is exactly the change that looks harmless and reintroduces double-booking under load.

The general principle: test the guarantee at the layer that provides it. The service's job is a readable message; the constraint's job is correctness under concurrency. Testing only the first leaves the second unverified, and it is the one that matters.

Flakiness, and where it comes from

Four causes account for nearly all of it, and all four are avoidable by construction.

Shared state between tests. A leftover dependency_overrides entry, a file on disk, a row that was committed. The rollback fixture handles the database; anything outside it needs explicit cleanup.

Time. A test with hardcoded dates starts failing on a particular day. Offsets from TODAY do not:

TODAY = datetime.now(UTC).date()

Ordering assumptions. A test asserting that the first result is a particular row depends on a query having an ORDER BY. If the code under test does not have one, the test is asserting a coincidence.

Collisions with seed data. Timestamped emails and far-future dates exist for this reason — the database is not empty, and a real unique constraint does not care that you are testing.

When something does go flaky, running the suite in a different order is the fastest discriminator. If it only fails after a particular test, it is shared state; if it fails in isolation, it is time or ordering.

What a good suite looks like from outside

Four properties, and each one is a decision rather than an accident.

It runs in seconds. A suite that takes minutes gets run before a commit; one that takes seconds gets run on every save, which is where its value actually comes from.

A failure names the behaviour. test_a_500_still_carries_cors_headers tells you what broke from the summary line alone.

It leaves nothing behind. Rollbacks for the database, explicit cleanup for anything outside it, and overrides cleared. A suite that pollutes is a suite whose failures are sometimes about the previous run.

Every important test has been watched failing. This is the one people skip and the one that decides whether the rest is real. A test that passes when you break the thing it tests is worse than no test, because it also carries a claim of coverage.

StayHub's grew from 58 to 100 while this track was written, and the additions were almost entirely the second level — API-level tests for things no service test could see. That ratio is worth noticing: the service tests were already good, and the gap was everything that lives in how the application is assembled rather than in what its functions do.

Habits

  • Name the behaviour, not the function. test_a_500_still_carries_cors_headers tells you what broke when it fails; test_middleware_2 does not.
  • One reason to fail per test. A test asserting six things reports the first and hides the rest.
  • Clean up anything outside the transaction. Files, indexes, caches — the rollback does not reach them.
  • Test the refusals. They are the ones that matter and the ones nobody writes.
  • Watch every important test fail once, deliberately, before trusting it.

Next: logging, health checks and request tracing — how to find out what an application is doing when it is running somewhere you cannot attach a debugger.