AI EngineeringZero to ProductionHome·About·What’s new·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)

Start with the argument you've probably had in a code review. Someone writes a test, calls every stand-in object a "mock," and asserts a returned value. A colleague objects: "That's not a mock, that's a stub — and you shouldn't be mocking there at all." The words fly, nobody agrees on what they mean, and the review stalls. The frustrating part is that both people are half right: they're using one word — "mock" — for five genuinely different tools, each with a different job. This section gives you the exact vocabulary so those arguments end in ten seconds.

Here's the analogy that makes the five click. Imagine you're rehearsing a play, but the lead actor hasn't arrived. You need stand-ins so rehearsal can continue — and which kind of stand-in you use depends on what the scene needs. A cardboard cutout just fills the spot so the stage isn't empty (nobody looks at it): that's a dummy. A stand-in who reads scripted lines off a card so the scene can move forward is a stub — it feeds canned answers. A stand-in who reads the lines and quietly writes down "the director asked me to enter twice" is a spy — it answers and records what happened for you to check afterward. A stand-in who was told in advance exactly which lines they must be given and storms off if the director gets it wrong is a mock — it carries its expectations and fails itself when they're not met. And a junior actor who actually performs a simplified version of the whole role is a fake — real working behaviour, just not production-grade (think an in-memory dictionary standing in for a database).

The single distinction worth burning into memory is what each one lets you assert. A dummy verifies nothing. A stub and a fake let you check state — the result the code produced or the data it stored. A spy and a mock let you check behaviour — how the code called its collaborator (was send called once, with these arguments?). That is the whole taxonomy in one sentence: state doubles (stub, fake) versus behaviour doubles (spy, mock), with the dummy as the do-nothing placeholder. The common mistake this whole framing exists to prevent is reaching for a mock by reflex: mocks assert the exact shape of your internal calls, so they couple the test to the implementation. Rename a method or reorder two calls and a mock-heavy suite goes red even though nothing broke. That's why the professional default is "prefer stubs and fakes, verify state, and reach for a spy or mock only when the interaction genuinely is the behaviour you're testing" — for example, that a payment gateway was charged exactly once.

“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.

five test doubles · grouped by what they let you assert test double verifies nothing state verification behaviour verification dummy stub fake spy mock a placeholder assert the result / stored data assert how it was called prefer the middle group; reach right only when the interaction IS the behaviour Two questions, five tools. A double either lets you assert state (stub, fake) or behaviour (spy, mock); the dummy asserts nothing. Knowing which group you're in tells you what your test is actually checking.
🗺️ How to read this diagram

Read it top-down: the one idea "test double" splits into three columns, and each column names the concrete doubles that live in it.

  • The top box is the umbrella term — any stand-in for a real collaborator. The blue arrows fan out to the three kinds.
  • The left grey column holds the dummy: it only fills a parameter slot, so it verifies nothing. The middle green column (stub, fake) lets you assert state — the value returned or the data stored. The right blue column (spy, mock) lets you assert behaviour — that a collaborator was called a certain way.
  • The payoff line at the bottom: prefer the green (state) doubles; only move to the blue (behaviour) ones when the interaction itself is the thing under test — because behaviour doubles couple the test to the implementation.

In short: Pick the column first (state vs behaviour), then the double — that single choice decides what your test can prove and how brittle it is.

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

Picture the CI dashboard on a Tuesday morning. A build failed overnight. You open it, see one red test, sigh, and click "re-run." It goes green. You merge. Nobody investigates. Do this enough times and something quietly catastrophic happens to your team: red stops meaning broken. The next time a test goes red because of a genuine, shipping-blocking bug, the reflex is the same — re-run, it might pass — and the bug sails into production behind a test suite everyone has learned to distrust. That test, the one that passes and fails on identical code, is called flaky, and it is the most corrosive smell in the whole discipline precisely because its damage is social, not technical: it erodes the one thing tests are for, a trustworthy signal.

So where does flakiness come from? It is never magic. A test is a function; run it twice on the same code and it must give the same answer — unless something outside the code changed between the runs. That "something" is nondeterminism, and it leaks in through a small, knowable set of doors. The clock (a test that only fails at midnight, or across a daylight-savings boundary). Randomness (a test that fails roughly one run in fifty because it rolled the unlucky value). The network or an external service (fine until a DNS blip). Concurrency (two threads racing for the same variable, order decided by the OS scheduler). Order dependence (test B passes alone but fails after test A left global state dirty). And iteration over unordered collections (asserting a specific order out of a set or, historically, a dict).

The unifying insight — and the reason the table below has a fix for every row — is this: a reliable test must own every source of nondeterminism it touches. It cannot read the ambient clock, the global random generator, the live network, or shared mutable state; it must be handed a controlled version of each. The move that achieves this is the same dependency injection you saw for the clock in TQ2 and for the network via stubs in TQ3: push the uncontrollable thing in as a parameter so the test can pin it. Seed the RNG, freeze the time, stub the service, give each test fresh state in setUp, sort before you assert. The mistake to name and avoid is the tempting shortcut — wrapping a flaky test in a "retry up to three times" loop. That doesn't fix nondeterminism; it hides it, and worse, it silences the exact intermittent failures (a real race condition, a genuine 1-in-50 bug) that flakiness is often trying to warn you about. Quarantine and fix, or delete — never paper over.

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
flaky · reads the ambient world clock · RNG network · shared state test pass fail same code, two different answers fixed · inject a controlled source seeded RNGfrozen clock same answer test test owns the source → repeatable retry-until-green: hides the bug inject & control: fixes the cause Flakiness = uncontrolled nondeterminism. On the left the test reads the ambient clock/RNG/network, so identical code passes one run and fails the next. On the right the source is injected and pinned, so both runs agree. A retry loop only hides the left case.
🗺️ How to read this diagram

Compare the two halves left-to-right: the left panel is the flaky test, the right panel is the same test after the fix.

  • On the left, grey arrows feed the ambient world (clock, RNG, network, shared state) straight into the test. Because those change between runs, the same code lands on pass once and fail the next — that fork is the flakiness.
  • On the right, the uncontrolled source is replaced by a seeded RNG / frozen clock that the test hands in itself (blue arrow). Now the test always reaches the same answer, so it is repeatable.
  • The bottom bar is the moral: a red "retry-until-green" loop only hides the left-hand fork, while the green "inject & control" move removes the nondeterminism at its source.

In short: A test that reads the ambient world is flaky; a test that is handed a pinned version of every uncontrolled input is repeatable — never paper over the difference with retries.

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

Here's the incident that teaches this section. Your service ships. The dashboard says "average response time: 40 ms" — green, well under target, everyone relaxes. Then support tickets trickle in: "the app freezes sometimes." Sometimes. You can't reproduce it, the average is fine, and yet a real slice of users is furious. What's happening is that the average is lying to you. It quietly averages away the handful of requests that took 900 ms, and those requests aren't noise — they're specific users, on specific page loads, having a genuinely bad time. The mean is a single number pretending to describe thousands of very different experiences.

To see why, stop thinking of latency as "a number" and start thinking of it as a distribution — a whole crowd of measurements with a shape. And the shape of real latency is almost always right-skewed: a big pile of fast requests near the median, with a long thin tail of slow ones stretching out to the right (a GC pause, a cold cache, a retry, a noisy neighbour). Because that tail is long, it drags the mean upward and to the right of where most requests actually live — so the mean sits in an empty gap describing no real request. The honest way to describe such a distribution is with percentiles. "p50" (the median) is the value half your requests beat — the typical experience. "p99" is the value 99% of requests beat — which is another way of saying it's how slow your worst 1% are. That worst 1% is not trivia: on a page that makes 100 backend calls, nearly every page load hits someone's p99. This is why real Service Level Objectives are written as "p99 < 300 ms," never "mean < 50 ms."

One more distinction the lab makes concrete, because people conflate them: latency and throughput are different axes, not two words for speed. Latency is how long one request takes (a duration, measured per request). Throughput is how many requests complete per second (a rate, measured across the system). They can even move in opposite directions — batching several requests together to raise throughput often makes each individual request wait longer, worsening tail latency. Optimising one can quietly cost you the other, so a serious performance report always states both, and states latency as a percentile spread rather than a single seductive average.

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.

latency per request (ms) → # requests → p50 mean p99 the tail users feel Latency is a distribution, not a number. A tall pile of fast requests sits at p50 (20 ms in the lab); a long thin tail of slow ones drags the mean right of the median and pushes p99 far out — which is exactly why SLOs target p99.
🗺️ How to read this diagram

Scan left-to-right: fast requests are on the left, slow ones on the right, and the height of each bar is how many requests landed at that speed.

  • The tall blue bars on the left are the big pile of typical, fast requests; the green dashed line marks p50 (the median) sitting right in that pile — the experience of a normal user.
  • The bars thin out to the right into a long amber-then-red tail — the occasional slow request (a GC pause, cold cache, retry). Because that tail is long, the grey "mean" line is dragged to the right of the median, landing in an almost-empty gap that describes no real request.
  • The red dashed p99 line out in the tail is the payoff: it captures how slow your worst 1% are — the requests users actually complain about — which no single average can express.

In short: The mean hides in the gap between the pile and the tail; p50 describes the typical user and p99 describes the tail they feel — so you gate on p99, not 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

"We load-tested it" is one of the most misleading sentences in engineering — because it usually means only one of four very different experiments was run, and the other three would have caught the outage. Teams say it, ship, and then get surprised by a memory leak that only shows after six hours, or a Black-Friday traffic spike the service never recovers from. The fix is to realise that "performance testing" isn't one activity; it's a family of tests, and each one asks a different question that the others can't answer.

Think of it like testing a bridge. Load testing asks "does it carry normal rush-hour traffic within spec?" — you ramp up to the expected number of cars, hold steady, and confirm p99 stays under the SLO. Stress testing asks the opposite: "at what weight does it actually break, and does it crack gracefully or collapse all at once?" — you push past capacity on purpose to find the failure point and watch how it degrades (a system that sheds load politely is far better than one that cascades into a crash). Soak testing (endurance) asks "what happens if traffic sits on it for hours or days?" — this is the only one that surfaces slow poisons like memory leaks, growing file-handle counts, or log-disk exhaustion, because they need time, not intensity, to appear. And spike testing asks "if traffic jumps 10× in one second and then drops, does the system absorb it and recover?" — the autoscaling-and-recovery question that a smooth ramp never triggers. Same system, four questions; you need all four because passing one tells you nothing about the others.

Finally, "non-functional" is broader than speed. Two more dimensions separate a hobby project from a professional suite. Security testing probes the qualities an attacker cares about — injection, broken authentication and authorization, leaked secrets, known-vulnerable dependencies (CVEs), and malformed untrusted input (this is exactly where the fuzzing from TQ7 earns its keep). The cheapest, highest-leverage security work is automated and lives in CI: static analysis (SAST tools like Bandit or Semgrep read your source for dangerous patterns), dynamic analysis (DAST tools like OWASP ZAP attack the running app), and dependency scanning (pip-audit checks your installed packages against CVE databases) — manual penetration testing sits on top of that, not instead of it. Accessibility testing (a11y) checks that people using assistive technology can actually use the product, measured against the WCAG standard: sufficient colour contrast, full keyboard navigation, alt text on images, correct ARIA roles, screen-reader-friendly labels — verified with tools like axe-core, Lighthouse, or pa11y. Both dimensions are easy to skip and expensive to bolt on later, which is why the professional instinct is to shift them left — into automated CI checks — long before a release gate.

Performance testing splits into distinct goals, each answering a different question:

Load Stress Soak Spike
🗺️ How to read this diagram

Read the four boxes left-to-right, following the blue arrows that chain them in order.

  • The green "Load" box is the starting point — testing at expected traffic. The blue arrow then steps up to the amber "Stress" box, which pushes past capacity to find the breaking point.
  • The arrows keep flowing rightward through the blue "Soak" box (hold moderate load for a long time) and end at the red "Spike" box (a sudden surge). The colours escalate from calm green to alarm red as each test gets harsher.
  • The payoff is that these are four separate experiments in sequence, not one — each box asks a different question the others can't answer, which is why "we load-tested it" only covers the first box.

In short: Load, stress, soak, and spike are four distinct performance tests — same system, four different questions, and you need all four.

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

You've spent this whole track learning that a good test is deterministic — and now you have to test an LLM, which is deterministic's opposite. Ask the same model the same question twice and you get "The order id is 10432." one time and "I found order 10432 in your message." the next. Both are correct. But your entire testing toolkit is built on assertEqual(output, expected), and that assertion just went from a reliable guard to a coin flip — it fails on wording changes that aren't bugs. The naive reaction ("pin temperature to 0 and assert the exact string") is brittle theatre: it breaks on the next model version and tells you nothing about whether the output was actually good.

The mental shift that unlocks this is one you already made when you killed flaky tests: stop asserting the exact answer, and start asserting the properties a correct answer must have. You don't know the precise words the model will use, but you know things that must be true of any acceptable output — and those are testable even when the wording isn't. There are three complementary levels, each borrowed from a technique you've already met:

And when the answer is genuinely open-ended (a paragraph of prose, a plan, an explanation) and no property or shape captures "good," you escalate to LLM-as-judge: a second model scores the output against explicit criteria. That is the bridge out of this software-testing track and into ch05-evaluation, where eval datasets and grader models extend this exact idea — assert properties, not strings — to the messy, subjective outputs that make LLM systems so hard, and so interesting, to test.

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
LLM output wording varies assertEqual ✗ exact match is hopeless property / invariant grounded · format · no PII snapshot / golden diff forces a human look rubric / allow-list one of accepted shapes LLM-as-judge grader scores open text accountable without pinning words assert the properties a correct answer must have — not the exact string Test properties, not strings. Because the wording is stochastic, assertEqual fails on non-bugs. Instead route the output through invariants, a snapshot, a shape allow-list, and — for open-ended text — an LLM judge, each borrowed from an earlier technique.
🗺️ How to read this diagram

Follow the arrows left-to-right: the untestable output enters on the left, passes through a stack of checks in the middle, and comes out accountable on the right.

  • The left box is the LLM output whose wording changes run to run — so the red assertEqual ✗ underneath it is a coin flip, not a guard.
  • The middle green stack is the ladder of checks that do work: a property/invariant (e.g. any id must appear in the input — grounding), a snapshot that forces a human to confirm intended changes, and a rubric/allow-list of accepted shapes. Each is borrowed from a technique earlier in the track.
  • The blue box at the bottom of the stack is the escalation — LLM-as-judge — for genuinely open-ended prose no fixed shape can capture; the arrows converge on the right box: the output is now accountable even though no exact string was pinned.

In short: Swap "is the output this exact text?" for "does the output have the properties a correct answer must have?" — and escalate to a grader model only when no property or shape fits.

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