Module 09 · ~7 hours · Priority: High

Testing Properly

pytest fixtures, a throwaway database per run, the difference between a unit and an integration test, and what a coverage number does and does not tell you.

What tests are actually for start here

Not proof of correctness. A fast feedback loop, and a tripwire that goes off when you break something you have already forgotten about.

What
A test is an ordinary function that runs part of your app and asserts something about the result, run by a tool that reports which ones failed.
Where
A tests/ folder beside app/, committed to git, run from your terminal and later from a build pipeline.
When
The moment a project outgrows what you can re-check by hand — which for the City Guide API was roughly Module 06.
How
pytest, one throwaway database per run, and assertions at the HTTP boundary where your clients actually live.

You already have three broken tests. In Module 02 you wrote smoke tests against an in-memory Python list. In Module 06 you replaced that list with Postgres and those tests stopped meaning anything — either they fail outright, or worse, they still pass while testing a code path the app no longer uses. That is the whole motivation for this module. A test suite that does not track the code is not neutral; it is a liar you trust.

The honest argument for a beginner has nothing to do with quality or professionalism. It is this: right now, after every change, you run four or five curl commands in a second terminal, eyeball the JSON, and decide it looks fine. You will keep that up for about a week. Then you will do three of the five. Then one. Then you will change the rating validator at 11pm and ship a 500 to yourself. A test suite is those same five curls, written down once, run in two seconds, checked by something that does not get bored.

  • Tests buy you the right to change code. Refactoring without tests is gambling — you are betting you remembered every caller. With tests, the bet is settled in two seconds instead of in production.
  • They are a memory aid, not a quality gate. Six weeks from now you will not remember that place names must be unique per neighbourhood. The test remembers, and it will tell you the moment you break it.
  • The failure message is half the value. A good test does not just say "broken", it says which contract broke: "creating a duplicate place returned 500, expected 409". That sentence is most of the debugging.
  • They do not prove correctness. A passing suite means "none of the things I thought to check are broken". That is enormously useful and it is not the same as "the code is right". Anyone who tells you otherwise is selling something.
The trap is testing that the code does what the code does. A test that reads your handler, copies its logic into the assertion, and checks that they agree will never fail and never help. A test asserts the contract — the status code, the shape of the body, the row that must exist afterwards — written from the outside, as a caller would state it.

pytest, the working subset

What
A test runner that finds functions named test_*, runs each one, and reports which raised an exception.
Where
pip install pytest httpx pytest-cov into the same .venv from Module 00. httpx is what FastAPI's test client is built on.
When
Every time you change application code. Bind it to a key in your editor if that is what it takes.
How
Plain assert statements, no special assertion methods, no class hierarchy to inherit from.

Discovery is convention, not configuration. Run pytest with no arguments and it walks down from your current folder collecting: files named test_*.py or *_test.py, functions inside them named test_*, and classes named Test* (which must have no __init__). Anything not matching those patterns is invisible to it — which is the single most common "why is pytest saying no tests ran" cause. You named the file places_test_api.py and it collected nothing.

pytest                     # run everything it can find
pytest -v                  # one line per test with its name and result
pytest -q                  # quiet: dots for passes, summary at the end
pytest -k duplicate        # only tests whose name contains "duplicate"
pytest -x                  # stop at the first failure - fastest feedback loop
pytest --lf                # "last failed": rerun only what failed last time
pytest -x --lf             # the loop you live in while fixing something
pytest tests/test_places.py::test_get_missing_place_returns_404   # one exact test

Why plain assert works. Most test frameworks need assertEqual(a, b) because a bare assertion can only report "false". pytest rewrites your test files' bytecode as it imports them, so it can see the sub-expressions and print their values. That is why this is enough:

def test_rating_must_be_1_to_5(client):
    r = client.post("/places", json={"name": "Bad", "rating": 9})
    assert r.status_code == 422

and why the failure tells you everything without you having written a message:

$ pytest -q
F                                                                        [100%]
=================================== FAILURES ===================================
___________________________ test_rating_must_be_1_to_5 _________________________

client = <starlette.testclient.TestClient object at 0x104f3d9d0>

    def test_rating_must_be_1_to_5(client):
        r = client.post("/places", json={"name": "Bad", "rating": 9})
>       assert r.status_code == 422
E       assert 201 == 422
E        +  where 201 = <Response [201 Created]>.status_code

tests/test_places.py:34: AssertionError
=========================== short test summary info ============================
FAILED tests/test_places.py::test_rating_must_be_1_to_5 - assert 201 == 422
1 failed in 0.42s

Read this the way you read a traceback in Module 00 — bottom first. The last line names the file, the test, and the one-sentence reason. Above it, the E lines are pytest's assertion introspection: assert 201 == 422 is the comparison it evaluated, and where 201 = <Response ...>.status_code tells you where the left-hand value came from. The > marks the exact failing line, and the lines above it are the test's own source replayed so you can see the setup. The header line names the fixtures that were passed in — here, client.

  • A test that raises any exception fails. assert is not special; a KeyError in your test code fails it just the same, and pytest prints a normal traceback. That is a bug in the test, not in the app — learn to tell those apart at a glance.
  • One behaviour per test, and the name says which. test_create_place_returns_201 beats test_places. When it goes red six weeks from now, the name is the first thing you read and often the last thing you need.
  • Add an empty tests/__init__.py and run pytest from the project root. Without it pytest puts each test file's folder on sys.path, which breaks the moment two folders both contain a test_places.py.
  • pytest -q is the one to memorise. Dots scroll, verbose output does not. You want to see fourteen dots and a green line, not fourteen paragraphs.

Fixtures: the one concept that matters

A fixture is a named piece of setup that pytest builds for you, hands to any test that asks for it by name, and tears down afterwards.

What
A function decorated with @pytest.fixture whose return (or yield) value is injected into every test that lists its name as a parameter.
Where
Next to the tests that use it, or in conftest.py when more than one file needs it.
When
Any time two tests need the same setup — a database session, a logged-in client, a sample row.
How
Declare it once, then name it as an argument. There is no import, no registration, no wiring.
import pytest

@pytest.fixture()
def sample_place():
    return {"name": "Blue Bottle", "neighbourhood": "Mission", "rating": 4}

def test_create_accepts_a_valid_place(client, sample_place):
    r = client.post("/places", json=sample_place)      # <- both fixtures injected
    assert r.status_code == 201

The match is by argument name. pytest sees a parameter called sample_place, looks for a fixture of that name, runs it, and passes the result. This is dependency injection — the same idea as FastAPI's Depends from Module 05, which is why get_db and a pytest fixture feel so similar. Fixtures can depend on other fixtures the same way: client below will ask for db_session, which asks for engine.

yield splits setup from teardown. Everything before the yield runs before the test; everything after runs when the test finishes, pass or fail. A fixture that returns with return has setup only; one that yields can clean up after itself. That is the mechanism the entire next section is built on.

Scope decides how often it is rebuilt, and it is a straight speed-versus-isolation trade:

ScopeBuiltGood forThe cost
function (default)Once per testAnything holding data: sessions, clients, sample rowsSlowest. You pay the setup cost N times.
moduleOnce per test fileModerately expensive setup shared by a themed groupState leaks between tests in that file.
sessionOnce per pytest runTruly immutable, expensive things: the engine, the schemaOne test can poison every later test, and the failure appears somewhere else entirely.

The rule that follows: session scope for things nobody writes to, function scope for everything else. Creating a database engine and the tables is session-scoped because it is slow and read-only. The session that inserts rows is function-scoped because it is neither. Getting this backwards produces the worst class of bug in a test suite — a test that passes alone and fails when run after another one, or fails only when you reorder the file.

conftest.py is where shared fixtures live. pytest imports it automatically — you never import it yourself — and it searches upward from each test file through every parent directory to the root, collecting every conftest.py it passes. So tests/conftest.py serves all tests, and tests/integration/conftest.py would add extras for that subfolder only. A fixture defined nearer the test wins.

In other stacks: Jest and Vitest use beforeEach/afterEach hooks and a shared setup file; Django uses setUp/tearDown on a TestCase class; RSpec uses let and before blocks plus factories. pytest's difference is that setup is requested by name rather than inherited or implied, so a test declares exactly what it needs and gets nothing else — which is why you can read one test function and know the whole world it runs in.

A throwaway database per run

This is the practical core of the module. Get it right once and every test after it is easy.

What
A second, empty database that the test suite creates, fills, and wipes, so tests never touch the data you are developing against.
Where
A database called cityguide_test inside the same Postgres container from Module 00.
When
Before you write a single test that touches the database — retrofitting isolation into a suite that already has forty tests is miserable.
How
Create the schema once per run, wrap every test in a transaction, roll it back afterwards, and point the app at it with app.dependency_overrides.

You have two real options and one wrong one. The wrong one is running tests against your development database: the first test that deletes all places deletes the places you were using five minutes ago.

Separate DB in the same containerSQLite in memory
SpeedFast enough — milliseconds per test with transaction rollbackFaster, no network at all
FidelityIdentical engine, version, types, and constraints to productionA different database wearing your SQL as a costume
SetupOne CREATE DATABASE, plus Docker already runningNone
VerdictUse this.Only for pure logic with no SQL in it

Say the SQLite problem plainly, because it is not theoretical. SQLite is dynamically typed and ignores VARCHAR(80) lengths, so a test inserting a 400-character place name passes — then Postgres raises StringDataRightTruncation the first time a real user does it. SQLite has foreign keys off by default, so saving a place_id that points at nothing passes, and Postgres raises ForeignKeyViolation. It permits a GROUP BY Postgres rejects, has no JSONB, no native ARRAY. Each of those is a green suite and a broken production. Fidelity to the thing you ship is the entire point; do not trade it for 200 milliseconds.

# create the test database once - it lives in the same container as the real one
$ docker compose exec db psql -U cityguide -d cityguide -c "CREATE DATABASE cityguide_test;"
CREATE DATABASE

# add to .env (and .env.example with a fake value, as in Module 00)
TEST_DATABASE_URL=postgresql://cityguide:localdevpassword@localhost:5432/cityguide_test

Schema: create_all or Alembic? Base.metadata.create_all() builds the tables straight from the Module 05 models — one line, instant. Running your Alembic migrations instead is slower but also proves the migrations work, which is a real bug class: a migration that has drifted from the models is invisible until deploy day. Start with create_all, then add one test that runs alembic upgrade head against an empty database, and you get both.

Transaction-per-test is the trick that makes tests independent. Open a connection, begin a transaction, run the test inside it, roll back. Every row the test inserted vanishes — no DELETE statements, no truncation, no ordering rules, and it is fast because rolling back an uncommitted transaction is nearly free.

session scope create_engine + Base.metadata.create_all() (once per run) | v function scope connection.begin() --> Session(join via SAVEPOINT) | | | v | test inserts / commits rows | | v v teardown transaction.rollback() --> those rows never existed

The subtlety: your handlers call db.commit(). A naive setup would have that commit escape and make the rows permanent. The fix is join_transaction_mode="create_savepoint" (SQLAlchemy 2.0) — the session's commit() then releases a SAVEPOINT inside your outer transaction instead of ending it, so your final rollback() still erases everything. Here is the whole file:

# tests/conftest.py
import os
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.main import app
from app.database import Base, get_db      # Base + get_db from Module 05
import app.models                          # noqa: F401 - import so Base knows the tables

TEST_DATABASE_URL = os.getenv(
    "TEST_DATABASE_URL",
    "postgresql://cityguide:localdevpassword@localhost:5432/cityguide_test",
)


@pytest.fixture(scope="session")
def engine():
    """One engine and one schema for the whole run. Nobody mutates this."""
    eng = create_engine(TEST_DATABASE_URL)
    Base.metadata.drop_all(bind=eng)      # start from nothing, even after a crash
    Base.metadata.create_all(bind=eng)
    yield eng
    eng.dispose()


@pytest.fixture()
def db_session(engine):
    """A session inside a transaction that is always rolled back."""
    connection = engine.connect()
    transaction = connection.begin()
    TestingSession = sessionmaker(
        bind=connection,
        autoflush=False,
        join_transaction_mode="create_savepoint",   # app commits stay inside
    )
    session = TestingSession()
    try:
        yield session
    finally:
        session.close()
        transaction.rollback()            # undo everything the test did
        connection.close()


@pytest.fixture()
def client(db_session):
    """A TestClient whose app talks to the test session, not the real database."""
    def override_get_db():
        yield db_session                  # same shape as the real get_db

    app.dependency_overrides[get_db] = override_get_db
    with TestClient(app) as c:            # 'with' runs startup/shutdown handlers
        yield c
    app.dependency_overrides.clear()      # never leak an override into the next test


@pytest.fixture()
def auth_client(client):
    """A client with a registered user's Bearer token already on every request."""
    creds = {"email": "tester@example.com", "password": "correct-horse-battery"}
    client.post("/auth/register", json=creds)
    token = client.post("/auth/login", json=creds).json()["access_token"]
    client.headers["Authorization"] = f"Bearer {token}"
    return client

app.dependency_overrides deserves a paragraph of its own. It is a plain dictionary on the FastAPI app whose keys are the dependency callables you wrote and whose values are replacements. When a request arrives and FastAPI is about to resolve Depends(get_db), it first checks that dictionary; if get_db is a key, it calls your replacement instead. Nothing in app/ changes, no environment variable is read, no import is patched. Your routers still say Depends(get_db) and are completely unaware. This is the payoff for having a get_db dependency at all instead of importing a global session — a design decision from Module 05 that only now shows why it was worth it.

  • drop_all before create_all is your crash insurance. If a previous run died mid-test with the schema half-migrated, the next run starts from a known empty state instead of a mysterious one.
  • Never point TEST_DATABASE_URL at cityguide. Consider adding an assertion in conftest.py that the URL ends in _test. It costs one line and prevents the one mistake in this module that destroys real work.
  • The override is cleared in teardown, not just set in setup. dependency_overrides lives on the app object, which is a module-level global shared by every test. Forget the clear() and a later test silently uses a closed session from an earlier one.

Testing the API, including every way it fails

What
TestClient sends real requests through your whole FastAPI app — routing, validation, dependencies, handler, serialisation — without opening a network port.
Where
tests/test_places.py, tests/test_auth.py: one file per resource.
When
For essentially everything in an API project. This is where the contract lives.
How
client.get(...), client.post(..., json=...), then assert on r.status_code and r.json().

The happy path is one test. The error taxonomy from Module 06 — 404, 422, 409, 503 — is four more, and those four are the ones that will actually catch a regression, because the happy path is the code you look at every day and the error branches are the code you never run by hand.

# tests/test_places.py
import pytest
from sqlalchemy.exc import OperationalError

from app.main import app
from app.database import get_db

VALID = {"name": "Blue Bottle", "neighbourhood": "Mission", "rating": 4}


def test_create_place_returns_201_and_echoes_the_body(client):
    r = client.post("/places", json=VALID)
    assert r.status_code == 201
    body = r.json()
    assert body["name"] == "Blue Bottle"
    assert body["id"] > 0                       # the database assigned it


def test_created_place_is_readable_afterwards(client):
    place_id = client.post("/places", json=VALID).json()["id"]
    r = client.get(f"/places/{place_id}")
    assert r.status_code == 200
    assert r.json()["neighbourhood"] == "Mission"


def test_list_starts_empty(client):
    # proves isolation: the tests above committed rows, and they are gone
    assert client.get("/places").json() == []


def test_get_missing_place_returns_404(client):
    r = client.get("/places/999999")
    assert r.status_code == 404
    assert r.json()["detail"] == "Place not found"


def test_delete_then_get_returns_404(client):
    place_id = client.post("/places", json=VALID).json()["id"]
    assert client.delete(f"/places/{place_id}").status_code == 204
    assert client.get(f"/places/{place_id}").status_code == 404


@pytest.mark.parametrize(
    "bad",
    [
        {"name": "X", "neighbourhood": "Mission", "rating": 9},    # above max
        {"name": "X", "neighbourhood": "Mission", "rating": 0},    # below min
        {"name": "", "neighbourhood": "Mission", "rating": 3},     # empty name
        {"neighbourhood": "Mission", "rating": 3},                 # name missing
    ],
)
def test_invalid_place_returns_422(client, bad):
    r = client.post("/places", json=bad)
    assert r.status_code == 422
    assert "detail" in r.json()                 # FastAPI's field-level errors


def test_duplicate_name_in_same_neighbourhood_returns_409(client):
    assert client.post("/places", json=VALID).status_code == 201
    r = client.post("/places", json=VALID)
    assert r.status_code == 409
    assert "already exists" in r.json()["detail"]


def test_health_returns_503_when_the_database_is_down(client):
    def broken_db():
        raise OperationalError("SELECT 1", {}, Exception("connection refused"))

    app.dependency_overrides[get_db] = broken_db    # cleared by the client fixture
    r = client.get("/health")
    assert r.status_code == 503
    assert r.json()["detail"] == "database unavailable"

@pytest.mark.parametrize turns one test into four. Each row runs as a separate test with its own name in the report (test_invalid_place_returns_422[bad3]), so when only the empty-name case breaks you see exactly which one — without writing four near-identical functions.

The 503 test is worth studying. You cannot politely ask Postgres to fall over mid-test, so you swap the dependency for one that raises what a dead database raises. That is not mocking your own logic — it is forcing a branch that is otherwise unreachable. The Module 06 exception handler is the thing under test, and this is the only way to reach it.

Authenticated endpoints are one fixture, not boilerplate in twelve tests. The auth_client fixture registers a user, logs in, and pins the Authorization header onto the client, so every protected test reads as if auth were free:

# tests/test_saved_lists.py

def test_saving_a_place_without_a_token_returns_401(client):
    r = client.post("/lists/favourites/places", json={"place_id": 1})
    assert r.status_code == 401


def test_saving_a_place_with_a_token_returns_201(auth_client):
    place_id = auth_client.post("/places", json=VALID).json()["id"]
    r = auth_client.post("/lists/favourites/places", json={"place_id": place_id})
    assert r.status_code == 201


def test_my_list_contains_only_my_saved_places(auth_client):
    place_id = auth_client.post("/places", json=VALID).json()["id"]
    auth_client.post("/lists/favourites/places", json={"place_id": place_id})
    names = [p["name"] for p in auth_client.get("/lists/favourites").json()]
    assert names == ["Blue Bottle"]


def test_a_garbage_token_returns_401(client):
    client.headers["Authorization"] = "Bearer not.a.real.token"
    r = client.get("/lists/favourites")
    assert r.status_code == 401
Do not assert on the token's contents. It is tempting to decode the JWT in a test and check the sub claim. That test asserts an implementation detail from Modules 07-08 and will break the day you add a claim — while telling you nothing about whether auth works. Assert the observable contract instead: no token is 401, a valid token is 200, another user's data is 404 or 403.

Unit, integration, and what is worth testing

What
A unit test exercises one function with no I/O. An integration test exercises several pieces together, including the database.
Where
Both live in tests/. The distinction is about what the test touches, not where the file sits.
When
Unit-test anything with real logic and no dependencies. Integration-test the HTTP boundary. Skip the rest.
How
If it needs the client or db_session fixture, it is an integration test. If it imports one function and calls it, it is a unit test.

There is an enormous amount of dogma attached to these two words. Ignore all of it; the definitions above are the only part that changes a decision you will actually make.

# unit: no fixtures, no database, no HTTP. Milliseconds.
from app.security import hash_password, verify_password

def test_password_verifies_against_its_own_hash():
    h = hash_password("correct-horse-battery")
    assert h != "correct-horse-battery"          # it is actually hashed
    assert verify_password("correct-horse-battery", h)
    assert not verify_password("wrong", h)


# integration: routing + validation + session + SQL + serialisation, all at once
def test_duplicate_name_in_same_neighbourhood_returns_409(client):
    ...

For an API like this one, the value is overwhelmingly in the integration tests. Not because unit tests are bad, but because of what your code is made of. Look at a handler from Module 06: it validates input, calls the session, catches an IntegrityError, maps it to 409, serialises a response. Almost none of that is standalone logic — it is wiring, and wiring can only be tested by running it. A unit test of a handler with a mocked session proves that you wrote the handler you wrote. An integration test proves that a client sending that JSON gets that status code, which is the actual promise you are making.

Unit tests earn their place where real logic lives with no I/O attached: password hashing, a rating or distance calculation, a slug generator, a permission check that is a pure function of a user and a resource. If something you want to unit-test needs three mocks to run, that is the code telling you its logic and its I/O are tangled together — a design signal, not a testing problem.

Worth testing:

  • Every error branch you wrote by hand. The 404, the 409, the 503, the 401. These are the least-exercised and highest-consequence paths in the whole codebase.
  • Every validation rule. If a Pydantic model says rating is 1 to 5, there is a test for 0 and a test for 6. Rules that are not tested get deleted during a refactor by someone who assumed they were decorative.
  • Anything you got wrong once. The moment you fix a bug, write the test that would have caught it — before the fix if you can, so you watch it go red then green. This is the highest-value test you will ever write, because a bug that happened once is evidence about where bugs happen.
  • Anything where being wrong is expensive. Auth, ownership checks, anything touching money or deletion. Test the negative case harder than the positive one.

Not worth testing: that FastAPI returns 422 for a malformed body in general (that is FastAPI's test suite, not yours — test your specific rules), that a getter returns what you set, that SQLAlchemy can insert a row, that a third-party library does what its README says. Every test has a maintenance cost, and a test that can only fail when someone else's library breaks is pure cost.

Coverage is a signal, never a target

What
A measurement of which lines of your code ran at least once while the tests ran.
Where
pip install pytest-cov, then pytest --cov=app.
When
Occasionally, to find code no test has ever reached. Not on every run, and never as a number you must hit.
How
--cov-report=term-missing and read the Missing column, which is the only column that tells you anything actionable.
$ pytest --cov=app --cov-report=term-missing -q
..............                                                           [100%]

---------- coverage: platform darwin, python 3.12.3-final-0 ----------
Name                     Stmts   Miss  Cover   Missing
------------------------------------------------------
app/__init__.py              0      0   100%
app/database.py             18      2    89%   31-32
app/main.py                 14      0   100%
app/models.py               34      0   100%
app/routers/places.py       61      4    93%   88-91
app/security.py             40      6    85%   57, 62-66
------------------------------------------------------
TOTAL                      167     12    93%

14 passed in 2.31s

Read the Missing column, not the percentage. app/security.py lines 62-66 — open the file and look. If that is the branch that rejects an expired token, you have just discovered that nothing in your suite has ever proved an expired token is refused, which is a genuine hole in an auth system. That discovery is what coverage is for, and it is worth real money.

Now the honest part. 100% coverage means every line executed. It does not mean any line behaved correctly. This test scores identically to a good one:

def test_create_place(client):
    client.post("/places", json=VALID)     # no assert. Covers the whole handler.

It runs the router, the validation, the session, the commit and the serialiser — and it passes whether the endpoint returns 201, 500, or a picture of a horse. Coverage counts lines, and a line that ran while producing garbage is still a line that ran. Any team that sets "90% coverage" as a gate gets 90% coverage, and a meaningful share of it will be tests exactly like that one, written at 5pm to clear the gate.

  • Use it as a search tool. "Which error handlers have never run?" is a great question, and coverage answers it in two seconds. That is where it genuinely earns its keep — error paths are hard to notice by reading, and trivial to spot in the Missing column.
  • Branch coverage is stricter than line coverage. pytest --cov=app --cov-branch also asks whether each if went both ways. A one-line if x: return counts as covered when only the true side ever ran; branch coverage catches that.
  • Uncovered lines are facts; covered lines are not. Zero coverage on a function is proof no test touches it. Full coverage is not proof of anything. The measurement is only trustworthy in one direction.

Test doubles, briefly

What
A stand-in object that replaces something real during a test — a fake clock, a fake HTTP call, a fake file.
Where
pytest's built-in monkeypatch fixture, or unittest.mock.patch from the standard library.
When
Only when the real thing is slow, non-deterministic, or outside your control.
How
Replace the attribute where it is used, not where it is defined, and let pytest undo it afterwards.
def test_token_expiry_is_one_hour(monkeypatch):
    # freeze the clock: the only way to test time-dependent code deterministically
    monkeypatch.setattr("app.security.now", lambda: FIXED_TIME)
    ...
    # monkeypatch reverses every change automatically at teardown

One rule covers 95% of it: mock at a boundary you do not own. The clock, the filesystem, an outbound call to someone else's service, a random number generator. Those are slow, flaky, or unrepeatable, and faking them makes a test deterministic.

Never mock your own database in a suite whose job is to prove your SQL works. A test with a mocked session asserts that you called session.add() — and passes happily while your unique constraint is missing, your foreign key points at the wrong column, and your query returns rows in an order you did not expect. You built a real throwaway database precisely so you never need this. If a test is hard to write against the real database, fix the fixture, not the test.

Build this

Target: a tests/ folder with a conftest.py that creates cityguide_test, wraps each test in a rolled-back transaction, and overrides get_db — plus at least twelve tests covering CRUD, all four error statuses from Module 06, and the authenticated endpoints from Modules 07-08. pytest -q is green, runs in under five seconds, and gives the identical result twice in a row.
  1. Delete the three Module 02 smoke tests. They test a list that no longer exists. Do not try to save them.
  2. pip install pytest httpx pytest-cov, pip freeze > requirements.txt, create cityguide_test, add TEST_DATABASE_URL to .env and .env.example.
  3. Write tests/conftest.py with the four fixtures: engine, db_session, client, auth_client. Get one trivial test passing before writing any others.
  4. Write twelve or more tests: create, read, list, update, delete, 404, 409, 422 (parametrised), 503, plus 401-without-token, 201-with-token, and one that reads back a saved list.
  5. Run pytest --cov=app --cov-report=term-missing. Pick the largest gap in the Missing column and write one test that closes it.
  6. Break it on purpose. In the create handler, change status_code=201 to 200. Run the suite and write down exactly which tests went red — by name — before you undo it. Then remove the le=5 from the rating field in your Pydantic model and do it again.

Verify it yourself

Every check below is pass/fail on its own. If the second run of the suite differs from the first, your isolation is broken and nothing else in this module counts.

$ docker compose ps
NAME              STATUS
city-guide-db-1   Up 3 minutes          # the container from Module 00 must be up

$ pytest -q
..............                                                           [100%]
14 passed in 2.31s                       # your count may differ; 12+ and 0 failed

$ pytest -q                              # RUN IT AGAIN, immediately
..............                                                           [100%]
14 passed in 2.28s                       # identical result = the rollback works
                                         # a 409 or "duplicate key" here = leaking rows

$ pytest --cov=app --cov-report=term-missing -q | tail -n 12
Name                     Stmts   Miss  Cover   Missing
------------------------------------------------------
app/database.py             18      2    89%   31-32
app/main.py                 14      0   100%
app/models.py               34      0   100%
app/routers/places.py       61      4    93%   88-91
app/security.py             40      6    85%   57, 62-66
------------------------------------------------------
TOTAL                      167     12    93%   # the Missing column must be there

# ---- the deliberate break: status_code=201 -> 200 in the create handler ----
$ pytest -q
.FF...F.......                                                           [100%]
=========================== short test summary info ============================
FAILED tests/test_places.py::test_create_place_returns_201_and_echoes_the_body
  - assert 200 == 201
FAILED tests/test_places.py::test_duplicate_name_in_same_neighbourhood_returns_409
  - assert 200 == 201
FAILED tests/test_saved_lists.py::test_saving_a_place_with_a_token_returns_201
  - assert 200 == 201
3 failed, 11 passed in 2.40s             # named failures, one line of code changed

# ---- undo that, then remove le=5 from the rating field ----
$ pytest -q -k invalid
.F...                                                                    [100%]
=========================== short test summary info ============================
FAILED tests/test_places.py::test_invalid_place_returns_422[bad0]
  - assert 201 == 422
1 failed, 4 passed in 0.61s              # only the "rating 9" row breaks. Undo it.

$ psql -h localhost -U cityguide -d cityguide -c "SELECT count(*) FROM places;"
 count
-------
     0                                   # or whatever you had BEFORE testing.
                                         # The test suite touched nothing here.
The two runs in a row are the real test of your test suite. If run one passes and run two fails with a duplicate-key or 409 error, your transaction is not rolling back — almost always because join_transaction_mode="create_savepoint" is missing and your handler's db.commit() is committing for real. Fix that before anything else; every test you write on a leaking fixture is a test you will rewrite.

Prove it

Write your answers in notes/module-09.md first, in your own words, before opening any of them. Where your answer differs from the one here, work out which of you is right — that gap is the module.

1. Why must tests not depend on each other's data, and what exactly in your fixtures guarantees it?

Show answer

Because a test that depends on a previous test's rows cannot be run alone, cannot be reordered, and lies about what broke — you get five failures from one real bug, in tests that have nothing to do with it. Order-dependence is also invisible until the day it bites: pytest can be told to shuffle, run in parallel, or run one file, and any of those breaks the chain. The guarantee here is structural, not disciplinary. Each test gets a function-scoped db_session that opens its own connection, begins a transaction, and rolls it back in teardown — the rollback runs even when the test fails, because it is in a finally. The only session-scoped thing is the engine and the schema, which no test writes to.

2. What is app.dependency_overrides actually replacing, and why not just change DATABASE_URL in the environment?

Show answer

It is a dictionary on the FastAPI app mapping a dependency callable to a substitute. Before resolving any Depends(get_db), FastAPI looks up get_db in it and calls your replacement if present. It replaces the function, not the connection string. That matters: swapping DATABASE_URL would give the app a different database but still its own engine, its own sessions, and its own commits — so your test could not see uncommitted rows, and rollback-based isolation would be impossible because the transaction would belong to the app, not to your fixture. The override is what makes the app and the test share one session and one transaction. It is also why get_db was written as a dependency in Module 05 rather than as an imported global.

3. Give a concrete test that passes against SQLite and fails against Postgres.

Show answer

Several. Insert a place whose name is 400 characters into a String(80) column: SQLite ignores declared lengths entirely and stores it, so the test is green; Postgres raises StringDataRightTruncation. Or save a place_id that does not exist into saved_places: SQLite has foreign key enforcement off by default, so it inserts happily; Postgres raises ForeignKeyViolation. It also runs in the other direction — a SELECT with a column missing from GROUP BY works in SQLite and is a hard error in Postgres, so a query you tested successfully fails on deploy. The general form is always the same: SQLite is more permissive, so your suite silently approves things production will reject.

4. Your coverage says 100%. What have you proved, and what have you not?

Show answer

You have proved that every line of app/ was executed at least once during the run. That is a real fact, and it rules out one thing: dead code that no test reaches. You have proved nothing about behaviour. A test that calls an endpoint and asserts nothing produces the same number as one that checks five things. You have also not proved every path ran — plain line coverage counts a one-line if as covered when only one branch ever fired, which is why --cov-branch exists. Read it as a map of what is untested, never as a score. The useful sentence is "lines 62-66 have never run", not "we are at 93%".

5. If you could keep exactly one test from your suite, which one, and why?

Show answer

There is no single right answer, but there is a right way to choose: keep the test whose failure would cost the most and whose breakage is least likely to be noticed by hand. That is almost never the happy path — you exercise create-a-place every time you touch the app. A strong choice is test_saving_a_place_without_a_token_returns_401, or better, a test that user A cannot read user B's saved list. Auth regressions are silent (everything still returns 200, just to the wrong person), catastrophic, and easy to introduce by deleting one Depends. The general rule: rank tests by consequence-of-failure times probability-of-not-noticing, not by how much code they cover.

6. You changed one line and three tests went red. Is that good or bad, and what would you conclude if forty had?

Show answer

Three is healthy: the change had a real, bounded consequence and the suite located it precisely. Zero would be the alarming result — it would mean the status code you deliberately broke is asserted nowhere, and the suite is decorative. Forty would tell you something different: your tests are coupled to one path through the app, so every one of them depends on create-returning-201 as setup. That is a smell, not a disaster, but it makes failures unreadable — you would have to guess which of the forty is the real cause. The usual cure is to build setup data through fixtures or the database session directly rather than through the endpoint under test, so a broken endpoint fails only the tests that are about it.