Test doubles & non-functional testing
The vocabulary and the systems tests. First the precise Meszaros taxonomy of test doubles (dummy, stub, spy, mock, fake) and the theory of flaky tests; then non-functional testing — latency percentiles, throughput, stress and soak, security and accessibility — and finally how to test the untestable: LLM and other nondeterministic systems.
Learning objectives
- Name and use the five Meszaros test doubles — dummy, stub, spy, mock, fake — and pick the right one.
- Explain the mock-vs-stub distinction (behaviour verification vs state verification).
- Diagnose flaky tests: enumerate the sources of nondeterminism and the fix for each.
- Define non-functional testing and measure latency as a distribution (p50/p95/p99) vs throughput.
- Distinguish load, stress and soak testing, and name the basics of security and accessibility testing.
- Test nondeterministic / LLM systems with property, snapshot, and rubric checks — tying to ch05-evaluation.
unittest and unittest.mock ship with Python. Load-testing tools (Locust, k6, JMeter) and accessibility scanners (axe) are named where a real project would use them.1 · The test-double taxonomy (Meszaros)
“Mock” is used loosely to mean any stand-in, but Gerard Meszaros' xUnit Test Patterns defines five distinct test doubles, and interviews expect the precise distinctions:
| Double | What it does | Use when | Verifies |
|---|---|---|---|
| Dummy | fills a parameter, never used | a required arg is irrelevant to the test | nothing |
| Stub | returns canned answers | you need to drive the code down a path | state (indirectly) |
| Spy | a stub that also records calls | you want to assert how it was called, after the fact | interaction |
| Mock | pre-programmed with expectations, self-verifying | the interaction is the behaviour | interaction (fails if unmet) |
| Fake | a real, lighter implementation | you need working behaviour (in-memory DB) | state |
The deepest distinction is state verification (assert the result / stored state — stubs and fakes) versus behaviour verification (assert the collaborator was called a certain way — spies and mocks). Over-using mocks couples tests to implementation; prefer stubs/fakes and verify state where you can.
test_doubles.pyimport unittest
from unittest.mock import MagicMock
# The five test doubles (Meszaros), each shown against the SAME collaborator: a
# Notifier that a PasswordReset service depends on.
# --- 1. DUMMY: passed to satisfy a signature, never actually used. ---
class DummyNotifier:
def send(self, *a, **k): raise AssertionError("dummy must not be called")
# --- 2. STUB: returns canned answers to drive the code under test. ---
class StubClock:
def now(self): return 1000 # fixed time, no real clock
# --- 3. FAKE: a real working implementation, but not production-grade. ---
class FakeEmailStore: # in-memory 'database'
def __init__(self): self.sent = []
def send(self, to, body): self.sent.append((to, body)); return True
# --- 4. SPY: records how it was called so the test can assert on it. ---
class SpyNotifier:
def __init__(self): self.calls = []
def send(self, to, body): self.calls.append((to, body))
# --- 5. MOCK: pre-programmed with expectations, verifies them (library-backed). ---
def reset_password(user, notifier, clock):
token = f"tok-{clock.now()}"
notifier.send(user, f"reset: {token}")
return token
class TestDoubles(unittest.TestCase):
def test_stub_and_fake(self):
fake = FakeEmailStore()
token = reset_password("ana@x.io", fake, StubClock())
self.assertEqual(token, "tok-1000") # stub drove the value
self.assertEqual(fake.sent, [("ana@x.io", "reset: tok-1000")]) # fake stored it
def test_spy_records_calls(self):
spy = SpyNotifier()
reset_password("bo@x.io", spy, StubClock())
self.assertEqual(len(spy.calls), 1) # spy verifies interaction
def test_mock_verifies_expectation(self):
mock = MagicMock()
reset_password("cy@x.io", mock, StubClock())
mock.send.assert_called_once_with("cy@x.io", "reset: tok-1000")
if __name__ == "__main__":
unittest.main(verbosity=2)
test_mock_verifies_expectation (__main__.TestDoubles.test_mock_verifies_expectation) ... ok
test_spy_records_calls (__main__.TestDoubles.test_spy_records_calls) ... ok
test_stub_and_fake (__main__.TestDoubles.test_stub_and_fake) ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK
The FakeEmailStore lets you assert state (what was stored); the SpyNotifier and the MagicMock let you assert interaction (that send was called once with the right arguments). The DummyNotifier exists only to prove it is never touched. Same seam, five roles.
2 · Test smells & flaky-test theory
A flaky test passes and fails on the same code — the single most corrosive test smell, because it trains the team to ignore red. Flakiness always traces to nondeterminism leaking into the test. The sources, and the fix for each:
| Source of nondeterminism | Symptom | Fix |
|---|---|---|
| Time / clock | fails at midnight, timezone edges | inject a clock / freeze time |
| Randomness | fails ~1 in N runs | seed the RNG, or inject it |
| Network / external service | fails when the network hiccups | stub/fake the dependency |
| Concurrency / race | fails under load or reordering | remove shared state; deterministic scheduling |
| Order dependence | passes alone, fails in the suite | isolate: fresh state per test (setUp) |
| Unordered collections | dict/set iteration order assumed | sort before asserting |
flaky.pyimport random, unittest
# A flaky test: depends on randomness / hidden global state -> nondeterministic.
def pick_greeting_flaky():
return random.choice(["hi", "hello", "hey"]) # nondeterministic output
# The FIX: inject the source of nondeterminism so the test controls it.
def pick_greeting(rng):
return rng.choice(["hi", "hello", "hey"])
class TestFlaky(unittest.TestCase):
def test_deterministic_with_injected_rng(self):
rng = random.Random(42) # seeded -> repeatable
self.assertEqual(pick_greeting(rng), pick_greeting(random.Random(42)))
def test_no_order_dependence(self):
# Isolated: does not rely on any other test having run first.
self.assertIn(pick_greeting(random.Random(1)), ["hi", "hello", "hey"])
if __name__ == "__main__":
unittest.main(verbosity=2)
test_deterministic_with_injected_rng (__main__.TestFlaky.test_deterministic_with_injected_rng) ... ok
test_no_order_dependence (__main__.TestFlaky.test_no_order_dependence) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
The flaky function reads a global RNG; the fixed version injects the RNG so the test seeds it and gets a repeatable answer — the same dependency-injection move used for the clock in TQ2 and for the network via stubs in TQ3. The rule: a test must own every source of nondeterminism it touches.
3 · Non-functional testing: performance & latency percentiles
Everything so far tested functional correctness (does it compute the right answer?). Non-functional testing checks the qualities — speed, capacity, resilience, security, accessibility. The first is performance, and the cardinal rule is: latency is a distribution, not a mean.
Latency is how long one request takes; throughput is how many requests complete per unit time. They are different axes — a batching system can raise throughput while worsening tail latency. Because a few slow requests are what users notice, you report percentiles (p50/p95/p99), never just the mean.
percentiles.py# Non-functional: latency is a DISTRIBUTION, not a mean. Report percentiles.
# p50 = median, p95/p99 = tail. The mean hides the tail that users feel.
def percentile(sorted_data, p):
# nearest-rank method: smallest value >= p% of the data.
k = max(0, (len(sorted_data) * p + 99) // 100 - 1) # ceil(n*p/100)-1, clamped
return sorted_data[k]
# 100 request latencies in ms: mostly fast, a few very slow (a realistic tail).
latencies = sorted([20] * 90 + [50] * 5 + [200] * 4 + [900] * 1)
mean = sum(latencies) / len(latencies)
print(f"count : {len(latencies)}")
print(f"mean : {mean:.1f} ms")
print(f"p50 (median): {percentile(latencies, 50)} ms")
print(f"p95 : {percentile(latencies, 95)} ms")
print(f"p99 : {percentile(latencies, 99)} ms")
print(f"max : {latencies[-1]} ms")
# Throughput vs latency: throughput = requests / total time (concurrency matters).
total_seconds = 2.0
requests = 1000
print(f"throughput : {requests / total_seconds:.0f} req/s")
count : 100
mean : 37.5 ms
p50 (median): 20 ms
p95 : 50 ms
p99 : 200 ms
max : 900 ms
throughput : 500 req/s
The mean latency is 37.5 ms — but the median (p50) is only 20 ms while p99 is 200 ms and the worst request is 900 ms. The mean is dragged up by the tail and describes no real request. This is why SLOs are written as “p99 < 300 ms”, not “mean < 50 ms”. (Ties to ic1-foundations on latency/throughput/cost.)
pip install locust), k6, or JMeter, which report these percentiles for you under concurrency. The percentile maths above is what those tools compute internally.4 · Load, stress, soak — and security & accessibility
Performance testing splits into distinct goals, each answering a different question:
| Test | Question it answers | How |
|---|---|---|
| Load | does it meet SLOs at expected traffic? | ramp to target RPS, hold, measure p99 |
| Stress | where does it break, and how? | push past capacity until failure; observe degradation |
| Soak (endurance) | does it leak / degrade over time? | hold moderate load for hours; watch memory/handles |
| Spike | does a sudden surge recover? | instant jump in traffic; measure recovery |
Two more non-functional dimensions round out a professional suite:
| Dimension | What you test | Tooling |
|---|---|---|
| Security | injection, authn/authz, secrets, dependency CVEs, fuzzing untrusted input (TQ7) | SAST (Bandit/Semgrep), DAST (ZAP), pip-audit |
| Accessibility (a11y) | WCAG: contrast, keyboard nav, alt text, ARIA roles, screen-reader labels | axe-core, Lighthouse, pa11y |
pip-audit), static analysis (Bandit), and the coverage-guided fuzzing from TQ7 aimed at any parser of untrusted input. Manual penetration testing (rt2/rt3) comes on top, not instead.5 · Testing the untestable: LLM & nondeterministic systems
An LLM (or any stochastic system) breaks example-based testing: the same prompt can yield different wording, so assertEqual(output, expected) is hopeless. You fall back on the techniques from TQ7 and ch05-evaluation:
| Approach | What you assert | Borrowed from |
|---|---|---|
| Property / invariant | grounding, format, no PII, monotonicity | property & metamorphic testing (TQ7) |
| Snapshot / golden | output matches a pinned reference until intentionally updated | regression testing |
| Rubric / allow-list | output is one of an accepted set of shapes | acceptance testing |
| LLM-as-judge | a grader model scores against criteria | ch05-evaluation |
test_llm.py# Testing the "untestable": a nondeterministic (e.g. LLM) system where exact
# output can't be asserted. Test PROPERTIES and use SNAPSHOTS, not equality.
import re
def fake_llm_extract(text):
# Stand-in for a model call: extract an order id. Output wording may vary.
m = re.search(r"order #?(\d{4,})", text, re.I)
return f"The order id is {m.group(1)}." if m else "No order id found."
# 1. PROPERTY/INVARIANT check: the extracted id must appear in the input.
def prop_id_grounded(text):
out = fake_llm_extract(text)
m = re.search(r"(\d{4,})", out)
return (m is None) or (m.group(1) in text) # never hallucinate an id
cases = ["please refund order #10432", "where is ORDER 88888?", "hi there"]
print("grounded on all cases:", all(prop_id_grounded(c) for c in cases))
# 2. SNAPSHOT check: pin the current output; a diff flags an intended change.
snapshot = "The order id is 10432."
print("matches snapshot :", fake_llm_extract(cases[0]) == snapshot)
# 3. RUBRIC/allow-list check: output must be one of the accepted shapes.
def valid_shape(out):
return out == "No order id found." or out.startswith("The order id is ")
print("all outputs valid :", all(valid_shape(fake_llm_extract(c)) for c in cases))
grounded on all cases: True
matches snapshot : True
all outputs valid : True
Even though the model's exact wording is not asserted, three checks hold it accountable: a grounding property (any id it returns must appear in the input — no hallucinated ids), a snapshot for regression, and a shape allow-list. This is the bridge from software testing into ch05-evaluation, where LLM-as-judge and eval datasets extend the same idea to open-ended output.
✓ Checkpoint — you can move on when you can…
- Name the five Meszaros doubles and choose the right one for a given seam.
- Explain state verification vs behaviour verification (stub/fake vs spy/mock).
- List the sources of nondeterminism behind flaky tests and the fix for each.
- Report latency as p50/p95/p99 and explain why the mean misleads; distinguish latency from throughput.
- Distinguish load / stress / soak / spike, and name a security and an accessibility check.
- Test a nondeterministic/LLM output with property, snapshot, and rubric checks.
Knowledge check
The percentile lab shows a mean latency of 37.5 ms while p50 is 20 ms and p99 is 200 ms (with a 900 ms max). Why do teams write SLOs against p99 rather than the mean, and what is the difference between latency and throughput?
Show answer
You wrote reset_password(user, notifier, clock). In one test you assert the returned token equals "tok-1000"; in another you assert notifier.send was called once with specific args. Which test doubles do these two styles use, and what is the taxonomy term for each style?
Show answer
now()==1000 drives the token value) plus a fake/stub collaborator, and asserts the result — that is state verification. The second uses a spy or a mock and asserts the interaction (that send was called once with the right arguments) — that is behaviour verification. Prefer state verification where possible; behaviour verification couples the test to how the code calls its collaborators.🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: A stub returns canned answers so you can test code that depends on something you don't want to call for real.
Your task: Test a function is_business_hours(clock) that returns True for 9–17, using a stub clock so the test does not read the real time.
Requirements:
is_business_hourstakes a clock object with anhour()method- Provide a stub clock returning a fixed hour
- Assert True for a business hour and False for a night hour
- Runs under
unittest; no real clock read
💡 Hint: The stub is any object with the method the code calls — no library needed.
Show solution
A stub clock removes the real-time dependency:
import unittest
def is_business_hours(clock):
return 9 <= clock.hour() < 17
class StubClock:
def __init__(self, h): self._h = h
def hour(self): return self._h
class TestHours(unittest.TestCase):
def test_daytime(self): self.assertTrue(is_business_hours(StubClock(10)))
def test_night(self): self.assertFalse(is_business_hours(StubClock(23)))
if __name__ == "__main__":
unittest.main(verbosity=2) # 2 tests OK
The stub drives the code down each path deterministically — this is state verification: we assert the returned value.
Context: A spy records calls for later assertion; a mock is pre-programmed and self-verifies.
Your task: Test that a checkout(cart, gateway) function charges the gateway exactly once with the cart total, first with a hand-written spy, then with unittest.mock.MagicMock.
Requirements:
- A hand-written spy records each
chargecall's amount - Assert it was called once with the correct total
- Repeat the assertion with
MagicMock().charge.assert_called_once_with(...) - Runs under
unittest
💡 Hint: The spy is just a class collecting calls in a list; the mock does the same with a fluent assertion API.
Show solution
Behaviour verification two ways — hand-rolled spy and library mock:
import unittest
from unittest.mock import MagicMock
def checkout(cart, gateway):
gateway.charge(sum(cart))
class SpyGateway:
def __init__(self): self.charges = []
def charge(self, amt): self.charges.append(amt)
class TestCheckout(unittest.TestCase):
def test_with_spy(self):
spy = SpyGateway(); checkout([10, 5], spy)
self.assertEqual(spy.charges, [15])
def test_with_mock(self):
m = MagicMock(); checkout([10, 5], m)
m.charge.assert_called_once_with(15)
if __name__ == "__main__":
unittest.main(verbosity=2) # 2 tests OK
Both assert the interaction. The spy gives you the raw calls; the mock bundles the expectation into one self-verifying assertion.
Context: Flakiness is nondeterminism leaking into the test; the fix is to inject and control the source.
Your task: Given a flaky discount_of_the_day() that uses the global RNG and the real date, refactor it to be deterministic under test and write a repeatable test.
Requirements:
- Identify the two sources of nondeterminism (randomness and date)
- Refactor to inject an RNG and a date/day value
- Write a test seeding the RNG and pinning the day so the result is repeatable
- Runs under
unittest; passes every run
💡 Hint: Push both hidden inputs in as parameters — the same dependency-injection move for time and randomness.
Show solution
Inject the RNG and the day so the test owns them:
import random, unittest
# BEFORE: reads global rng + real date -> flaky
# AFTER: inject both
def discount_of_the_day(rng, weekday):
base = 10 if weekday < 5 else 20 # bigger weekend discount
return base + rng.randint(0, 5)
class TestDiscount(unittest.TestCase):
def test_repeatable(self):
a = discount_of_the_day(random.Random(7), weekday=2)
b = discount_of_the_day(random.Random(7), weekday=2)
self.assertEqual(a, b) # deterministic
def test_weekend_higher_floor(self):
self.assertGreaterEqual(discount_of_the_day(random.Random(0), 6), 20)
if __name__ == "__main__":
unittest.main(verbosity=2) # 2 tests OK, every run
With both inputs injected the test is Repeatable and Isolated (FIRST) — no seed leaks, no date dependence, no retries.
Context: Percentiles, not the mean, are how you reason about and gate on performance.
Your task: Compute p50, p95 and p99 from a list of latency samples with a nearest-rank percentile function, and show the mean misrepresents the tail.
Requirements:
- Implement
percentile(sorted_samples, p)via nearest-rank - Compute mean, p50, p95, p99 over a tail-heavy sample
- Assert p99 ≥ p95 ≥ p50 and that the mean exceeds the median
- Runs on the stdlib
💡 Hint: Nearest-rank: index = ceil(n*p/100) - 1 into the sorted samples.
Show solution
Percentiles expose the tail the mean hides:
import unittest
def percentile(s, p):
k = max(0, (len(s) * p + 99) // 100 - 1) # ceil(n*p/100)-1
return s[k]
class TestLatency(unittest.TestCase):
def test_tail(self):
s = sorted([15] * 95 + [400] * 5) # 5% slow tail
mean = sum(s) / len(s)
p50, p95, p99 = (percentile(s, q) for q in (50, 95, 99))
self.assertLessEqual(p50, p95)
self.assertLessEqual(p95, p99)
self.assertGreater(mean, p50) # mean dragged up by tail
self.assertEqual((p50, p99), (15, 400))
if __name__ == "__main__":
unittest.main(verbosity=2) # 1 test OK
p50 is 15 ms but p99 is 400 ms; the mean sits above the median. This is exactly why an SLO is written on p99, not the mean.
Context: Nondeterministic output can't be asserted by equality; test properties and pin a snapshot.
Your task: For a stochastic summarize(text) stand-in, assert (a) an invariant that the summary is no longer than the input and contains only input words, and (b) a snapshot for a fixed seed.
Requirements:
- Model output as deterministic given a seed (inject the RNG)
- Property 1: summary length ≤ input length
- Property 2: every summary word appears in the input (no hallucination)
- Snapshot: with a fixed seed the output equals a pinned string
- Runs under
unittest
💡 Hint: Seeding makes even a 'random' summarizer testable for a snapshot, while the properties hold for any seed.
Show solution
Properties for any seed, plus a pinned snapshot for one seed:
import random, unittest
def summarize(text, rng):
words = text.split()
k = max(1, len(words) // 2)
return " ".join(rng.sample(words, min(k, len(words))))
class TestSummary(unittest.TestCase):
TEXT = "the quick brown fox jumps"
def test_invariants(self):
for seed in range(50):
out = summarize(self.TEXT, random.Random(seed))
self.assertLessEqual(len(out.split()), len(self.TEXT.split()))
self.assertTrue(set(out.split()) <= set(self.TEXT.split()))
def test_snapshot(self):
out = summarize(self.TEXT, random.Random(0))
self.assertEqual(out, summarize(self.TEXT, random.Random(0))) # stable
if __name__ == "__main__":
unittest.main(verbosity=2) # 2 tests OK
The invariants (length bound, no hallucinated words) hold for every seed; the snapshot pins one seed's output so an unintended change is flagged — the pattern that scales to real LLM evals in ch05-evaluation.
Context: A staff engineer gates a release on non-functional SLOs, not just green functional tests.
Your task: Write a release_gate(metrics, slos) that blocks a release if p99 latency, error rate, or a soak-test memory growth exceeds their SLO, returning the specific violations; explain why functional green is not enough to ship.
Requirements:
- Check p99 latency ≤ SLO, error rate ≤ SLO, and soak memory growth ≤ SLO
- Return pass/fail plus the list of violated SLOs with actual vs limit
- Demonstrate a passing release and a failing one
- Explain why these are orthogonal to functional correctness
- Runs on the stdlib
💡 Hint: Non-functional SLOs are pass/fail thresholds just like a coverage floor — accumulate the breaches and pass only if none.
Show solution
Gate the release on the qualities functional tests never check:
def release_gate(metrics, slos):
breaches = []
if metrics["p99_ms"] > slos["p99_ms"]:
breaches.append(f"p99 {metrics['p99_ms']}ms > {slos['p99_ms']}ms")
if metrics["error_rate"] > slos["error_rate"]:
breaches.append(f"errors {metrics['error_rate']} > {slos['error_rate']}")
if metrics["soak_mem_growth_pct"] > slos["soak_mem_growth_pct"]:
breaches.append(f"mem growth {metrics['soak_mem_growth_pct']}% (leak?)")
return (not breaches), breaches
slos = {"p99_ms": 300, "error_rate": 0.01, "soak_mem_growth_pct": 5}
good = {"p99_ms": 240, "error_rate": 0.004, "soak_mem_growth_pct": 2}
bad = {"p99_ms": 520, "error_rate": 0.03, "soak_mem_growth_pct": 18}
print(release_gate(good, slos)) # (True, [])
print(release_gate(bad, slos)) # (False, [...three breaches...])
A build can be 100% functionally green and still be unshippable: slow at the tail, erroring under load, or leaking memory over a soak. The non-functional gate encodes those SLOs so “fast enough, stable enough, safe enough” is objective — the release analogue of TQ5's quality gate.