AI EngineeringZero to ProductionHome·About·What’s new·Contact
Software Testing · Chapter TQ3

Fixtures, parametrize & mocking

The tools that make tests fast and reliable: fixtures, parametrize, mocking the outside world, and designing for testability — all runnable offline.

⏱️ ~2.5 hours🧪 6 labs🎯 Intermediate→Tech-lead

Learning objectives

  • Remove duplication with fixtures.
  • Cover many inputs with parametrize.
  • Mock external dependencies (API, time, files).
  • Design fakes/injection so a codebase stays testable.
▶ Runnable companionThe code here is also saved under code/tq3-fixtures-mocking/. Python blocks run offline; config files are ready to drop into a project.

1 · Fixtures essential

Here's a pain you've felt if you've written more than a handful of tests. Every test needs a logged-in user, or a populated cart, or a database with three rows in it — so you paste the same eight setup lines into the top of test after test. Then the setup changes (the User constructor gains a required argument) and you're hunting through forty tests fixing the same copy-pasted block forty times. One of them you miss, and now a test is silently building the object the old way. Fixtures exist to kill exactly that.

Think of a fixture as the stagehand for your tests. Before each scene (test), the stagehand walks on, sets up the props — the cart with items in it, the open database connection, the temp folder — hands them to the actor, and after the scene clears them away. The actor (your test) never has to build the set; it just performs against props it was handed. Crucially, the stagehand builds a fresh set for every scene, so nothing one test does can leak into the next.

Precisely: a fixture is a function that produces the setup a test depends on, and hands a new instance to each test that asks for it. In pytest you write @pytest.fixture above the function; a test then requests it simply by naming it as a parameter, and pytest calls the fixture and injects the result — that name-matching is the whole magic, no imports or wiring. The word to hold onto is isolation: because each test receives its own freshly-built object, tests can run in any order, in parallel, or one-at-a-time and still give the same answer. A shared, module-level object would quietly couple your tests together — test A mutates it, test B sees the mutation, and now a failure in B is really a bug in A. That coupling is the single most common cause of "it passes alone but fails in the suite," and fixtures are the cure.

Two mistakes to name before you make them. First, a fixture is not a global variable — if you build one object once and share it, you've thrown away the isolation that was the entire point. Second, don't over-fixture: setup that's used by exactly one test is often clearer written inline; reach for a fixture when the setup is shared or non-trivial. The lab below deliberately shows the fixture as a plain factory function first (so you see there's no framework magic — it's just "a function that returns a fresh object"), with the real @pytest.fixture form noted at the bottom.

one fixture, called again per test — each test gets its OWN fresh object @pytest.fixture sample_cart() builds a Cart test A → fresh Cart #1 mutates it freely test B → fresh Cart #2 can't see A's changes test C → fresh Cart #3 clean slate again isolation: no shared state between tests A fixture hands each test its own fresh object. The same fixture function runs once per test, so test A, B and C each get a brand-new Cart — nothing one test does can leak into another. That per-test freshness is isolation.
🗺️ How to read this diagram

Read left to right: the single blue box on the left is the fixture, and the three green boxes on the right are three separate tests that each requested it.

  • The blue box is one fixture function (sample_cart()). It is written once — there is only one of it.
  • The three blue arrows fanning out to the right are the fixture being called again for each test. Each arrow delivers a separate green box — a distinct, freshly-built Cart.
  • The payoff is the labels on the green boxes: test A can mutate its cart freely, yet test B and C never see those changes because they hold different objects. That is why the caption below says no shared state.

In short: one fixture, but a new object per test — which is exactly what lets tests run in any order and still pass.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · the fixture idea (runs)
fixtures.pyclass Cart:
    def __init__(self): self.items=[]
    def add(self,x,q=1): self.items.append((x,q))
    def total(self): return sum(q for _,q in self.items)

def sample_cart():                    # a fixture = fresh object per test
    c = Cart(); c.add("book"); c.add("pen", 2); return c

assert sample_cart().total() == 3     # each test gets its own -> isolated
assert len(sample_cart().items) == 2
print("fixture-backed tests pass")
# pytest form:  @pytest.fixture def sample_cart(): ...   then def test(sample_cart): ...
fixture-backed tests pass
▶ How this works

A fixture is reusable setup: instead of copy-pasting the same "build a cart, add some items" code into every test, you write it once and let each test ask for a fresh copy. Here Cart is the thing being tested and sample_cart() is the fixture that hands you a ready-made cart.

  1. class Cart is a tiny shopping cart: add stores an item and quantity, and total adds up all the quantities.
  2. sample_cart() builds a brand-new cart with a book (quantity 1) and 2 pens, then returns it. Because it makes a new cart every time it's called, one test can never mess up another test's data — that's isolation.
  3. The two assert lines are the tests: they each call sample_cart() to get their own cart, then check the totals. assert means "this must be true, or stop and complain".
  4. The comment at the bottom shows the real pytest version: you decorate the function with @pytest.fixture, then a test just names sample_cart as an argument and pytest injects it automatically.

What the output means: fixture-backed tests pass prints only if both asserts held. If a total were wrong, Python would raise an AssertionError and you'd see a traceback instead of that line.

Try this: Add a line c.add("cup", 5) inside sample_cart before return c, then predict the new total (it becomes 8) and update the first assert to match.

2 · Parametrize intermediate

Watch how a well-meaning test suite rots. You write test_is_even_two, then copy-paste it into test_is_even_three, then test_is_even_zero, changing one number and one expected value each time. Five near-identical functions later, someone finds a bug at the boundary and you realize you forgot the negative case — so you paste a sixth. Every one of those functions is the same logic with the data hard-wired in, and the test file is now three times longer than the code it tests. The moment you feel yourself copy-pasting a test body and tweaking a literal, that's the signal.

The fix is to separate the test's logic from the test's data. Picture a restaurant health inspector: they don't invent a new procedure for each dish — they run the same checklist against a table of dishes. One procedure, many rows. Parametrization is exactly that: you write the test body once, then feed it a table of (input, expected) rows, and the framework runs the body once per row.

In pytest that's the @pytest.mark.parametrize("n,expected", cases) decorator, and it buys you something a plain loop does not: each row becomes its own reported test. If three rows pass and one fails, you see three green dots and one red one that names the exact failing input — not a single test that dies on the first bad case and hides the rest. That last point is the real payoff and the reason not to just write a for loop with an assert inside: a bare loop stops at the first failure, so if rows 2 and 5 are broken you only ever learn about row 2 until you fix it and re-run. (The lab below uses a loop with a descriptive assert message to simulate the idea offline, and the stdlib subTest in the exercises gives you the "don't stop on first failure" behavior without pytest.)

The common mistake here is over-stuffing the table until the cases become unreadable, or worse, computing the "expected" value with the same code you're testing — which proves nothing, since a bug would corrupt both sides equally. Keep expected values as hand-written literals a human checked, and keep each row to one clear scenario: a couple of normal cases, the boundary (zero, empty, the max), and the nasty edge (a negative, a duplicate). A tight, honest table is worth more than fifty mechanical rows.

one test body × a table of rows → each row is its OWN reported test the case table (2, True) (3, False) (0, True) (-4, True) (7, False) (input, expected) test body runs once per row ✓ is_even(2) ✓ is_even(3) ✗ is_even(0) ✓ is_even(-4) ✓ is_even(7) a failing row is named — the rest still report One body, many rows, many reported tests. The table on the left feeds a single test body, but each row becomes its own result on the right — so a bad row (shown red) is named individually while every other row still passes and reports.
🗺️ How to read this diagram

Scan left to right: the table of cases → the single test body → one reported result per row.

  • The left box is the case table — each line is one (input, expected) pair a human wrote by hand.
  • The middle box is the test body you write once. The single blue arrow shows the whole table flowing into it; it runs the body once per row.
  • The five boxes on the right are the payoff: each row becomes its own reported test. Four are green (passed) and one is red (failed) — the red one is named, so you know exactly which input broke while the others still report.

In short: the red box is why you parametrize instead of writing a plain loop — a loop dies on the first failure and hides the rest.

Python · table-driven tests (runs)
param.pydef is_even(n): return n % 2 == 0

cases = [(2, True), (3, False), (0, True), (-4, True), (7, False)]
for n, expected in cases:
    assert is_even(n) == expected, f"failed for {n}"
print(f"all {len(cases)} parametrized cases pass")
# pytest form:  @pytest.mark.parametrize("n,expected", cases) def test_is_even(n,expected): ...
all 5 parametrized cases pass
▶ How this works

Parametrize means running the same test over a table of input/expected-output pairs, instead of writing one test per case. This block checks is_even against five different numbers using a loop that imitates what pytest's @pytest.mark.parametrize does for you.

  1. is_even(n) returns True when n % 2 == 0 — i.e. when dividing by 2 leaves no remainder (% is the remainder operator).
  2. cases is the table: each pair is (input, expected_answer). So (2, True) means "is_even(2) should be True".
  3. The for loop unpacks each pair into n and expected, then asserts the function's real answer matches. The f"failed for {n}" message tells you which number broke if one fails.
  4. The comment shows the pytest form: the same cases list feeds @pytest.mark.parametrize, and pytest reports each row as its own separate test.

What the output means: all 5 parametrized cases pass means every row matched. With real pytest you'd instead see 5 green dots — one per case — so a single failing input is easy to spot.

Try this: Add (1, True) to cases (a wrong expectation) and re-run — the assert fails and the message prints failed for 1, pointing straight at the bad row.

3 · Mocking advanced

Tests must be fast and deterministic, but real code calls slow/unpredictable things. Mocking replaces them with fakes you control. unittest.mock is stdlib — runs offline.

Picture the test suite you dread. It hits a real payment sandbox, so it's slow — ninety seconds a run. It reads the live clock, so a test that checks "token expires after 60s" passes at 2pm and fails at 2am. It calls a third-party API that goes down for maintenance every Sunday, so your CI turns red for reasons that have nothing to do with your code. These aren't bugs in your logic — they're your test reaching out and touching the messy, slow, unpredictable outside world. Mocking is how you cut those wires.

The mental model is a flight simulator. A trainee pilot doesn't practice engine-failure drills in a real 747 over a real city — they sit in a simulator where the instructor can, on command, make an engine "fail," freeze the weather, or set the fuel gauge to exactly 12%. The cockpit is real; the world outside is a controllable fake. A mock is that fake world for your code: it stands in for the collaborator (the API client, the clock, the file system) and lets you dictate exactly what it returns — and, unlike the real thing, it records every call so you can inspect afterward how your code used it.

Concretely, unittest.mock gives you two moves you'll use constantly. patch (as a with block or a decorator) temporarily swaps a real name for a fake for the duration of that block, then restores the original automatically — so the fake never leaks into other tests. A Mock object, meanwhile, is a shape-shifter: any attribute or method you call on it exists and returns another Mock, and you steer it with return_value (or side_effect to make it raise). Because it logs its calls, you can then assert on behavior: assert_called_once() proves your code invoked the collaborator exactly once — not zero times (it silently skipped the call) and not five times (an accidental loop). That distinction matters: a return-value check tells you what came back; a call assertion tells you how your code interacted with the world.

The classic trap — the one this whole chapter builds toward — is patching the wrong name. Python looks up names where they're used, not where they're defined, so if mymodule does from time import time, you must patch mymodule.time, not time.time; patch the definition and your fake never gets seen. And the deeper lesson lurking under all of it: if a test needs heaps of patching to isolate one function, that's not a mocking skill you're missing — it's your design telling you the function is tangled up with its dependencies. We'll cash that observation out in §5.

patch swaps the real collaborator for a fake — for the duration of the block without patch get_username code under test RealClient.fetch slow · flaky · network with patch.object(...) get_username unchanged code fake fetch returns {"name":"Ava"} records the call assert_called_once() Patch cuts the wire to the outside world. Top row: unpatched, the code reaches the real network client (red). Bottom row: inside the with patch.object(...) block the same code hits a fake that returns a canned value and records the call — so you can assert behaviour, not just the return.
🗺️ How to read this diagram

Compare the two rows top-to-bottom — same code on the left, a different collaborator on the right.

  • Top row (without patch): get_username calls the red RealClient.fetch — the slow, flaky, network-touching real thing. That is what the test is trying to avoid.
  • Bottom row (inside the with block): the code is unchanged, but the collaborator is now a green fake that returns a canned {"name":"Ava"}. The swap lasts only for the block, then the real one is restored.
  • The blue box on the far right is the extra payoff: the fake records that it was called, so assert_called_once() can verify how your code used the collaborator — not just what came back.

In short: the code never changes; patch only swaps what it talks to — a fake you control and can inspect.

Python · mock an external call (runs)
mock.pyfrom unittest.mock import patch

def get_username(user_id, client):
    return client.fetch(user_id)["name"]      # client is some API wrapper

# patch the collaborator so no real call happens:
class RealClient:
    def fetch(self, uid): raise RuntimeError("would hit the network!")

with patch.object(RealClient, "fetch", return_value={"name": "Ava"}) as m:
    assert get_username(1, RealClient()) == "Ava"
    m.assert_called_once()
print("mocked call verified, no network")
mocked call verified, no network
▶ How this works

Mocking means replacing a real dependency (an API, a database, the network) with a fake you fully control. You do it so tests are fast and deterministic — no waiting on a server, no flaky failures, no needing internet. Here the real dependency is a network client, and we swap in a fake reply.

  1. get_username is the code under test: it calls client.fetch(user_id) and pulls out the "name" field. The client is the outside dependency we don't want to really call.
  2. RealClient.fetch deliberately raises an error — it stands in for code that would hit the network. If our test ever called it for real, we'd see that loud RuntimeError.
  3. patch.object(RealClient, "fetch", return_value={...}) temporarily replaces fetch with a fake that just returns {"name": "Ava"}. The with block limits the swap to those lines — outside it, fetch is real again.
  4. m.assert_called_once() checks the fake was called exactly once. Mocks record how they were used, so you can verify behavior, not just the return value.

What the output means: mocked call verified, no network confirms two things: the function returned "Ava" from the fake, and the network was never touched (the real fetch would have crashed).

Try this: Delete the with patch.object(...) line but keep the call inside — you'll get the RuntimeError("would hit the network!"), which is exactly what the mock was protecting you from.

Python · mock time for determinism (runs)
mock_time.pyfrom unittest.mock import patch
import time
def cache_key(): return f"key-{int(time.time())}"

with patch("time.time", return_value=1000.0):
    assert cache_key() == "key-1000"
print("time frozen -> deterministic")
time frozen -> deterministic
▶ How this works

Time is a dependency too. Any code that reads the clock gives a different answer every run, so you can't assert on it. Mocking time.time freezes the clock so the result is predictable and testable.

  1. cache_key() builds a string like key-1700000000 from the current Unix time (seconds since 1970). Normally that number changes every second, so the output is unstable.
  2. patch("time.time", return_value=1000.0) makes every call to time.time() return 1000.0 for the duration of the with block — the clock is pinned.
  3. Now int(time.time()) is always 1000, so cache_key() reliably equals "key-1000" and the assert can pass every time.

What the output means: time frozen -> deterministic means the frozen clock made the output repeatable. "Deterministic" = same inputs always give the same result, which is what a trustworthy test needs.

Try this: Change return_value to 2000.0 and update the expected string to "key-2000" — the test still passes, proving you now control what "now" means.

4 · Professional — fixtures with teardown professional

Here's the failure that teaches this lesson the hard way. Your test suite opens a database connection, or a temp file, or a socket, in each test's setup. It works fine — for a while. Then, weeks later, CI starts failing intermittently with "too many open files" or "database is locked." The cause: every test opened a resource and none of them ever closed it, so they leaked, one per test, until the process ran out. Or worse — a test wrote a temp file, the next test found it already there, and now tests pass or fail depending on what ran before them. Setup got all the attention; nobody wrote the cleanup.

A fixture, properly understood, has two halves: the setup that builds the resource, and the teardown that tears it down again. Think of a chemistry lab. Before the experiment you set out clean glassware; after it, you wash everything and put it away — because the next student needs the bench exactly as clean as you found it. Teardown is that wash-up. Skip it and the residue from your experiment contaminates the next one. The whole promise of isolation — each test starting from a known clean slate — depends on teardown running reliably, even when the test in the middle blows up.

Pytest expresses this beautifully with yield: a fixture that does its setup, yields the resource to the test, and then runs whatever comes after the yield as teardown. The yield is the hand-off point — everything above it is "before the test," everything below it is "after the test, guaranteed." The stdlib unittest equivalent is setUp/tearDown, or more robustly addCleanup, which registers a cleanup the instant a resource exists (so it fires even if setup only half-completes — a real edge case tearDown can miss). The subtle point to internalize: teardown must run on the failure path, not just the happy path, which is exactly why frameworks provide these hooks instead of letting you tack a .close() at the end of the test — that final line never executes if an assertion above it raises.

One tip that dodges the whole problem when you can: prefer resources that clean up themselves. The lab below uses SQLite's :memory: database — it lives entirely in RAM and simply evaporates when the connection closes or the process ends, so there's no file on disk to leave behind and no other test it can contaminate. When a genuinely external resource (a real file, a container, a cloud bucket) is unavoidable, that's when disciplined teardown earns its keep.

a yield fixture: everything before yield is setup, everything after is guaranteed teardown setup connect + CREATE TABLE yield conn test runs here teardown conn.close() …even if the test RAISES before the test after the test — always yield is the hand-off between setup and teardown. Everything before yield builds the resource; the test runs at the yield; everything after it tears the resource down — and that teardown fires even when the test raises, which a plain .close() at the end of the test would not.
🗺️ How to read this diagram

Read the three boxes left to right — that's the order in time, with the solid arrows marking each step.

  • The blue box is setup: everything before yield — here, connecting and creating the table.
  • The amber middle box is the yield itself: pytest pauses the fixture and lets the test run against the resource it was handed.
  • The green box is teardown: everything after yield, here conn.close(). The dashed curved arrow is the key point — it loops from the test back down to teardown to show it runs even if the test raises, which is exactly what a trailing .close() line would skip.

In short: before yield = setup, after yield = guaranteed cleanup on both the pass and the failure path.

Python · setup/teardown with a real in-memory DB (runs)
teardown.pyimport sqlite3

def make_db():                         # fixture with teardown via context
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
    return conn

db = make_db()
db.execute("INSERT INTO t(name) VALUES ('ava')")
assert db.execute("SELECT COUNT(*) FROM t").fetchone()[0] == 1
db.close()                             # teardown
print("db fixture works and is isolated per test")
# pytest form uses `yield` for teardown:  @pytest.fixture def db(): c=...; yield c; c.close()
db fixture works and is isolated per test
▶ How this works

Some fixtures need cleanup afterwards — a database connection, an open file, a temp folder. Teardown is that cleanup step. This lab spins up a real database that lives entirely in memory (nothing written to disk), uses it, then closes it.

  1. sqlite3.connect(":memory:") creates a throwaway database in RAM. It's real SQL, but it vanishes when closed — perfect for tests because it starts empty and can't pollute other tests.
  2. make_db() is the fixture: it connects and creates a table t, then returns the connection ready to use.
  3. The middle lines exercise it: insert one row ('ava'), then SELECT COUNT(*) and assert exactly 1 row exists. fetchone()[0] grabs the single count value from the result.
  4. db.close() is the teardown — it releases the connection. The bottom comment shows pytest's cleaner way: yield the connection to the test, and any code after yield runs as automatic teardown.

What the output means: db fixture works and is isolated per test prints once the row count matched. Because the DB is in-memory and freshly built, each test that calls make_db() gets its own clean database.

Try this: Insert a second name before the assert and change the expected count to 2. This shows the fixture gives you a real, working table to test against.

5 · Tech-lead — design for testability tech-lead

If code is hard to mock, the design is the problem. A lead pushes dependency injection (SD3) so external calls are swappable — making the whole codebase testable without monkey-patching internals.

Here's the reframe that turns a testing chapter into a design chapter. In §3 you learned to patch the outside world. But if you find yourself patching three modules, freezing the clock, and monkeypatching a private attribute just to test one function, stop and listen to what the pain is telling you. Fighting the code to test it is not a sign you need better mocking tricks — it's the code screaming that it's too entangled with its dependencies. Difficulty of testing is one of the most honest, hardest-to-fake signals of design quality we have. Easy-to-test code is almost always well-structured code, and vice versa; the test is the canary in the coal mine.

The root cause is almost always the same: a function or class that reaches out and builds its own collaborators. When ReportBad calls random.random() internally, or a function does db = RealDatabase() on its own, it has welded itself to a specific, unpredictable, slow dependency. There's no seam — no place for a test to slip a fake in. Your only recourse is patching, which is really you prying open a seam by force from the outside.

The fix is dependency injection, and the idea is disarmingly simple: don't let an object build its dependencies — hand them to it. Think of a power tool with a standard socket. A good drill doesn't have one bit welded on; it accepts any bit you push into the chuck. In production you insert the real "bit" (the live database, the wall-clock time); in a test you insert a tiny fake bit (a lambda: 1000 for the clock, a hand-written FakeStore that just records what it was told). The drill's logic is identical either way, because it only ever depended on the socket — the interface — not on any specific bit. That socket is the "seam," and injection is the act of putting seams where the outside world touches your code.

The payoff compounds beyond one test. Injected fakes are usually plain objects and lambdas — no patch, no Mock, no fragile string paths to get right, so the tests are shorter and don't break when you rename an internal. Notice the mistake this quietly avoids: over-mocking. Heavy mocking is a design smell, not a testing technique to master — the more you reach for it, the louder the code is asking to be refactored. As a tech lead, the highest-leverage move isn't writing better tests; it's designing code with clean seams so the whole team writes easy tests by default. The best test infrastructure a codebase can have is a testable design.

don't let the code BUILD its dependency — HAND it in before · builds its own ReportBad.run() random.random() inside no seam → must patch after · injected Report(clock, source) collaborators passed in a seam → drop in a fake refactor test hands in fakes clock=lambda:1000 · no patch Injection turns a closed box into one with a seam. The red "before" box builds random.random() itself — no place to slip a fake in, so a test must patch internals. The green "after" box accepts clock and source as arguments, so a test just hands in a lambda and a plain fake — zero patching.
🗺️ How to read this diagram

Read left to right: the red "before" design refactors into the green "after" design, which then makes the test (bottom box) trivial.

  • The red box is ReportBad: it constructs its own dependency (random.random()) inside. There is no seam — the only way to control it in a test is to patch internals.
  • The blue "refactor" arrow leads to the green box, Report(clock, source): the same logic, but the collaborators are now parameters. That parameter list is the seam.
  • The dashed arrow down to the blue box is the payoff: because the seam exists, the test just hands in clock=lambda:1000 and a plain fake — no patch, no Mock.

In short: hard-to-test (red) → inject the dependencies (green) → the test writes itself with plain fakes.

Python · injectable design = trivial to test (runs)
testable_design.py# HARD to test: creates its own dependency internally
class ReportBad:
    def run(self):
        import random
        return random.random()          # non-deterministic, can't assert

# EASY to test: dependency injected
class Report:
    def __init__(self, clock, source):   # inject collaborators
        self.clock, self.source = clock, source
    def run(self):
        return {"at": self.clock(), "data": self.source()}

# test with fakes — fully deterministic, no patching:
r = Report(clock=lambda: 1000, source=lambda: [1, 2, 3])
assert r.run() == {"at": 1000, "data": [1, 2, 3]}
print("injected design tested with zero mocking machinery")
injected design tested with zero mocking machinery
▶ How this works

This is the big lesson: if code is hard to mock, the design is the problem. Dependency injection means passing a class its dependencies from outside instead of having it build them itself. Then tests just hand over fakes — no patching machinery needed at all.

  1. ReportBad.run creates its own randomness inside (random.random()). You can't predict the answer, so you can't write an assert for it — it's untestable by design.
  2. Report.__init__(self, clock, source) takes its dependencies as arguments and stores them. It doesn't care what clock and source are, only that it can call them — that's injection.
  3. In the test, we pass tiny fakes: clock=lambda: 1000 (a function that always returns 1000) and source=lambda: [1, 2, 3]. A lambda is just a one-line throwaway function.
  4. Because both inputs are fixed and known, r.run() is fully predictable, so the assert can check the exact dictionary — with zero mocking, patching, or monkeypatching.

What the output means: injected design tested with zero mocking machinery confirms the point: good design made the test trivial. When you find yourself patching deep internals, that's a hint to inject dependencies instead.

Try this: Swap in source=lambda: [] and change the expected "data" to [] — you just tested a different scenario by handing over a different fake, no framework required.

Lots of mocking = a design smellIf tests need to patch deep internals, that's the code telling you to inject dependencies. A lead who enforces injectable design (SD3) makes testing easy for the whole team — the best test infrastructure is a testable design.

Exercise TQ3.1 — Fixtures + mocking + injection

Context: A caching service over an external client is the archetype for fixtures, mocking, and injection all at once: you fake the network, parametrize inputs, assert the cache, and simulate failure.

Your task: Build a WeatherService(client) that caches results, inject a fake client so no network is touched, parametrize several cities, assert the client is called once per city (proving the cache), and mock a failure to test error handling.

Requirements:

  • The service takes its client via constructor injection
  • Repeated lookups for the same city hit the cache — the client is called only once per city
  • Parametrize the assertion over several cities (a case table or subTest)
  • A fake client (no real network) supplies canned responses and records its calls
  • One case makes the client raise to exercise the service's error handling
  • Note explicitly that injection removed any need to patch

💡 Hint: Assert call counts on the injected fake to prove the cache: a second lookup of the same city should add no new call, while a new city adds exactly one.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · A fixture-style factory (stdlib)Beginner

Context: A fixture provides fresh, shared setup so tests don't cross-talk. Before pytest's @pytest.fixture, the stdlib gives you unittest.setUp, which runs before every test method.

Your task: Demonstrate the fixture idea with unittest.setUp: give each test a brand-new object so no test can leak state into another.

Requirements:

  • Define a small stateful class (e.g. a Cart with add and total)
  • setUp constructs a fresh instance before each test
  • One test asserts the object starts empty
  • Another test mutates it and asserts the new state
  • The two tests pass in either order — no shared state
  • Note that the pytest equivalent is @pytest.fixture

💡 Hint: Because setUp reruns before each method, the mutation in one test cannot be visible in another — that isolation is the whole value of a fixture.

Show solution

The fixture idea without pytest — setUp runs before each test:

import unittest

class Cart:
    def __init__(self): self.items = []
    def add(self, x): self.items.append(x)
    def total(self): return len(self.items)

class TestCart(unittest.TestCase):
    def setUp(self):
        self.cart = Cart()          # fresh per test = the fixture
    def test_starts_empty(self):
        self.assertEqual(self.cart.total(), 0)
    def test_add(self):
        self.cart.add("x")
        self.assertEqual(self.cart.total(), 1)

if __name__ == "__main__":
    unittest.main(verbosity=2)      # 2 tests OK, no cross-talk

Each test gets its own Cart, so test_add can't leak into test_starts_empty. The pytest equivalent is @pytest.fixture.

Exercise 2 · Parametrize (table-driven, stdlib)Intermediate

Context: Covering many inputs by copy-pasting a test body is a maintenance trap. The stdlib subTest runs one test body over a table of cases and, crucially, names the failing row.

Your task: Use subTest to run a single test method over a table of (input, expected) cases for a small function like is_even.

Requirements:

  • Keep the cases in a table (a list of tuples)
  • Iterate the table inside one test method
  • Wrap each case in with self.subTest(...) so failures don't stop the loop
  • Pass the varying value into subTest so a failure reports which row broke
  • Cover both true and false results, including a negative input
  • Note the pytest analogue is @pytest.mark.parametrize

💡 Hint: The keyword you pass to subTest (e.g. n=n) is what shows up in the failure label — include the value that identifies the case.

Show solution

subTest is the stdlib way to run a case table:

import unittest

def is_even(n): return n % 2 == 0

class TestEven(unittest.TestCase):
    CASES = [(0, True), (1, False), (2, True), (-3, False)]
    def test_is_even(self):
        for n, expected in self.CASES:
            with self.subTest(n=n):
                self.assertEqual(is_even(n), expected)

if __name__ == "__main__":
    unittest.main(verbosity=2)     # one method, 4 sub-cases; failures name n

A failing case reports its n= so you know exactly which row broke — the same benefit as pytest's @pytest.mark.parametrize.

Exercise 3 · Mock the outside worldAdvanced

Context: Tests must not touch real APIs, clocks, or files — those are slow, flaky, and non-deterministic. The stdlib unittest.mock lets you swap a dependency for a fake and assert exactly how it was called.

Your task: Use unittest.mock.Mock to replace a network client in a fetch_user function, faking its response and asserting on the call that was made.

Requirements:

  • The function under test takes the client as a parameter and calls a method on it (e.g. client.get(...))
  • Build a Mock and set its return value to a canned response
  • Assert the function's transform of that response (e.g. an uppercased name)
  • Assert the client was called exactly once with the expected argument (assert_called_once_with)
  • The test is fast, offline, and deterministic

💡 Hint: A Mock records its calls, so you can verify both the value your code returned and the precise call it made to the collaborator — behavior, not just output.

Show solution

unittest.mock is stdlib — no external dependency:

import unittest
from unittest.mock import Mock

def fetch_user(client, uid):
    resp = client.get(f"/users/{uid}")     # would be a real HTTP call
    return resp["name"].upper()

class TestFetch(unittest.TestCase):
    def test_uses_client_and_transforms(self):
        client = Mock()
        client.get.return_value = {"name": "ada"}   # fake the network
        self.assertEqual(fetch_user(client, 7), "ADA")
        client.get.assert_called_once_with("/users/7")

if __name__ == "__main__":
    unittest.main(verbosity=2)     # fast, offline, deterministic

The mock lets you assert both the transform and the exact call made — testing behavior without a real server or flaky network.

Exercise 4 · Mock time and patch a dependencyExpert

Context: Two of the hardest things to test are the clock and a module-level dependency. mock.patch lets you freeze time.time so time-dependent logic becomes deterministic — no sleep, no flakiness.

Your task: Use mock.patch to freeze time.time and test a token is_expired check on both sides of its expiry boundary.

Requirements:

  • is_expired(issued_at, ttl) compares elapsed time against the TTL
  • Patch time.time to a fixed value with a decorator or context manager
  • One test asserts a still-fresh token is not expired
  • Another test asserts an old token is expired
  • Both tests pass regardless of the real wall-clock time
  • The patch targets the name where time.time is looked up

💡 Hint: Patch the dependency in the namespace where it is used, not where it is defined; with the clock frozen you can place issued_at just inside and just outside the TTL.

Show solution

Patch the dependency in the namespace where it's looked up:

import unittest, time
from unittest.mock import patch

def is_expired(issued_at, ttl=60):
    return time.time() - issued_at > ttl

class TestExpiry(unittest.TestCase):
    @patch("time.time", return_value=1000.0)
    def test_not_expired(self, _mock):
        self.assertFalse(is_expired(issued_at=990, ttl=60))   # 10s old
    @patch("time.time", return_value=1000.0)
    def test_expired(self, _mock):
        self.assertTrue(is_expired(issued_at=900, ttl=60))    # 100s old

if __name__ == "__main__":
    unittest.main(verbosity=2)     # both deterministic regardless of real clock

Freezing time.time makes the expiry logic testable at both sides of the boundary — no sleep, no flakiness.

Exercise 5 · Fixtures with teardown (setUp/tearDown)Professional

Context: Resources must be released even when a test fails midway. setUp/tearDown — and more robustly addCleanup — guarantee cleanup regardless of outcome.

Your task: Create a temp file in setup, use it in a test, and register cleanup so the file is provably gone afterward even if the test raises.

Requirements:

  • Create the temp file in setUp (e.g. with tempfile.mkstemp)
  • Register removal with addCleanup so it runs even on failure
  • The test writes to and reads back from the file, asserting the content
  • Cleanup runs whether the test passes or raises
  • Explain why addCleanup is more robust than tearDown
  • Note the pytest analogue is a fixture with yield

💡 Hint: addCleanup still fires if setUp half-completes, which a plain tearDown may not — register the cleanup the moment the resource exists.

Show solution

Teardown guarantees cleanup regardless of outcome:

import unittest, tempfile, os

class TestWithTempFile(unittest.TestCase):
    def setUp(self):
        fd, self.path = tempfile.mkstemp()
        os.close(fd)
        self.addCleanup(self._remove)     # runs even if the test raises
    def _remove(self):
        if os.path.exists(self.path):
            os.remove(self.path)
    def test_writes(self):
        with open(self.path, "w") as f: f.write("hi")
        with open(self.path) as f:
            self.assertEqual(f.read(), "hi")

if __name__ == "__main__":
    unittest.main(verbosity=2)     # temp file created, used, then cleaned up

addCleanup is more robust than tearDown because it still runs if setUp half-fails — the pytest analogue is a fixture with yield.

Exercise 6 · Design for testability (inject the seams)Industry scenario

Context: Tech-lead reality: code that is hard to test is usually badly designed. A function that constructs its own dependencies forces heavy mocking; one that accepts them lets a plain fake drop in with no patching magic.

Your task: Refactor a function that news-up its own clock and store into one that accepts them as parameters, then test it with a simple fake and a lambda — no mock.patch.

Requirements:

  • Show the hard-to-test version that creates its own dependencies internally
  • Refactor so the clock and store are passed in as arguments (the seams)
  • Write a small FakeStore that records what it was told to write
  • Supply the clock as a plain lambda returning a fixed timestamp
  • Assert the fake recorded the expected (timestamp, amount)
  • No mock.patch is needed anywhere

💡 Hint: When the collaborators are parameters, a hand-written fake and a lambda replace all the patching — the pain of testing was really a design signal.

Show solution

Dependency injection makes mocking unnecessary — pass a fake in:

import unittest

# HARD to test: creates its own clock + store internally
# def charge(amount): db = RealDB(); db.write(time.time(), amount)

# TESTABLE: seams are parameters
def charge(amount, clock, store):
    store.write(clock(), amount)

class FakeStore:
    def __init__(self): self.rows = []
    def write(self, ts, amt): self.rows.append((ts, amt))

class TestCharge(unittest.TestCase):
    def test_records_with_time(self):
        store = FakeStore()
        charge(42, clock=lambda: 1000.0, store=store)
        self.assertEqual(store.rows, [(1000.0, 42)])

if __name__ == "__main__":
    unittest.main(verbosity=2)     # no patching needed; seams are explicit

With the clock and store injected, the test uses a plain fake and a lambda — no mock.patch gymnastics. Testable code is well-designed code.

✓ Checkpoint — you can move on when you can…

  • Use fixtures for shared setup.
  • Parametrize many inputs.
  • Mock APIs, time, and files.
  • Push injectable design so code stays testable.

Knowledge check check yourself

✓ Knowledge check

In the mocking lab, RealClient.fetch deliberately raises "would hit the network!" and the test patches it. What two things does m.assert_called_once() plus the passing run actually prove?

Show answer
It proves the function returned the fake value ("Ava") and that the real network call was never made — if the patch were removed the RuntimeError would fire. assert_called_once() additionally verifies the collaborator was invoked exactly once, so you're checking behavior, not just the return value.
✓ Knowledge check

The lesson contrasts ReportBad (creates random.random() internally) with Report (takes clock and source as arguments). Why does dependency injection make the second class trivial to test?

Show answer
Because the injected class receives its collaborators from outside, a test can pass tiny deterministic fakes (e.g. lambda: 1000) and assert the exact output — no patching or monkeypatching needed. The lesson's rule: if code is hard to mock, the design is the problem; heavy mocking is a design smell pointing you toward injection.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in