Fixtures, parametrize & mocking
The tools that make tests fast and reliable: fixtures, parametrize, mocking the outside world, and designing for testability — all runnable offline.
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.
code/tq3-fixtures-mocking/. Python blocks run offline; config files are ready to drop into a project.1 · Fixtures essential
A fixture provides shared setup without copy-paste. In pytest it's @pytest.fixture; the idea is a factory that returns a fresh object per test.
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
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.
class Cartis a tiny shopping cart:addstores an item and quantity, andtotaladds up all the quantities.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.- The two
assertlines are the tests: they each callsample_cart()to get their own cart, then check the totals.assertmeans "this must be true, or stop and complain". - The comment at the bottom shows the real pytest version: you decorate the function with
@pytest.fixture, then a test just namessample_cartas 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
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
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.
is_even(n)returns True whenn % 2 == 0— i.e. when dividing by 2 leaves no remainder (%is the remainder operator).casesis the table: each pair is(input, expected_answer). So(2, True)means "is_even(2) should be True".- The
forloop unpacks each pair intonandexpected, then asserts the function's real answer matches. Thef"failed for {n}"message tells you which number broke if one fails. - The comment shows the pytest form: the same
caseslist 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.
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
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.
get_usernameis the code under test: it callsclient.fetch(user_id)and pulls out the"name"field. Theclientis the outside dependency we don't want to really call.RealClient.fetchdeliberately 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 loudRuntimeError.patch.object(RealClient, "fetch", return_value={...})temporarily replacesfetchwith a fake that just returns{"name": "Ava"}. Thewithblock limits the swap to those lines — outside it,fetchis real again.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.
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
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.
cache_key()builds a string likekey-1700000000from the current Unix time (seconds since 1970). Normally that number changes every second, so the output is unstable.patch("time.time", return_value=1000.0)makes every call totime.time()return1000.0for the duration of thewithblock — the clock is pinned.- Now
int(time.time())is always1000, socache_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
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
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.
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.make_db()is the fixture: it connects and creates a tablet, then returns the connection ready to use.- The middle lines exercise it: insert one row (
'ava'), thenSELECT COUNT(*)and assert exactly 1 row exists.fetchone()[0]grabs the single count value from the result. db.close()is the teardown — it releases the connection. The bottom comment shows pytest's cleaner way:yieldthe connection to the test, and any code afteryieldruns 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.
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
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.
ReportBad.runcreates 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.Report.__init__(self, clock, source)takes its dependencies as arguments and stores them. It doesn't care whatclockandsourceare, only that it can call them — that's injection.- In the test, we pass tiny fakes:
clock=lambda: 1000(a function that always returns 1000) andsource=lambda: [1, 2, 3]. Alambdais just a one-line throwaway function. - Because both inputs are fixed and known,
r.run()is fully predictable, so theassertcan 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.
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.
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
Cartwith add and total) setUpconstructs 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.
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
subTestso 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.
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
Mockand 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.
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.timeto 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.timeis 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.
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. withtempfile.mkstemp) - Register removal with
addCleanupso 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
addCleanupis more robust thantearDown - 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.
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
FakeStorethat records what it was told to write - Supply the clock as a plain
lambdareturning a fixed timestamp - Assert the fake recorded the expected
(timestamp, amount) - No
mock.patchis 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
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
assert_called_once() additionally verifies the collaborator was invoked exactly once, so you're checking behavior, not just the return value.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
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.