🐍 Backend with Python · Chapter 12
Testing a service: unit, integration, and fixtures
A test suite is only worth having if it fails for exactly one reason — your code broke — and never because of the clock, the network, or the test that ran before it.
Study this lesson free in the app →
The basics
A test is a small program that runs your code with known inputs and fails loudly if the output is wrong. For a service you write two kinds.
The problem it solves: without fast, trustworthy tests every deploy is a guess, and every refactor is a risk nobody wants to take.
The mental model: test your logic in isolation, test your wiring for real, and fake only what you don't own.
- A unit test calls one function directly —
compute_fare(10, 1.5)— with no web server, database or network. Milliseconds each; write hundreds. - An integration test sends a real HTTP request through FastAPI, real validation and a real database (in-memory SQLite or a throwaway Postgres), and checks the response and the rows.
Words you must know 5
- Fixture
- a function pytest runs to build something a test needs (a DB session, a client). Code after
yieldis the teardown, and it runs even when the test fails. - Scope
- how long a fixture lives: per test (
function, the default),module, or the wholesession. - parametrize
- run one test body over a table of inputs; each row is a separately reported test.
- dependency_overrides
- FastAPI's switch for replacing a
Depends(...)with a fake in tests. - monkeypatch
- pytest's fixture for temporarily replacing an attribute or environment variable, undone after the test.
Explained
The caterer's tasting day
A week before a 600-plate wedding in Hyderabad, the caterer runs a tasting.
- Unit tests: the head cook tastes each dish alone — is the biryani salted right, is the double-ka-meetha too sweet? Fast, and when one fails you know exactly which pot.
- Integration test: a full plate is served at a real table, in order, by real servers. Now you catch what no single tasting can — the raita reaches the table after the biryani is cold.
- Fixtures: before every tasting the station is laid out the same way — clean plates, fresh oil — and after it, scrubbed. Nobody tastes today's dal in yesterday's pot.
- Fakes: the family isn't there yet, so someone plays the groom's uncle who wants extra mirchi. You don't fake the stove — the stove is yours, and its quirks are the point.
That last line is the whole lesson: fake the guests, never the stove.
What to test how, for one FastAPI service
| Layer | Example | Speed | How many | | --- | --- | --- | --- | | Unit | compute_fare(km, surge) | < 1 ms | most of them | | Integration | POST /rides → app → SQLite/Postgres → GET /rides/asha | 5–50 ms | every endpoint, happy + main failure | | End-to-end | deployed service, real partners | seconds | a handful, smoke only |
Fake what you don't own; run what you do.
- Payment gateways, SMS providers, the clock, randomness → replace them. They're slow, cost money, or are non-deterministic.
- Your own database queries → run them against a real database. Mocking
session.executemeans you're testing that you called the mock, not that the SQL works.
SQLite in memory is a fine fast default, but it is not Postgres: it is loose about types and lacks Postgres-only features. Run the same integration suite against a throwaway Postgres (a container) in CI.
One test body, a table of cases
@pytest.mark.parametrize("km, surge, expected", [
(1, 1.0, 6200),
(10, 1.0, 17000),
(10, 1.5, 25500),
(10, 5.0, 51000), # surge capped at 3x
])
def test_compute_fare(km, surge, expected):
assert compute_fare(km, surge) == expected
def test_zero_km_rejected():
with pytest.raises(ValueError, match="positive"):
compute_fare(0)pytest -v printed one line per row:
test_fare.py::test_compute_fare[1-1.0-6200] PASSED
test_fare.py::test_compute_fare[10-1.0-17000] PASSED
test_fare.py::test_compute_fare[10-1.5-25500] PASSED
test_fare.py::test_compute_fare[10-5.0-51000] PASSED
test_fare.py::test_zero_km_rejected PASSEDThen we deleted the 3× cap from compute_fare and re-ran:
FAILED test_fare.py::test_compute_fare[10-5.0-51000] - assert 85000 == 51000
1 failed, 4 passed in 0.18sThe failing id names the exact row. That's why a table beats a loop inside one test — a loop stops at the first failure and hides the rest.
Fixture setup and teardown, in order
Real pytest -s output: open db, create user, test_one runs, delete user, create user, test_two runs, delete user, close db, 2 passed.
An integration test: real app, SQLite, fake gateway
# conftest.py
@pytest.fixture(scope="session")
def engine():
eng = create_engine("sqlite://", poolclass=StaticPool,
connect_args={"check_same_thread": False})
Base.metadata.create_all(eng) # schema once per run
yield eng
eng.dispose()
@pytest.fixture
def db(engine):
session = Session(engine)
yield session
session.close()
with engine.begin() as conn: # teardown: empty every table
for table in reversed(Base.metadata.sorted_tables):
conn.execute(table.delete())
@pytest.fixture
def client(db, gateway): # gateway = FakeGateway()
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[get_gateway] = lambda: gateway
yield TestClient(app)
app.dependency_overrides.clear()
# test_rides_api.py
def test_create_ride_charges_and_saves(client, gateway):
r = client.post("/rides", json={"rider": "asha", "km": 10})
assert r.status_code == 201
assert gateway.charges == [("asha", 17000)]
assert client.get("/rides/asha").json() == [{"id": 1, "fare_paise": 17000}]StaticPool keeps one connection, so every session sees the same in-memory database. Whole suite: 9 passed in 0.01s.
The clock that wouldn't freeze
# otp.py
from time import time
def otp_expired(issued_at, ttl=300):
return time() - issued_at > ttl
# test_otp.py
import time, otp
NOW = 1_000_000.0
def test_not_expired(monkeypatch):
monkeypatch.setattr(time, "time", lambda: NOW)
assert otp.otp_expired(issued_at=NOW - 299) is FalseGreen alone, red in CI
Tuesday, 11:40. test_db_is_clean_again passes when run by itself and fails in the full suite:
E AssertionError: assert [{'id': 1, 'f...aise': 17000}] == []
E Left contains one more item: {'id': 1, 'fare_paise': 17000}
FAILED test_rides_api.py::test_db_is_clean_again
1 failed, 8 passed in 0.03sThe row is Asha's ride from the previous test. The engine is session-scoped (good — schema built once), but the db fixture tried to undo each test by wrapping it in an outer transaction and rolling back. On SQLite's default driver settings, that rollback didn't undo the app's commit() — the row survived.
The fix we ran: a plain teardown that deletes every table's rows after each test. Suite: 9 passed. (On Postgres, the outer-transaction-and-rollback recipe works and is faster; know which database your fixture is written for.)
The lesson: a test that depends on run order is broken even when it's green. Shuffle test order in CI (the pytest-randomly plugin does this on every run) to flush these out early.
Gotchas
- Teardown in the test body. If an assert fails first, cleanup never runs. Put it after
yieldin a fixture - Forgetting
app.dependency_overrides.clear(). The fake leaks into every later test in the process - Patching where it's defined, not where it's used.
from x import y→ patchyour_module.y - Mocking your own DB layer. Tests pass, SQL is wrong. Use a real database
- Session-scoped fixtures that are mutable. One test appends to a shared list; the next test sees it. Wide scope only for things that are expensive and read-only
- Testing the happy path only. Every endpoint needs its 422 (bad body) and its "partner failed" case —
test_bad_body_is_422also asserts the gateway was not charged - Sleeping in tests. Inject a clock or monkeypatch time;
time.sleep(2)× 500 tests is a 17-minute CI
Words to know
| Unit test | A test that calls one function directly with no network, database or framework in the way. |
|---|---|
| Integration test | A test that exercises several real parts together — HTTP routing, validation, ORM and a real database. |
| Fixture | A pytest function that builds something a test needs; code after its yield is teardown. |
| Fixture scope | How long a fixture's value lives: function (default), class, module, package or session. |
| parametrize | pytest marker that runs one test body over many argument rows, each reported as its own test. |
| dependency_overrides | A dict on a FastAPI app mapping a dependency callable to a replacement used during tests. |
| monkeypatch | pytest fixture to set attributes, dict items or env vars for one test and restore them afterwards. |
In two minutes
- Unit tests call pure functions directly; integration tests send real HTTP through the app to a real database.
- Fake what you don't own (gateways, SMS, clock); run what you do (your SQL, your DB constraints).
- parametrize turns a table of cases into separately-reported tests; the failing id names the exact row.
- Fixtures build what tests need; code after yield is teardown and runs even if the test fails.
- Scope: expensive read-only things (engine) per session; anything mutable (session, rows) per test.
- FastAPI's app.dependency_overrides swaps Depends(...) for fakes; clear it in fixture teardown.
- monkeypatch replaces attributes and env vars for one test — patch where the name is looked up.
- A test that passes alone and fails in the suite has leaked state; that's a bug even when it's green.
Test yourself
1. What does scope="session" on a pytest fixture mean?
- The fixture runs once per test function
- It is created once for the whole test run and shared by every test that asks for it
- It runs once per HTTP session in TestClient
- It is only available to tests in conftest.py
Show the answer
It is created once for the whole test run and shared by every test that asks for it — Session scope = built once, torn down at the very end. Right for an engine or schema; wrong for anything a test mutates, because the next test sees the change.
2. Why put cleanup code after yield in a fixture rather than at the end of the test function?
- It runs faster
- pytest forbids code after the last assert
- It lets the fixture be parametrized
- Teardown after yield still runs when the test's assertion fails; code at the end of the test is skipped
Show the answer
Teardown after yield still runs when the test's assertion fails; code at the end of the test is skipped — A failing assert raises and the rest of the test never executes, so inline cleanup leaks state into later tests. Fixture teardown is guaranteed.
3. Why fake the payment gateway but use a real database in integration tests?
- You don't own the gateway and it costs money or flakes; your SQL and constraints are your code's behaviour, so mocking them tests nothing real
- Databases can't be mocked in Python
- Gateways are faster than databases
- FastAPI can only override non-database dependencies
Show the answer
You don't own the gateway and it costs money or flakes; your SQL and constraints are your code's behaviour, so mocking them tests nothing real — Fake the guests, not the stove. A mocked session.execute passes even when your query is wrong.
4. How does app.dependency_overrides replace a dependency in a FastAPI test?
- It patches the function's source code
- It replaces the route handler
- It maps the original dependency callable to a replacement, and every Depends(original) resolves to the replacement while it's set
- It only works for async dependencies
Show the answer
It maps the original dependency callable to a replacement, and every Depends(original) resolves to the replacement while it's set — Keys are the original callables (get_db, get_gateway). It stays in effect until you remove it — hence .clear() in teardown.
5. What is the difference between a unit test and an integration test for this service?
- Unit tests use pytest; integration tests use unittest
- A unit test calls one function directly with no I/O; an integration test drives the real app through HTTP to a real database
- Unit tests run in CI only
- Integration tests never use fixtures
Show the answer
A unit test calls one function directly with no I/O; an integration test drives the real app through HTTP to a real database — Both use pytest and fixtures. The difference is how much real machinery sits between the test and the assertion.
6. What happens if a fixture sets app.dependency_overrides[get_gateway] and never clears it?
- pytest clears it automatically
- FastAPI raises on the next request
- Only that test is affected
- Every later test in the same process still gets the fake, including tests meant to check the real wiring
Show the answer
Every later test in the same process still gets the fake, including tests meant to check the real wiring — dependency_overrides is a plain dict on a module-level app object. pytest knows nothing about it; it leaks until the process ends, making results depend on test order.
There are 10 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.
Start this lesson →