S013 · Testing with pytest — TDD Workflow
Turn code from hope into evidence: real pytest patterns — parametrize, fixtures, mocks, coverage — plus the TDD loop that makes refactoring safe.
🎯 Write a real test suite with pytest — parametrize, fixtures, mocks, and coverage — and use the red → green → refactor loop to add a feature without fear.
Why this session exists
Every "senior Python engineer" you'll meet writes tests before or alongside code. Not because a manual said to — because they got burned. Untested code is a liability that compounds daily: a change breaks something unrelated, a coworker refactors and doesn't know a corner case existed, you deploy Friday and page yourself at midnight. Tests are the seatbelt. This session teaches you the seatbelt-clicking motion until it's automatic.
- Explain the difference between a unit test, an integration test, and an end-to-end test with a concrete example.
- Write pytest tests with `assert`, `parametrize`, and `fixture` — and know when to reach for each.
- Mock an external dependency (HTTP, filesystem, clock) with `pytest-mock` / `monkeypatch`.
- Run tests with coverage, read the report, and identify branches you forgot.
- Do a full red → green → refactor cycle on a small feature without leaving the terminal.
Prerequisites
- S012 · Modules, Packages, Virtualenvs — you need a src/ layout package to test against.
- Basic Python: functions, classes, exceptions.
(a) Intuition · 5 min
Imagine an airport with no metal detector. Every passenger clears TSA by promising they didn't pack a knife. That's untested code — a promise, not evidence. You'd never board that plane.
Now imagine the detector beeps only on knives and only when there's really a knife. Fast, cheap, boring. That's what a good test suite feels like: it beeps only when you actually broke something, and it takes 3 seconds.
A test is an assertion: "given this input, my code should produce this output." assert add(2, 3) == 5. If it stops being true, pytest beeps. If nothing beeps, you shipped safely.
The magic isn't the framework — it's that you now have an executable specification. Refactor freely; the tests catch regressions in milliseconds instead of Fridays.
The three levels of tests — the pyramid
- Unit tests (70%) — one function, no I/O, milliseconds. Cheap to write, cheap to run, fail with a laser-precise message.
- Integration tests (25%) — one component talking to a real dependency (DB, HTTP). Slower but proves the wiring works.
- End-to-end tests (5%) — the whole system, user-shaped inputs. Expensive, flaky, invaluable for critical happy paths.
The red → green → refactor loop
- 1RED · write a failing testDescribe the behaviour you want. Run pytest. Watch it fail with a specific reason. This proves the test actually tests something.
- 2GREEN · make it pass, ugly is fineWrite the minimum code that turns the red into green. Do NOT beautify. Do NOT generalise. Just green.
- 3REFACTOR · clean up with confidenceNow that tests protect you, rename, dedupe, extract functions. Re-run after every small change. Green the whole way.
- 4REPEAT · one behaviour at a timeEach loop is 2–5 minutes. Small steps, always green at the end. This is TDD in 10 words.
A quick history
- 1999JUnit · Kent BeckFirst mainstream unit-test framework. Kicks off the xUnit family across languages.
- 2003PyUnit (unittest) enters stdlibJava-flavoured, verbose. Still in Python today, still used in some legacy code.
- 2004pytest 0.xHolger Krekel ships a saner alternative — plain `assert`, no boilerplate class. Slowly wins.
- 2015pytest becomes the defaultDjango, Flask, Sentry, requests — all move to pytest. `unittest` is now a compatibility layer.
- 2020+Hypothesis + coverage.py mainstreamProperty-based tests and branch-coverage go from niche to standard on any respectable Python project.
(b) Visual walkthrough · 15 min
How pytest collects and runs your tests
The lifecycle of a fixture
A plain function that `yield`s or returns a value. Name = the fixture's name.
Add a parameter with the fixture's name; pytest injects the value.
scope='function' (default) rebuilds per test; scope='module' once per file; scope='session' once per pytest run.
Anything after `yield` runs when the test finishes — perfect for closing files, dropping tables, resetting env vars.
The tools that live around pytest
The modern Python testing stack
unittest vs pytest — same problem, different taste
Modern, minimal
- Plain `assert x == y`
- Rich failure messages via introspection
- Function fixtures, not setUp/tearDown
- Huge plugin ecosystem
- Use this by default
Java-flavoured, verbose
- `self.assertEqual(x, y)`
- Class-based, setUp/tearDown methods
- In stdlib — no install
- Fine for tiny scripts
- Legacy code you inherit
The mental model
"High coverage means well tested. If we're at 90% line coverage, the code is verified — and a test that runs the code without crashing proves it works."
Coverage measures which lines executed, not which behaviours were checked. A test suite with no assertions at all can reach 100% coverage. Coverage is a reliable negative signal — uncovered lines are definitely untested — and a nearly worthless positive one.
Because the correlation is real at the low end. Going from 20% to 60% genuinely does catch bugs, because you are writing your first tests for whole modules that had none. The signal saturates and then inverts: chasing the last 15% pushes people toward tests that call code to touch lines rather than to verify contracts, and toward mocking everything in sight — at which point the test asserts that your mocks were configured the way you configured them. That kind of suite is worse than no suite, because it fails on every refactor (coupled to implementation) while passing on real regressions (no behavioural assertion). The metric is popular precisely because it is the only one that's cheap to compute, not because it is the one that matters.
100% coverage, zero verification:
# code under test
def discount(price, pct):
return price - price * pct / 100
# 'covers' every line, asserts nothing about correctness
def test_discount():
discount(100, 10)
# what actually pins the contract - including the edges:
def test_discount_real():
assert discount(100, 10) == 90
assert discount(100, 0) == 100
assert discount(0, 50) == 0Why do pytest fixtures use dependency injection — a function that declares a parameter name and receives an object — instead of the obvious setUp() method? Derive why the indirection earns its keep.
- 1A test needs preconditions (a temp dir, a database, a client) and those preconditions must be torn down whether the test passes or fails.forced by · leaked state makes the next test's result depend on this test's outcome
- 2Different tests in the same file need different subsets of preconditions, and building all of them for every test is wasteful — sometimes prohibitively so, if one is a database.forced by · setup cost is paid per test, and test suites are run constantly
- 3A single
setUp()method runs unconditionally for every test in the class, so it must be the union of all needs. The only way to vary it is to split the class — which forces your file structure to mirror your fixture combinations.forced by · inheritance is the only composition mechanism available, and a class has one setUp - 4If instead each precondition is a named, independently-defined provider, and a test requests the ones it needs by name, then each test builds exactly its own subset.forced by · naming the dependency is the minimal way to express "I need this one, not the others"
- 5And because providers can themselves request other providers, the framework can resolve a dependency graph, deduplicate shared nodes within a scope, and tear down in reverse order automatically.forced by · a graph with declared edges can be topologically ordered; a pile of setUp calls cannot
Therefore fixtures are dependency injection because DI is what lets setup compose without inheritance — and composition is exactly what setUp lacks.
And note what this predicts: scope must exist (function, module, session) so an expensive node in the graph can be built once and shared — with the corresponding hazard that a mutable session-scoped fixture reintroduces inter-test coupling. It also predicts yield fixtures: the code after yield is teardown, running in reverse dependency order, which is the only correct order to dismantle a graph.
A test is three blocks: put the world in a known state, perform exactly one action, assert on the observable outcome. If you cannot point at the single Act line, the test is testing more than one thing and its failure will not tell you which.
The deeper rule is what you assert on: the contract (given this input, this output; given this bad input, this error) rather than the mechanism (these internal methods were called in this order). Contract tests survive refactors and catch regressions. Mechanism tests do the reverse.
- A failing test must name its own cause. If you have to open a debugger to find out what broke, the test was too broad or asserted too little.
- Tests must be order-independent and runnable in parallel. Shared mutable state — a session fixture, a real database, a module-level cache — is the usual culprit when they aren't.
- Mock at the boundary you don't own (network, clock, filesystem, cloud SDK). Mocking your own internals couples the test to the code and guarantees false failures on refactor.
- Test the edges, not the middle: empty, one, many, duplicate, null, wrong type, boundary values. Bugs live at boundaries because that's where the branches are.
Fire this model the moment you see: a test named test_it_works · assertions on mock call counts and nothing else · a test that fails only in CI or only when run with others · a refactor that broke 40 tests but no behaviour · a bug report for code that had "full coverage".
Your data pipeline needs testing. Unit tests with fake data, integration tests against a real store, or production data-quality assertions?
These are not alternatives; they cover disjoint failure classes. Unit tests catch your bugs, integration tests catch interface bugs, quality checks catch the world's bugs — and only the third class is growing as your dependency surface grows.
The allocation that works: heavy unit coverage on transforms, a thin integration suite over the store interactions you actually depend on, and unconditional quality gates (row counts, null rates, key uniqueness, freshness) on every input and output. The last one is the highest-value-per-line testing in data engineering and the one most often skipped, because it doesn't look like testing.
(c) Hands-on · 25 min
You're going to test a tiny in-memory URL shortener — enough surface for real patterns (parametrize, fixtures, mocking a clock, exceptions). Copy this into tdd_demo.sh and run it.
What each block does
Anatomy of the test suite
Add a new behaviour: shortener.stats() returns {"total": int, "active": int, "expired": int}.
Loop:
# 1. RED — write the test first
cat >> tests/test_shortener.py <<'PY'
def test_stats_counts_active_and_expired(shortener, fake_clock):
shortener.shorten("https://a.com") # forever
c = shortener.shorten("https://b.com", ttl_seconds=10) # expires
fake_clock.advance(11)
stats = shortener.stats()
assert stats == {"total": 2, "active": 1, "expired": 1}
PY
pytest # RED: AttributeError: 'Shortener' object has no attribute 'stats'
# 2. GREEN — implement the minimum. Add to Shortener class in src/tinyurl/store.py:
# def stats(self) -> dict[str, int]:
# now = self.clock()
# expired = sum(1 for e in self._links.values()
# if e.expires_at is not None and now >= e.expires_at)
# return {"total": len(self._links), "active": len(self._links) - expired, "expired": expired}
pytest # GREEN
# 3. REFACTOR — extract a helper `_is_expired(entry)` used by both resolve() and stats().
# Re-run tests after each edit. Stay green.(d) Production reality · 15 min
GitHub's test suite grew to tens of thousands of tests over a decade. CI runs were creeping past 45 minutes because tests were serial. Worse, a handful of "flaky" tests failed 1 in 200 runs and no one owned fixing them.
Developers started ignoring red builds ("it's just flakes") — a cultural death spiral for testing.
requests.get("https://api.stripe.com/…"). It passes locally, then CI fails because the API is rate-limiting, or the internet is flaky, or Stripe changed their staging keys. Everyone reruns the pipeline and it "works" — until it doesn't and blocks a launch.Mock the HTTP layer. Two common tools:
# responses — for the requests library
import responses
@responses.activate
def test_fetch():
responses.add(responses.GET, "https://api.stripe.com/v1/charges",
json={"data": []}, status=200)
...
# respx — for httpx / async
# vcrpy — records real responses once, replays foreverRule: unit tests never touch the network. Integration tests do, but they live in a separate folder and CI job you can turn off when the network is on fire.
assert obj.created_at == datetime.utcnow(). It passed until a slow CI machine ran it and there was a 1 ms gap. Then failed. Then passed. Then failed on a Friday.FakeClock) OR use freezegun / time-machine to freeze datetime.now. Either way: your code never asks the wall clock in a way tests cannot observe.Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- What is the test pyramid, and why not just write only E2E tests? (name the trade-off in one sentence)
- What is a fixture, and how is it different from a helper function? (name two things fixtures do)
- You inherited a codebase with no tests and 100k lines. What is the first test you write? (defend the choice)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.