Module 00: Testing Fundamentals and the Test Pyramid
Why this matters
Every line of code you've written in the earlier tracks made a promise: this
endpoint returns 201, this validator rejects a future birth date, this
service raises InsufficientStockError. Right now the only thing verifying
those promises is you, manually, by poking the running app — and you only do
it once, the day you write the code. The moment someone else (or you, three
months later) changes a nearby line, those promises silently break and nobody
finds out until a user does. Automated tests are executable promises: they
state what the code should do, and they re-check it on every change, for free,
forever. That's the entire value proposition — not "catching bugs" in some
vague sense, but pinning down behavior so you can change code without fear.
The catch is that not all tests are equal. A test that spins up your whole
app, a real database, and a real payment sandbox to check one validation rule
is slow, flaky, and tells you almost nothing when it fails (which of the fifty
moving parts broke?). A test that calls one pure function with three inputs is
fast, stable, and points a laser at the fault. Most teams learn this the hard
way, ending up with a slow, brittle suite nobody trusts. The test pyramid
is the shape that avoids that: lots of small fast tests, fewer medium ones, a
tiny number of big ones. This module gives you the vocabulary — unit,
integration, e2e, and the four kinds of test doubles — and gets pytest
running so every later module has a foundation to build on.
Concepts
What a test actually is (arrange–act–assert)
A test is just a function that runs some code and asserts something is true about the result; if the assertion fails, the test fails. There's no magic. The universal shape is Arrange–Act–Assert (AAA): set up the inputs, perform the one action under test, then check the outcome. Keeping those three phases visually distinct is the single biggest readability win in testing.
def test_discount_applies_to_total():
cart = Cart(items=[Item(price=100), Item(price=50)]) # arrange
total = cart.total_with_discount(pct=10) # act
assert total == 135 # assert
One test should exercise one behavior and, ideally, have one logical reason to fail. A test that arranges five things, acts three times, and asserts on eight is really several tests wearing a trenchcoat — when it goes red you won't know which promise broke.
The test pyramid: unit, integration, e2e
Tests differ in scope — how much of the system they exercise at once — and scope trades off against speed and stability. The test pyramid arranges the three main scopes by how many of each you should have:
- Unit tests (the wide base) — exercise one small piece (a function, a class, a service method) in isolation, with its collaborators replaced by test doubles. Milliseconds each. You have hundreds or thousands. When one fails, it names the exact broken piece.
- Integration tests (the middle) — exercise several real pieces wired
together: a service against a real test database, a FastAPI route through
TestClient. Tens to low hundreds. Slower (they touch I/O), but they catch the bugs unit tests can't — wrong SQL, a mis-wired dependency, a serialization mismatch. - End-to-end tests (the narrow tip) — drive the whole running system from the outside, as a real client would, across process boundaries. A handful. Slow and the most prone to flakiness. They prove the assembled system works at all, but they're a terrible place to test business-rule permutations.
/\ e2e few, slow, high-confidence-in-the-whole
/ \
/----\ integration some, medium speed, real wiring/IO
/ \
/--------\ unit many, fast, isolated, precise failures
The shape matters because the wrong shape is expensive. An inverted pyramid (mostly e2e, few unit) — sometimes called the "ice-cream cone" — gives you a suite that's slow, flaky, and vague on failure. Push behavior coverage down to the fastest layer that can meaningfully test it.
Test doubles: mock, stub, fake, spy
To test a unit in isolation you replace its real collaborators (the database, a payment API, an email sender) with stand-ins called test doubles. The word "mock" gets used for all of them colloquially, but the distinctions matter because they answer different questions:
- Stub — returns canned answers to calls. Used to supply input to the
code under test. "When
get_stock('sku-1')is called, return10." You assert on the code's output, not on the stub. - Fake — a real, working, lightweight implementation. An in-memory dictionary standing in for a repository; SQLite standing in for Postgres. It actually behaves, just without the production weight.
- Mock — a double with expectations about how it's called. You assert on
the interaction: "
send_emailwas called exactly once, with this address." Used when the behavior under test is the side effect. - Spy — records the calls that happened so you can inspect them afterward, without pre-set expectations. A "mock" you interrogate after the fact.
The key division is state verification (stub/fake — you check the resulting value or state) versus interaction verification (mock/spy — you check that a call happened). Reach for state verification first; it's less brittle. Use interaction verification only when the side effect is the thing you're testing (an email must be sent) — module 04 goes deep on this.
test doubles
|
+------------+------------+
| |
state verification interaction verification
(assert on result) (assert a call happened)
prefer this first use only when needed
| |
+--+---+ +--+---+
| | | |
stub fake mock spy
canned working, pre-set records
answer in-memory expects calls
Why isolate at all — the cost of I/O and nondeterminism
Isolation isn't dogma; it buys two concrete things. Speed: a unit test that
touches no network, disk, or clock runs in under a millisecond, so a thousand
of them run in a second and you run them on every save. Determinism: real
collaborators fail for reasons unrelated to your code — the DB is down, the
payment sandbox is slow, datetime.now() is different every run. A test that
fails intermittently for reasons outside the code under test is a flaky
test, and flaky tests are worse than no test because they train the team to
ignore red. Replacing I/O and nondeterminism (time, randomness) with doubles
makes tests fast and stable. The tradeoff — doubles can drift from the real
thing they impersonate — is exactly why the pyramid also keeps a layer of
integration tests that use the real thing.
What tests are for (and what they are not for)
Tests exist to let you change code safely — that's the north star. A good suite is a regression net: it lets you refactor, upgrade a dependency, or add a feature and get immediate, specific feedback if you broke a promise. Tests are also design pressure: code that's hard to test is usually hard to use (tangled dependencies, hidden global state), so the pain of testing is a signal to fix the design, not to skip the test.
What tests are not: they're not a proof of correctness (they check the cases you thought of), not a substitute for thinking, and not a metric to game. "100% of lines executed" says nothing about whether you asserted the right things. Treat tests as a tool for confidence and change-safety, and the rest of this track — coverage, TDD, mocking, contracts, CI — falls into place as ways to make that tool sharper.
pytest: the tool you'll use for all of it
Python ships with unittest, but the ecosystem standard is pytest: plain
assert statements (no self.assertEqual), functions instead of classes,
powerful fixtures and parametrization, and a rich plugin ecosystem
(pytest-cov, pytest-asyncio, pytest-mock). A pytest test is just a
function named test_* in a file named test_*.py containing asserts.
pytest discovers and runs them, and when an assert fails it introspects
the expression to show you the actual values — no manual failure messages
needed.
# test_math.py
def test_addition():
assert 2 + 2 == 4
$ pytest -q
. [100%]
1 passed in 0.01s
That's the whole loop: write test_* functions with asserts, run pytest,
read the report. Everything else in this track is refinement.
Command reference
| Command / concept | What it does |
|---|---|
pip install pytest pytest-cov |
Install the runner and coverage plugin |
pytest |
Discover and run every test_*.py / *_test.py |
pytest -q |
Quiet output (one char per test) |
pytest -v |
Verbose: one line per test with its name |
pytest path/to/test_file.py |
Run one file |
pytest test_file.py::test_name |
Run one specific test |
pytest -k "discount and not tax" |
Run tests whose name matches an expression |
pytest -x |
Stop at the first failure |
pytest --lf |
Re-run only last-failed tests |
pytest -s |
Don't capture stdout (let print through) |
assert expr |
The assertion; pytest rewrites it to show values |
pytest.raises(Error) |
Assert a block raises a given exception |
conftest.py |
Shared fixtures/config, auto-discovered per directory |
Standard project layout pytest expects:
myapp/
app/
__init__.py
services.py
tests/
__init__.py
conftest.py # shared fixtures
test_services.py # test_* files
pyproject.toml # [tool.pytest.ini_options] lives here
A minimal pytest config in pyproject.toml so discovery and imports behave:
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-q"
Asserting an exception is raised (you'll use this constantly for domain errors):
import pytest
from app.services import OrderService, InsufficientStockError
def test_place_order_rejects_oversell():
svc = OrderService(FakeRepo(stock={"sku-1": 1})) # arrange (fake double)
with pytest.raises(InsufficientStockError) as exc: # act + assert
svc.place_order(user_id=1, items=[Item("sku-1", qty=5)])
assert exc.value.sku == "sku-1" # assert on the error
A stub vs. a fake, concretely:
# STUB: canned answer, used to feed the code under test
class StubStockRepo:
def get_stock(self, sku): return 10 # always says 10
# FAKE: a real, working, in-memory implementation
class FakeOrderRepo:
def __init__(self): self._orders, self._next = {}, 1
def create_order(self, user_id, items):
oid = self._next; self._next += 1
self._orders[oid] = {"id": oid, "user_id": user_id}
return self._orders[oid]
def get(self, oid): return self._orders.get(oid)
Hands-on exercises
Create a fresh project folder testing-lab/ with the layout above. You'll grow
it across this whole track. Install pytest and pytest-cov into a virtualenv.
1. Get pytest running
Write tests/test_smoke.py with a single test_addition asserting 2 + 2 == 4. Run pytest -q. Expected: 1 passed. Now change the assertion to 2 + 2 == 5 and re-run. Read the failure output carefully — note how pytest shows
you the actual left/right values without you writing a message. Change it back.
2. Write a pure function and unit-test it (AAA)
Add app/pricing.py with def total_with_discount(prices: list[int], pct: int) -> int that sums prices and applies a percentage discount, rounding down.
Write tests/test_pricing.py with a test that clearly shows the three AAA
phases (blank line between arrange, act, assert). Add a second test for the
pct=0 case. Expected: both pass.
3. Assert an exception with pytest.raises
Extend total_with_discount to raise ValueError if pct is outside 0–100.
Write a test using with pytest.raises(ValueError): for pct=150. Then make
it stricter: capture the exception and assert its message mentions the bad
value. Expected: the test passes and pins the error contract.
4. Build a stub and a fake
In app/services.py, write a tiny OrderService whose place_order checks
stock via an injected repo and raises InsufficientStockError on oversell.
Write one test using the StubStockRepo (canned stock) to prove the oversell
path, and one using a FakeOrderRepo to prove a successful order gets an id.
Note in a comment which double you used and why.
5. Classify the scope
Look at the two tests you just wrote and the ones from exercises 2–3. For each, write a one-line comment labeling it unit / integration / e2e and justify it in a few words. Expected: all of them are unit tests — you replaced or avoided every real collaborator. Notice you have zero of the other two kinds so far; that's normal this early.
6. Feel the flakiness
Write a function def greet_by_hour() -> str that returns "good morning"
before noon and "good afternoon" otherwise, reading datetime.now()
directly. Write a test asserting it returns "good morning". Run it. Now
reason: this test passes or fails depending on what time you run it. That's a
nondeterministic (flaky) test. Don't fix it yet — just write down why it's
flaky and what collaborator you'd need to control. (Module 04 controls the
clock; module 01 refactors for injectability.)
7. Run subsets like you will in real life
With five-plus tests now, practice the runner: pytest -v (see names),
pytest -k pricing (only pricing tests), pytest tests/test_pricing.py:: test_total_with_discount, and pytest -x after deliberately breaking one
test. Expected: you can slice the suite three different ways from memory.
8. Diagnose and fix: a test that tests nothing
This test is green, but it's worthless. Explain both problems and fix them.
def test_place_order():
svc = OrderService(FakeOrderRepo(stock={"sku-1": 10}))
order = svc.place_order(user_id=1, items=[Item("sku-1", qty=2)])
# (no assertions below)
svc.place_order(user_id=1, items=[Item("sku-1", qty=2)])
order2 = svc.place_order(user_id=2, items=[Item("sku-1", qty=1)])
Solution
Two problems. (1) No assertion. The test executes code but never checks
anything, so it can only fail if the code raises — it verifies almost
nothing. A test with no assert is a false sense of security. (2) It tests
three behaviors at once (two different users, a repeated order) with no clear
single reason to fail, and still asserts on none of them.
Fix: split into focused tests, each asserting one promise.
def test_place_order_returns_created_order_with_id():
svc = OrderService(FakeOrderRepo(stock={"sku-1": 10}))
order = svc.place_order(user_id=1, items=[Item("sku-1", qty=2)])
assert order["id"] == 1
assert order["user_id"] == 1
def test_place_order_rejects_oversell():
svc = OrderService(FakeOrderRepo(stock={"sku-1": 1}))
with pytest.raises(InsufficientStockError):
svc.place_order(user_id=1, items=[Item("sku-1", qty=5)])
The lesson: green is not the goal — green with meaningful assertions is. A test that can't fail for the right reason isn't protecting anything.
Independent challenge
No code given. Take the OrderService layering you built in
02-api-layer-and-request-handling / module 06 (Handlers, Controllers, and
Services) — the HTTP-ignorant service that raises domain exceptions — and
give it a real unit-test suite from scratch. Write a FakeOrderRepository (an
in-memory fake, not a mock) and use it to test every branch of
place_order: the empty-order rejection, the oversell rejection (asserting the
error carries the right sku), and the happy path (asserting the returned
order's fields). Every test must follow visible Arrange–Act–Assert, exercise
exactly one behavior, and touch no web server, no real database, and no clock.
Then classify each test's scope in a comment and confirm you've built a small
pyramid base with nothing above it yet — that's the correct starting shape.
Hint
The reason you can unit-test the service at all is the discipline from module
06: it imports no FastAPI and takes plain arguments, so you construct it with
your fake repo and call methods directly. For the oversell test, seed the fake
with stock={"sku-x": 0} so the branch is forced; for the happy path, seed
enough stock and assert on the returned dict's id/user_id. Use
pytest.raises(InsufficientStockError) as exc and then assert exc.value.sku == ... to pin the error's payload, not just its type.
Common mistakes & troubleshooting
- Tests with no assertions. Code runs, nothing is checked; it only catches
crashes. Every test needs at least one meaningful
assert. - One test, many behaviors. Multiple acts and asserts in one function; when it fails you can't tell which promise broke. One behavior per test.
- Inverted pyramid. Leaning on slow e2e tests for logic that a unit test could pin. Push behavior coverage down to the fastest layer that can test it.
- Confusing the doubles. Asserting on a stub's calls (that's a mock's job) or building an elaborate mock when a simple fake would do. Prefer state verification (stub/fake); reserve interaction verification (mock/spy) for when the side effect is the behavior.
- Testing the clock / randomness directly.
datetime.now()andrandom.random()inside code under test make flaky tests. Inject them so a test can control them (module 04). ModuleNotFoundErrorin tests. Usually a layout/__init__.pyortestpaths/pythonpathissue. Add__init__.pyfiles and configure[tool.pytest.ini_options]; runpytestfrom the project root.- Chasing green instead of confidence. A passing suite that asserts trivia protects nothing. Ask of each test: "what real bug would this catch?"
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What are the three phases of a well-structured test, and why keep them visually distinct?
- Name the three layers of the test pyramid from base to tip, and for each give its scope, relative speed, and roughly how many you should have.
- Why is an inverted pyramid (mostly e2e) a bad place to be? Give two concrete symptoms.
- Distinguish a stub from a mock. Which verifies state and which verifies interaction, and which should you reach for first?
- What is a fake, and when would you choose one over a stub?
- What two concrete things does isolating a unit from its real collaborators buy you, and what's the tradeoff you accept?
- A test passes reliably in the morning and fails every afternoon. What kind of test is this, what's the likely cause, and why is such a test worse than having no test at all?
Answers
- Arrange (set up inputs/collaborators), Act (perform the one action under test), Assert (check the outcome). Keeping them distinct makes the test readable and makes each test exercise one behavior with one reason to fail.
- Unit (base): one small piece in isolation, fastest (ms), hundreds/thousands. Integration (middle): several real pieces wired together incl. real test DB/ TestClient, medium speed, tens–low hundreds. E2e (tip): the whole running system from outside, slowest, a handful.
- Because e2e tests are slow and the most flaky, and they give vague failures (any of many parts could be the cause). Symptoms: a suite so slow nobody runs it locally, and red builds that don't tell you what actually broke.
- A stub returns canned values to feed the code under test — you verify the code's resulting state/output. A mock carries expectations about how it's called — you verify the interaction (a call happened as expected). Reach for stub/fake (state) first; it's less brittle.
- A fake is a real, lightweight, working implementation (in-memory repo, SQLite for Postgres). Choose it over a stub when you need the collaborator to actually behave across multiple calls (store then retrieve), not just return one canned value.
- Speed (no I/O → sub-millisecond tests you run constantly) and determinism (no external systems, clock, or randomness to fail for unrelated reasons). The tradeoff: doubles can drift from the real thing, which is why you keep a layer of integration tests that use the real collaborators.
- A flaky (nondeterministic) test; the likely cause is reading the real clock
(
datetime.now()) inside the code. It's worse than no test because intermittent red trains the team to ignore failures, eroding trust in the whole suite.
Further reading & sources
- Martin Fowler — The Practical Test Pyramid - Ham Vocke's canonical write-up of the unit/integration/e2e shape this module is built around.
- Martin Fowler — TestPyramid - The short bliki entry that names the pyramid and warns against the inverted "ice-cream cone".
- Martin Fowler — Mocks Aren't Stubs - The reference on the four test doubles and the state-vs-interaction verification split covered here.
- pytest — Get Started - Official docs for the runner you install and use for every test in this track.
- pytest — How to write and report assertions - How pytest's plain-
assertintrospection andpytest.raises(used in the exercises) work. - xUnit Patterns — Four-Phase Test - Gerard Meszaros on the Arrange–Act–Assert structure every test in this module follows.
Next
01-unit-testing-in-depth — you can run
a test and name the layers; now you'll get fluent at the unit layer itself:
pytest fixtures for setup/teardown, parametrization to cover many cases
without duplication, testing pure functions versus functions with side effects,
and reading coverage as a signal rather than chasing it as a target.