◐ ai-teacher

🐍 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.

Words you must know 5

Fixture
a function pytest runs to build something a test needs (a DB session, a client). Code after yield is the teardown, and it runs even when the test fails.
Scope
how long a fixture lives: per test (function, the default), module, or the whole session.
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.

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.

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 PASSED

Then 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.18s

The 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 False

Green 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.03s

The 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

Words to know

Unit testA test that calls one function directly with no network, database or framework in the way.
Integration testA test that exercises several real parts together — HTTP routing, validation, ORM and a real database.
FixtureA pytest function that builds something a test needs; code after its yield is teardown.
Fixture scopeHow long a fixture's value lives: function (default), class, module, package or session.
parametrizepytest marker that runs one test body over many argument rows, each reported as its own test.
dependency_overridesA dict on a FastAPI app mapping a dependency callable to a replacement used during tests.
monkeypatchpytest fixture to set attributes, dict items or env vars for one test and restore them afterwards.

In two minutes

Test yourself

1. What does scope="session" on a pytest fixture mean?

  1. The fixture runs once per test function
  2. It is created once for the whole test run and shared by every test that asks for it
  3. It runs once per HTTP session in TestClient
  4. 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?

  1. It runs faster
  2. pytest forbids code after the last assert
  3. It lets the fixture be parametrized
  4. 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?

  1. 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
  2. Databases can't be mocked in Python
  3. Gateways are faster than databases
  4. 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?

  1. It patches the function's source code
  2. It replaces the route handler
  3. It maps the original dependency callable to a replacement, and every Depends(original) resolves to the replacement while it's set
  4. 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?

  1. Unit tests use pytest; integration tests use unittest
  2. A unit test calls one function directly with no I/O; an integration test drives the real app through HTTP to a real database
  3. Unit tests run in CI only
  4. 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?

  1. pytest clears it automatically
  2. FastAPI raises on the next request
  3. Only that test is affected
  4. 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.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →