Search Tech Journey

Find topics, journeys and posts

6-month learning plan13 / 130
back to blog
pythonbeginner 15m read

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)


(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:

FeatureWhat it solvesSyntax
assertplain comparisons with rich diffsassert actual == expected
pytest.raisesassert a specific exception fireswith pytest.raises(ValueError, match="neg"):
Fixturesshared, reusable setup (DB, tmp files, clients)@pytest.fixture + take the fixture as an arg
Parametrizerun 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) == expected

pytest 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-cov

Write 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 -= amount

Run:

uv run pytest -v --cov=tdd_demo --cov-report=term-missing

Observe when you run:

  1. All 7 tests (5 defined + 3 from parametrize) pass with green dots.
  2. Coverage report shows 100% for wallet.py — every branch hit.
  3. --cov-report=term-missing would list uncovered line numbers if you had any.
  4. Change >= to > in overdraft — one test fails with a clear diff.
  5. Rename test_deposit_increases_balance to deposit_increases_balance (no test_ 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-only after refactors to see which tests were silently un-discovered (renamed files, missing __init__.py).
  • Use conftest.py for shared fixtures — pytest auto-loads it up the directory tree. Don't create your own import machinery.
  • Coverage ≠ correctness. 100% coverage with assert True proves nothing. Coverage tells you what's not tested; it can't tell you your assertions are meaningful.

(e) Quiz + exercise · 10 min

  1. What are the three steps of the TDD cycle, in order?
  2. What are two rules pytest uses to auto-discover tests?
  3. What problem do fixtures solve that plain setup functions don't?
  4. When would you use @pytest.mark.parametrize instead of a for-loop inside one test?
  5. 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:

  1. What is Testing with pytest — TDD Workflow? (one sentence, no jargon)
  2. When would you use it? (one concrete example)
  3. When would you NOT use it? (one anti-pattern)

What comes next


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 →
  1. 005Python Variables & Types — Mental Model of Memory
  2. 006Control Flow — if/else, loops, comprehensions
  3. 007Functions — arguments, scope, closures
  4. 008Data Structures — list, tuple, dict, set (when to use what)
  5. 009Classes & Objects — the OOP Mental Model
  6. 010Inheritance, Composition & Polymorphism