S013 · Testing with pytest — TDD Workflow
Tests turn code from hope into evidence.
Module M01: Python Foundations · Session 13 of 130 · Track: SW ✅
What you'll be able to do after this session
- Explain Testing with pytest — TDD Workflow to a friend in one minute, out loud, no notes.
- Recognise it when you see it in real code / systems / papers.
- Complete the hands-on exercise at the end without looking up help.
Prerequisites
If you can't answer these in 30 seconds each, redo the linked session first:
Pre-read (skim before the session)
- 🎥 Intuition (5–15 min) · pytest in 5 Minutes — ArjanCodes — why testing pays back the time you invest.
- 🎥 Deep dive (30–60 min) · Corey Schafer — Unit Testing Your Code — the canonical long tutorial (uses unittest but pytest concepts map 1:1).
- 🎥 Hands-on demo (10–20 min) · ArjanCodes — pytest Tutorial: Fixtures & Parametrize — live fixtures, mocks, parametrize.
- 📖 Canonical article · pytest — Getting Started — the official quickstart.
- 📖 Book chapter / tutorial · Real Python — Effective Python Testing With pytest — the deep beginner guide.
- 📖 Engineering blog · Stripe — Sorbet & typed testing at scale — how a payments company thinks about correctness (translates cleanly to Python + pytest + mypy).
(a) Intuition · 5 min
The one-paragraph mental model. Why this concept exists, what problem it solves, and the analogy that makes it stick.
In one sentence: Tests turn code from hope into evidence.
Imagine you build a bridge and every time you drive over it you cross your fingers hoping it holds. That's code without tests. A test is a small truck you send across the bridge on every change — if it makes it, you ship; if it doesn't, you fix. TDD (test-driven development) flips the order: you build the truck first, watch it fall (red), then build just enough bridge to hold it (green), then make the bridge prettier (refactor). Red → green → refactor.
pytest is the framework that makes running these trucks trivial. Any function in any file named test_*.py starting with test_ is auto-discovered. You use plain assert — no self.assertEqual boilerplate. When an assert fails, pytest shows you both sides with syntax highlighting, and tells you the exact line.
The forever-gotcha: a test that never fails tests nothing. Before you trust a green test, break the code it's testing on purpose — the test should go red. If it stays green, your test is a placebo.
(b) Visual walkthrough · 15 min
Diagrams, worked examples, step-by-step visuals.
The TDD loop, drawn:
Each cycle should be minutes, not hours. If a red-green cycle takes a day, your test is too big — split it.
Anatomy of a pytest file:
# tests/test_wallet.py
import pytest
from mypkg.wallet import Wallet
def test_new_wallet_is_empty(): # 1. plain function, name starts with test_
w = Wallet()
assert w.balance == 0 # 2. plain assert; pytest introspects both sides
def test_deposit_increases_balance():
w = Wallet()
w.deposit(100)
assert w.balance == 100
def test_overdraft_raises():
w = Wallet()
with pytest.raises(ValueError): # 3. asserting an exception
w.withdraw(1)Run pytest -v and you'll see three green ticks. Break Wallet.deposit to add +1 and rerun — pytest prints:
> assert w.balance == 100
E assert 101 == 100
E + where 101 = <Wallet balance=101>.balance
The four pytest superpowers to learn in order:
| Feature | What it solves | Syntax |
|---|---|---|
assert | plain comparisons with rich diffs | assert actual == expected |
pytest.raises | assert a specific exception fires | with pytest.raises(ValueError, match="neg"): |
| Fixtures | shared, reusable setup (DB, tmp files, clients) | @pytest.fixture + take the fixture as an arg |
| Parametrize | run one test against many inputs | @pytest.mark.parametrize("x,y", [(1,2),(3,4)]) |
Fixture example — a tmp_path fixture is built into pytest and gives each test its own temp directory that is auto-cleaned:
def test_writes_report(tmp_path):
out = tmp_path / "report.csv"
write_report(out, rows=[{"a": 1}])
assert out.read_text().startswith("a\n1")Parametrize example — one test, ten input pairs:
@pytest.mark.parametrize("n,expected", [(0,1),(1,1),(2,2),(3,6),(5,120)])
def test_factorial(n, expected):
assert factorial(n) == expectedpytest reports each pair separately, so failures point at the exact input.
(c) Hands-on · 20 min
Code you type and run. Not pseudo-code — real, runnable, copy-pasteable.
mkdir tdd-demo && cd tdd-demo && uv init --package
uv add --dev pytest pytest-covWrite the test first, watch it fail:
# tests/test_wallet.py
import pytest
from tdd_demo.wallet import Wallet
@pytest.fixture
def wallet():
return Wallet(owner="alice")
def test_new_wallet_is_empty(wallet):
assert wallet.balance == 0
assert wallet.owner == "alice"
def test_deposit_increases_balance(wallet):
wallet.deposit(100)
wallet.deposit(50)
assert wallet.balance == 150
def test_withdraw_decreases_balance(wallet):
wallet.deposit(100)
wallet.withdraw(30)
assert wallet.balance == 70
def test_overdraft_raises(wallet):
with pytest.raises(ValueError, match="insufficient"):
wallet.withdraw(1)
@pytest.mark.parametrize("amt", [-1, 0, -100])
def test_deposit_rejects_non_positive(wallet, amt):
with pytest.raises(ValueError):
wallet.deposit(amt)Now the implementation — write just enough to pass:
# src/tdd_demo/wallet.py
from dataclasses import dataclass, field
@dataclass
class Wallet:
owner: str
balance: int = 0
def deposit(self, amount: int) -> None:
if amount <= 0:
raise ValueError(f"deposit must be positive, got {amount}")
self.balance += amount
def withdraw(self, amount: int) -> None:
if amount > self.balance:
raise ValueError(f"insufficient funds: {self.balance} < {amount}")
self.balance -= amountRun:
uv run pytest -v --cov=tdd_demo --cov-report=term-missingObserve when you run:
- All 7 tests (5 defined + 3 from parametrize) pass with green dots.
- Coverage report shows 100% for wallet.py — every branch hit.
--cov-report=term-missingwould list uncovered line numbers if you had any.- Change
>=to>inoverdraft— one test fails with a clear diff. - Rename
test_deposit_increases_balancetodeposit_increases_balance(notest_prefix) — pytest silently skips it. That's why the prefix rule matters.
Try this modification: add a transfer(other_wallet, amount) method and TDD it: write the failing test first, then the implementation. Include a parametrized test that covers self-transfer (should raise) and zero amount (should raise).
(d) Production reality · 10 min
How this shows up in real systems, gotchas, war stories, common bugs.
Big teams don't "have tests" — they have a test pyramid: many fast unit tests (milliseconds), fewer integration tests (seconds, hit a real DB in Docker), very few end-to-end tests (minutes, spin up the full app). Google's internal number is roughly 70/20/10. When engineers invert the pyramid — mostly E2E — CI takes 45 minutes, flakes constantly, and nobody adds new tests.
Flaky tests are worse than no tests. A test that fails 1 time in 20 trains everyone to hit "rerun" without reading the failure — so real bugs get reran until green. The two big causes: (1) shared mutable state between tests (global variables, module-level singletons), (2) time / randomness / network. Fixes: @pytest.fixture with scope="function" (fresh instance per test), freezegun for time, respx / httpx.MockTransport for HTTP, seeded random.
At Microsoft on the OneNote Copilot pipeline, we use pytest with pytest-xdist (-n auto) to run tests in parallel across CPU cores — cuts a 4-minute suite to 40 seconds. We also run pytest --lf (last-failed) locally to iterate on the failing test only.
The LLM-as-a-Judge eval framework I built at Microsoft is built on pytest — each eval is a test_* function that calls the model, scores the response with a judge model, and asserts score >= threshold. pytest --json-report feeds a Grafana dashboard; regressions block deploys.
Netflix's Chaos Monkey is essentially a production integration test — it kills random services and asserts the system stays healthy. Same mental model as pytest: assert an invariant, fail loudly.
Gotchas:
- Don't import from
tests/in production code — accidental prod dependency on test fixtures has killed deploys. - Do run
pytest --collect-onlyafter refactors to see which tests were silently un-discovered (renamed files, missing__init__.py). - Use
conftest.pyfor shared fixtures — pytest auto-loads it up the directory tree. Don't create your own import machinery. - Coverage ≠ correctness. 100% coverage with
assert Trueproves nothing. Coverage tells you what's not tested; it can't tell you your assertions are meaningful.
(e) Quiz + exercise · 10 min
- What are the three steps of the TDD cycle, in order?
- What are two rules pytest uses to auto-discover tests?
- What problem do fixtures solve that plain setup functions don't?
- When would you use
@pytest.mark.parametrizeinstead of a for-loop inside one test? - Why is a flaky test worse than a missing test?
Stretch (connects to S011 Errors & Debugging): add a fixture caplog (built into pytest) to the Wallet tests to assert that a warning is logged (not raised) when deposit(amount) is called with amount > 1_000_000. Use caplog.records[0].levelname == "WARNING" and inspect caplog.records[0].message.
Explain-out-loud test
If you can't teach these 3 things to a friend without notes, redo the session:
- What is Testing with pytest — TDD Workflow? (one sentence, no jargon)
- When would you use it? (one concrete example)
- When would you NOT use it? (one anti-pattern)
What comes next
- Next session (S014): Type Hints, mypy, dataclasses & pydantic
- Previous (S012): Modules, Packages, Virtualenvs, pip & uv
- Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: (a) intuition · (b) visual walkthrough · (c) hands-on · (d) production reality · (e) quiz + exercise. Duration: 60 minutes.
More from M01 · Python Foundations
all modules →