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.
Not proof of correctness. A fast feedback loop, and a tripwire that goes off when you break something you have already forgotten about.
tests/ folder beside app/, committed to git, run from your terminal and later from a build pipeline.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.
test_*, runs each one, and reports which raised an exception.pip install pytest httpx pytest-cov into the same .venv from Module 00. httpx is what FastAPI's test client is built on.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.
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.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.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.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.
@pytest.fixture whose return (or yield) value is injected into every test that lists its name as a parameter.conftest.py when more than one file needs it.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:
| Scope | Built | Good for | The cost |
|---|---|---|---|
function (default) | Once per test | Anything holding data: sessions, clients, sample rows | Slowest. You pay the setup cost N times. |
module | Once per test file | Moderately expensive setup shared by a themed group | State leaks between tests in that file. |
session | Once per pytest run | Truly immutable, expensive things: the engine, the schema | One 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.
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.
This is the practical core of the module. Get it right once and every test after it is easy.
cityguide_test inside the same Postgres container from Module 00.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 container | SQLite in memory | |
|---|---|---|
| Speed | Fast enough — milliseconds per test with transaction rollback | Faster, no network at all |
| Fidelity | Identical engine, version, types, and constraints to production | A different database wearing your SQL as a costume |
| Setup | One CREATE DATABASE, plus Docker already running | None |
| Verdict | Use 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.
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.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.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.TestClient sends real requests through your whole FastAPI app — routing, validation, dependencies, handler, serialisation — without opening a network port.tests/test_places.py, tests/test_auth.py: one file per resource.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
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.
tests/. The distinction is about what the test touches, not where the file sits.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:
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.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.
pip install pytest-cov, then pytest --cov=app.--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.
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.monkeypatch fixture, or unittest.mock.patch from the standard library.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.
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.
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.
pip install pytest httpx pytest-cov, pip freeze > requirements.txt, create cityguide_test, add TEST_DATABASE_URL to .env and .env.example.tests/conftest.py with the four fixtures: engine, db_session, client, auth_client. Get one trivial test passing before writing any others.pytest --cov=app --cov-report=term-missing. Pick the largest gap in the Missing column and write one test that closes it.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.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.
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.
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?
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?
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.
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?
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?
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?
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.