AI EngineeringZero to ProductionHome·About·Contact
Software Testing · Chapter TQ8

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.

⏱️ ~3 hours🧪 6 labs🎯 Beginner→Industry

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.
▶ Runnable companionAll Python here runs on the standard library — 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:

DoubleWhat it doesUse whenVerifies
Dummyfills a parameter, never useda required arg is irrelevant to the testnothing
Stubreturns canned answersyou need to drive the code down a pathstate (indirectly)
Spya stub that also records callsyou want to assert how it was called, after the factinteraction
Mockpre-programmed with expectations, self-verifyingthe interaction is the behaviourinteraction (fails if unmet)
Fakea real, lighter implementationyou 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.

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 · all five doubles, one collaborator (runs)
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 nondeterminismSymptomFix
Time / clockfails at midnight, timezone edgesinject a clock / freeze time
Randomnessfails ~1 in N runsseed the RNG, or inject it
Network / external servicefails when the network hiccupsstub/fake the dependency
Concurrency / racefails under load or reorderingremove shared state; deterministic scheduling
Order dependencepasses alone, fails in the suiteisolate: fresh state per test (setUp)
Unordered collectionsdict/set iteration order assumedsort before asserting
Python · flaky vs fixed, deterministic (runs)
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.

Quarantine, then fix — never ignoreThe correct response to a flaky test is to quarantine it (mark it, stop it blocking merges) and fix it immediately, or delete it. What you must never do is add a retry-until-green loop: that hides real intermittent bugs and destroys the signal that red means broken.

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.

Python · latency percentiles & throughput (runs)
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.)

The real tools (need the lib)You generate the load with Locust (Python, 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:

Load Stress Soak Spike
TestQuestion it answersHow
Loaddoes it meet SLOs at expected traffic?ramp to target RPS, hold, measure p99
Stresswhere 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
Spikedoes a sudden surge recover?instant jump in traffic; measure recovery

Two more non-functional dimensions round out a professional suite:

DimensionWhat you testTooling
Securityinjection, 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 labelsaxe-core, Lighthouse, pa11y
Shift security leftThe cheapest security testing is automated and in CI: dependency scanning (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:

ApproachWhat you assertBorrowed from
Property / invariantgrounding, format, no PII, monotonicityproperty & metamorphic testing (TQ7)
Snapshot / goldenoutput matches a pinned reference until intentionally updatedregression testing
Rubric / allow-listoutput is one of an accepted set of shapesacceptance testing
LLM-as-judgea grader model scores against criteriach05-evaluation
Python · property + snapshot + rubric on a stochastic output (runs)
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.

This closes the trackYou now have the full toolkit: functional tests (TQ1–5), coverage & design theory (TQ6), generative techniques (TQ7), and the doubles, non-functional, and nondeterministic-system methods here. The capstone applies them to a real module.

✓ 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

✓ 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
Latency is a right-skewed distribution: a few slow requests (the tail) drag the mean up to 37.5 ms even though a typical request (p50) takes 20 ms — so the mean describes no real user and hides the tail that users actually feel. p99 (“99% of requests are faster than X”) captures that tail, which is why SLOs target it. Latency is the time for one request; throughput is requests completed per unit time — different axes, and optimizing one (e.g. batching for throughput) can worsen the other (tail latency).
✓ Knowledge check

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
The first uses a stub for the clock (canned 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.

Exercise 1 · Stub a dependency to drive a pathBeginner

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_hours takes a clock object with an hour() 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.

Exercise 2 · Spy vs mock on a collaboratorIntermediate

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 charge call'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.

Exercise 3 · Kill a flaky testAdvanced

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.

Exercise 4 · Latency percentiles from raw samplesExpert

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.

Exercise 5 · Property + snapshot test for an LLM-like functionProfessional

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.

Exercise 6 · A non-functional release gateIndustry scenario

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.

© 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