Back to Blog
Engineering

How to Test a FastAPI App: Static, Integration, and the Live-Service Trap

This blog has now built a small FastAPI curriculum: JWT auth, rate limiting, pagination, webhooks, background jobs, migrations, error shapes. Ten articles that build things — and not one that says how to test any of them. Time to fix that, and to do it around the mistake worth the most to avoid, because we made it ourselves and paid for it four deploys in a row.

The mistake has a name: the live-service trap. A test suite that makes requests to your deployed API is not an integration suite. It is a client — and your service treats it like one, with everything that implies. But the trap makes more sense once the healthy structure is on the table, so let's build that first: three layers, each answering a different question, each failing for a different reason.

The Three Layers, and the Question Each One Answers

Layer 1 — Static: “Is the app shaped right?”

Import the app, inspect routes, schemas, and source patterns. No network, no database, no server process. Runs anywhere in milliseconds, fails only when the code actually changed.

Layer 2 — Integration: “Does the app behave right?”

The real application, exercised through TestClient with dependencies overridden at the edges. Real routing, real validation, real handlers — test-controlled auth, database, and clock. This is where most of your coverage belongs.

Layer 3 — Live smoke: “Is the deployment alive?”

A handful of requests against the deployed service: health returns 200, auth rejects garbage, the version is the one you shipped. Thin by design — it verifies the deployment, not the logic, because the logic was verified two layers ago.

The failure mode in the wild is almost always the same: layer 2's job gets pushed into layer 3. The suite grows up pointed at a staging or production URL, every behavior test becomes a network call, and the team learns to live with a suite that is slow, flaky, and — this is the part nobody prices in — rate limited. Then they blame the tests.

Layer 1: Static Tests, the Cheapest Coverage You'll Ever Buy

A FastAPI app is unusually inspectable: the framework holds your entire routing table, dependency graph, and schema set as data. That means whole classes of regression can be caught by importing the app and looking at it — no server, no fixtures, no waiting.

from app.main import app

def test_no_route_lost_auth():
    # Every /api route except the public list must depend on auth.
    # This catches the classic refactor accident: a router remounted
    # without its dependencies, silently making endpoints public.
    public = {"/api/health", "/api/auth/login", "/api/auth/register"}
    for route in app.routes:
        path = getattr(route, "path", "")
        if not path.startswith("/api") or path in public:
            continue
        dep_names = {d.call.__name__ for d in route.dependant.dependencies}
        direct = {p.name for p in route.dependant.query_params}
        assert "get_current_user" in dep_names or "current_user" not in direct, (
            f"{path} appears to have lost its auth dependency"
        )

def test_error_responses_use_problem_shape():
    # Source-pattern check: no handler returns detail=str(e),
    # the leak we wrote a whole error-shapes article about.
    import pathlib, re
    for f in pathlib.Path("app").rglob("*routes*.py"):
        text = f.read_text(encoding="utf-8")
        assert not re.search(r"detail\s*=\s*str\(\s*e\s*\)", text), (
            f"{f} exposes raw exception text to clients"
        )

Static tests are also where claim-checks live — assertions that your docs, marketing copy, and config still agree with the code, which we wrote about yesterday. The unifying idea: anything checkable without running the app should be checked without running the app, because that's the layer that can never be flaky.

Layer 2: Integration With TestClient — the Real App, Test-Controlled Edges

FastAPI's TestClient runs your actual application in-process: real middleware, real validation, real handlers, no socket. The piece that makes it work at scale is dependency_overrides — the mechanism that swaps the app's edges (database, current user, clock) for test-controlled versions without touching the code under test.

import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.db import get_db
from app.middleware.auth import get_current_user

class FakeUser:
    id = 1
    email = "test@example.com"
    is_active = True

@pytest.fixture
def client(test_db_session):
    # Real app, fake edges. The handler code path is identical
    # to production; only the dependencies differ.
    app.dependency_overrides[get_db] = lambda: test_db_session
    app.dependency_overrides[get_current_user] = lambda: FakeUser()
    yield TestClient(app)
    app.dependency_overrides.clear()   # always clean up

def test_message_edit_window(client, test_db_session, frozen_clock):
    resp = client.post("/api/messages/1/send", json={"content": "hi"})
    assert resp.status_code == 200
    msg_id = resp.json()["id"]

    # Sixteen minutes later, the edit window has closed.
    frozen_clock.advance(minutes=16)
    resp = client.put(f"/api/messages/messages/{msg_id}",
                      json={"content": "hello"})
    assert resp.status_code == 400

Notice what the override buys you in that second test: a time-dependent business rule tested in milliseconds, deterministically, with no sleeping and no clock skew. Against a live service, that same test is impossible — you'd wait sixteen real minutes or not test the rule at all, and “not test the rule” is what actually happens.

Override the Dependency, Never Delete the Feature

The tempting shortcut, once rate limiting or auth gets in the way of tests, is an environment flag that disables it under test. Resist that. A disabled feature has zero coverage — the limiter that's off in every test is a limiter whose bugs ship. The honest versions: override the limiter's identity dependency so each test gets its own bucket, point it at a fresh in-memory store per test, or exercise it deliberately in a dedicated test that asserts the 429 happens. The rate limiter is code. Code gets tested, not switched off.

Layer 3, and the Trap: Your Test Suite Is a Client

Here's the story that earned this article its title. Our own deploy gate runs a pre-deploy suite, and part of that suite grew up pointed at the live API. Reasonable-looking tests: log in, log in with a wrong password and expect 401, submit a bad contact form and expect 422. Then one week, four consecutive deploys were blocked by 429s — and none of them was a bug.

The arithmetic was sitting in our own middleware. The API rate-limits /api/auth/* at 10 requests per minute per IP, on a 60-second window; the suite makes about eight auth calls, so any two runs inside a minute collide with each other. Contact endpoints allow 5 per minute, which one validation-heavy test class exhausts alone. And best of all: the auth code has a brute-force lockout — 10 failed logins and the account locks for 15 minutes — which means the suite's own wrong-password tests, run a few times in a row, locked the test account and turned every subsequent run red for a quarter of an hour.

Read that again from the service's point of view and the comedy resolves into a lesson: everything worked. The limiter limited. The lockout locked. An IP hammering the auth endpoints with failing credentials is exactly what those defenses exist for — the service had no way to know this particular attacker was the CI pipeline. A test suite pointed at a live service is a client, subject to every defense you built against clients, and the better your defenses, the worse your suite behaves.

The fix was not to weaken the service — it was to move the behavior tests down a layer, where they always belonged. Wrong-password-returns-401 is a layer-2 test; with the database dependency overridden it runs in milliseconds and can't lock anything. What stays at layer 3 is genuinely deployment-shaped, and it's short:

import os
import httpx

BASE = os.environ["SMOKE_BASE_URL"]

def test_health_is_green():
    resp = httpx.get(f"{BASE}/health", timeout=10)
    assert resp.status_code == 200
    body = resp.json()
    assert body["status"] == "healthy"
    assert body["checks"]["database"] == "ok"

def test_auth_gate_is_up():
    # ONE unauthenticated probe: the gate exists. Not eight probes,
    # not wrong-password matrices -- those live in layer 2 now.
    resp = httpx.get(f"{BASE}/api/profiles/me", timeout=10)
    assert resp.status_code == 401

Two requests, neither of which can trip a per-minute limit or a lockout. The smoke layer's whole contract is: prove the deployment is the one we think it is, spend as few requests as possible doing it. If a smoke suite is big enough to collide with your own rate limits, it's doing layer 2's job in layer 3's location.

What Goes Where: the Sorting Rule

When you're unsure which layer a test belongs in, ask what has to be true for it to fail:

The corollary is the honest definition of flakiness: a test that can fail for a reason other than the one it's about is in the wrong layer. Wrong-password-401 that fails because of a rate limiter isn't flaky in some mysterious way — it's a behavior test paying network-and-defense taxes it never needed to pay.

One Last Habit: Test Your Article's Own Code

Every Python block in this post was extracted from the rendered page and run through ast.parse before publishing — a habit we adopted after catching missing imports in one of our own guides. If you publish code, parse it. A snippet with a NameError is a small live-service trap of its own: it works right up until a reader is the client.

The Bottom Line

Structure the suite so each layer can only fail for its own reason: static tests that watch the code's shape, integration tests that exercise real behavior through TestClient with overridden edges, and a smoke layer thin enough to never be mistaken for a load test. And when a live service starts 429ing your pipeline, read it as the compliment it is — your defenses work — then move the tests that triggered them down to the layer where defenses don't apply. The service can't tell your CI from an attacker. Structure things so it never has to.

The Kit That Comes With the Layers

ShipKit ships a structured FastAPI backend — auth, payments, admin — that this testing structure drops onto cleanly. Buy once, own it.

Explore ShipKit
BW

Brandon Wigley

Founder of Wigley Studios. Building developer tools since 2018.

Previous: Stale by Tuesday All Articles