AI EngineeringZero to ProductionHome·About·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

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.

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

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.

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

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.

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