The Test Harness
Green tests that never mention the clock
Chapter 42 wrote down what a servo was expected to do beside what it actually did. Chapter 43 put the seam cases behind one runner so a broken handoff between two working modules stopped hiding. Both answer the same question: did it work. Neither says how long it took. For an assistant that has to answer a spoken question before the person asking gets bored, the second question fails first, and it fails quietly.
Picture the version you will actually live through. Someone adds a lookup to the reply path, and a turn that used to spend 40 microseconds inside her own orchestration now spends 40 milliseconds. Every assertion still passes. Three weeks later she feels sluggish, twenty commits are candidates, and none is obviously guilty. The number that would have convicted the right one existed on the day it landed, and nothing wrote it down.
So this chapter's rule: a test result is data, with a name, a verdict, a duration in milliseconds and a message, and the suite writes those results to a file so this morning's run can be compared against last week's. Forty lines of standard library, no framework, no plugins, and it runs in the time it takes Python to start.
None of this is an argument against pytest. When you want fixtures, parametrized
cases and a plugin that collects a thousand tests across a package, install it and
use it. This one exists for the other job: the fast pulse you run on every save and
on a single-board computer over ssh, where "add a framework and two plugins to
learn that a turn now takes 40 ms" is more moving parts than the question deserves.
The two coexist happily, with one wrinkle: run pytest in the same project and it
will decline to collect TestHarness and TestResult,
because their names start with Test and their constructors take
arguments. It says so in a warning, and it is the same rule chapter 43 worked
around.
Time it, catch it, keep going
# labs/test_harness.py
import time
from dataclasses import dataclass
@dataclass
class TestResult:
name: str
passed: bool
duration_ms: float
error: str = ""
class TestHarness:
def __init__(self, suite: str) -> None:
self.suite = suite
self.results: list[TestResult] = []
def test(self, func) -> TestResult:
start = time.perf_counter()
try:
func()
result = TestResult(func.__name__, True, (time.perf_counter() - start) * 1000)
except Exception as exc:
result = TestResult(func.__name__, False, (time.perf_counter() - start) * 1000, str(exc))
self.results.append(result)
flag = "PASS" if result.passed else "FAIL"
print(f" [{flag}] {result.name} ({result.duration_ms:.3f} ms)")
return result
def test_reply_is_never_blank() -> None:
assert "Oh. It's you again.".strip()
if __name__ == "__main__":
runner = TestHarness("voice loop")
runner.test(test_reply_is_never_blank)
$ uv run python labs/test_harness.py
[PASS] test_reply_is_never_blank (0.001 ms)
A test is a function that returns normally when things are fine and raises when
they are not, so assert already does the whole job and
TestHarness only has to catch what it throws.
except Exception is deliberate: it covers AssertionError
and anything else a test body can raise, while leaving
KeyboardInterrupt and SystemExit alone, because those
derive from BaseException and Ctrl+C should stop a run instead of
becoming a logged result. func.__name__ supplies the label, which is
why test takes a function object and not a name plus a lambda. Three
decimal places, because a passing test measured in single microseconds rounds to
zero at one place, and zeros cannot creep.
def summary(self) -> dict:
passed = sum(1 for r in self.results if r.passed)
return {"suite": self.suite, "total": len(self.results),
"passed": passed, "failed": len(self.results) - passed}
def test_history_pairs_up() -> None:
history = []
history.append({"role": "user", "content": "Hello GLaDOS"})
assert len(history) == 4, f"expected 4 messages, got {len(history)}"
if __name__ == "__main__":
runner = TestHarness("voice loop")
runner.test(test_reply_is_never_blank)
runner.test(test_history_pairs_up)
print(runner.summary())
$ uv run python labs/test_harness.py
[PASS] test_reply_is_never_blank (0.001 ms)
[FAIL] test_history_pairs_up (0.002 ms)
expected 4 messages, got 1
{'suite': 'voice loop', 'total': 2, 'passed': 1, 'failed': 1}
The broken test proves the one important job: the AssertionError
becomes a row, the run carries on, and summary() counts one of each. A
runner that let the first failure escape would hide every test after it, and those
are the ones you want on the day something breaks. Notice where the message came
from. A bare assert len(history) == 4 raises
AssertionError() with no arguments, and str() of that is
the empty string, so the report would record a failure with nothing to say about
it. The comma and the f-string put expected 4 messages, got 1 in the
error field. Write the message every time; you are writing it for
yourself at 11pm.
Point it at the loop she actually runs
from glados.core import GladOSCore
def fake_stt(path: str) -> str:
return "Hello GLaDOS"
def fake_llm(text: str, history: list[dict]) -> str:
return f"Oh. It's you again. ({len(history)} messages remembered)"
def fake_tts(text: str) -> None:
print(f"[SPEAKING] {text}")
def build_core() -> GladOSCore:
return GladOSCore(stt=fake_stt, llm=fake_llm, tts=fake_tts)
def test_turn_reports_success() -> None:
assert build_core().run_turn("glados/data/heard.wav") is True
def test_silence_ends_the_turn() -> None:
core = GladOSCore(stt=lambda path: " ", llm=fake_llm, tts=fake_tts)
assert core.run_turn("glados/data/heard.wav") is False
def test_history_pairs_up() -> None:
core = build_core()
core.run_turn("glados/data/heard.wav")
core.run_turn("glados/data/heard.wav")
assert len(core.history) == 4, f"expected 4 messages, got {len(core.history)}"
$ uv run python labs/test_harness.py
You: Hello GLaDOS
GLaDOS: Oh. It's you again. (0 messages remembered)
[SPEAKING] Oh. It's you again. (0 messages remembered)
[PASS] test_turn_reports_success (0.008 ms)
[PASS] test_silence_ends_the_turn (0.003 ms)
You: Hello GLaDOS
GLaDOS: Oh. It's you again. (0 messages remembered)
[SPEAKING] Oh. It's you again. (0 messages remembered)
You: Hello GLaDOS
GLaDOS: Oh. It's you again. (2 messages remembered)
[SPEAKING] Oh. It's you again. (2 messages remembered)
[PASS] test_history_pairs_up (0.007 ms)
Three real conversational turns ran through the real GladOSCore, and
the whole suite finished in under a fiftieth of a millisecond. That is possible
because of the constructor chapter 32 built: the core takes its listener, brain and
voice as arguments, so fake_stt, fake_llm and
fake_tts stand in for a transcription model, a language model and a
speech synthesizer, each returning a fixed string in microseconds. What gets
measured is her orchestration, the turn logic you keep editing, with none of the
model load time that would drown it. Swap a real engine back in and the same test
tells you what that engine costs. Two suites, one runner.
import contextlib
import io
def test(self, func, max_ms: float | None = None) -> TestResult:
start = time.perf_counter()
try:
with contextlib.redirect_stdout(io.StringIO()):
func()
elapsed = (time.perf_counter() - start) * 1000
if max_ms is not None and elapsed > max_ms:
result = TestResult(func.__name__, False, elapsed,
f"over budget: {elapsed:.1f} ms > {max_ms:.1f} ms")
else:
result = TestResult(func.__name__, True, elapsed)
except Exception as exc:
elapsed = (time.perf_counter() - start) * 1000
result = TestResult(func.__name__, False, elapsed, str(exc))
def slow_llm(text: str, history: list[dict]) -> str:
time.sleep(0.05) # a stage that got slower overnight
return fake_llm(text, history)
def test_turn_within_budget() -> None:
build_core().run_turn("glados/data/heard.wav")
def test_turn_within_budget_slow() -> None:
core = GladOSCore(stt=fake_stt, llm=slow_llm, tts=fake_tts)
core.run_turn("glados/data/heard.wav")
if __name__ == "__main__":
runner = TestHarness("voice loop")
runner.test(test_turn_within_budget, max_ms=5.0)
runner.test(test_turn_within_budget_slow, max_ms=5.0)
$ uv run python labs/test_harness.py
[PASS] test_turn_within_budget (0.013 ms)
[FAIL] test_turn_within_budget_slow (50.106 ms)
over budget: 50.1 ms > 5.0 ms
Two changes, both small. redirect_stdout swaps the process's standard
output for an in-memory buffer for the duration of the call, so everything the turn
prints lands in a string no one reads and the output goes back to one line per test;
test prints after the with block closes, so its own lines
survive. Then max_ms: a test that raises fails, and now a test that
returns correctly but takes longer than its budget fails too, on a number already in
hand. That is the regression from the top of the chapter, caught the day it landed
by a suite that ran in 50 milliseconds. Your figures will differ from these, and the
50 ms here is a sleep standing in for the change you did not mean to
make.
# labs/test_harness.py — full file
import contextlib
import io
import json
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from glados.core import GladOSCore
REPORT = Path("glados/data/test_harness.json")
@dataclass
class TestResult:
name: str
passed: bool
duration_ms: float
error: str = ""
class TestHarness:
def __init__(self, suite: str) -> None:
self.suite = suite
self.results: list[TestResult] = []
def test(self, func, max_ms: float | None = None) -> TestResult:
start = time.perf_counter()
try:
with contextlib.redirect_stdout(io.StringIO()):
func()
elapsed = (time.perf_counter() - start) * 1000
if max_ms is not None and elapsed > max_ms:
result = TestResult(func.__name__, False, elapsed,
f"over budget: {elapsed:.1f} ms > {max_ms:.1f} ms")
else:
result = TestResult(func.__name__, True, elapsed)
except Exception as exc:
elapsed = (time.perf_counter() - start) * 1000
result = TestResult(func.__name__, False, elapsed, str(exc))
self.results.append(result)
flag = "PASS" if result.passed else "FAIL"
print(f" [{flag}] {result.name} ({result.duration_ms:.3f} ms)")
if result.error:
print(f" {result.error}")
return result
def summary(self) -> dict:
passed = sum(1 for r in self.results if r.passed)
return {"suite": self.suite, "total": len(self.results),
"passed": passed, "failed": len(self.results) - passed}
def save(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
report = {
"summary": self.summary(),
"run_at": datetime.now().isoformat(timespec="seconds"),
"results": [
{"name": r.name, "passed": r.passed,
"duration_ms": round(r.duration_ms, 4), "error": r.error}
for r in self.results
],
}
path.write_text(json.dumps(report, indent=2))
def fake_stt(path: str) -> str:
return "Hello GLaDOS"
def fake_llm(text: str, history: list[dict]) -> str:
return f"Oh. It's you again. ({len(history)} messages remembered)"
def fake_tts(text: str) -> None:
print(f"[SPEAKING] {text}")
def build_core() -> GladOSCore:
return GladOSCore(stt=fake_stt, llm=fake_llm, tts=fake_tts)
def test_turn_reports_success() -> None:
assert build_core().run_turn("glados/data/heard.wav") is True
def test_silence_ends_the_turn() -> None:
core = GladOSCore(stt=lambda path: " ", llm=fake_llm, tts=fake_tts)
assert core.run_turn("glados/data/heard.wav") is False
def test_history_pairs_up() -> None:
core = build_core()
core.run_turn("glados/data/heard.wav")
core.run_turn("glados/data/heard.wav")
assert len(core.history) == 4, f"expected 4 messages, got {len(core.history)}"
def test_turn_within_budget() -> None:
build_core().run_turn("glados/data/heard.wav")
def main() -> None:
runner = TestHarness("voice loop")
runner.test(test_turn_reports_success)
runner.test(test_silence_ends_the_turn)
runner.test(test_history_pairs_up)
runner.test(test_turn_within_budget, max_ms=5.0)
runner.save(REPORT)
print(runner.summary())
if __name__ == "__main__":
main()
$ uv run python labs/test_harness.py
[PASS] test_turn_reports_success (0.013 ms)
[PASS] test_silence_ends_the_turn (0.005 ms)
[PASS] test_history_pairs_up (0.009 ms)
[PASS] test_turn_within_budget (0.013 ms)
{'suite': 'voice loop', 'total': 4, 'passed': 4, 'failed': 0}
$ head -20 glados/data/test_harness.json
{
"summary": {
"suite": "voice loop",
"total": 4,
"passed": 4,
"failed": 0
},
"run_at": "2026-08-22T09:28:16",
"results": [
{
"name": "test_turn_reports_success",
"passed": true,
"duration_ms": 0.013,
"error": ""
},
{
"name": "test_silence_ends_the_turn",
"passed": true,
"duration_ms": 0.0054,
"error": ""
},
The file is the point of the exercise. A terminal scrolls; this does not.
round(r.duration_ms, 4) trims seventeen digits of floating-point noise
to something a human compares at a glance while keeping microsecond resolution, so a
test drifting from 0.0054 to 0.0090 shows up. timespec="seconds" stops
the timestamp carrying microseconds no one will read. And
mkdir(parents=True, exist_ok=True) is the habit from chapter 31: create
the folder if it is missing, say nothing if it is there, so the script works the
first time and the fiftieth.
Why this works: two exits and one honest clock
All of it rests on one fact about Python: a raised exception unwinds the stack until
something catches it. The try block is that catcher, so the two ways any
function can end map onto two branches. Return normally and you get
TestResult(passed=True). Raise anything at all and the
except branch records the failure, keeps the message, and lets the run
continue. The budget check adds a third verdict with no third exit, because the
duration is already in a local variable by then. The pattern generalizes past testing:
turn "it blew up" into a value and the caller decides what happens next, instead of
the stack deciding for it.
The clock matters as much as the catch. time.perf_counter() is monotonic
and high-resolution: it counts from an arbitrary starting point that never moves
backwards, so an NTP correction landing mid-test cannot produce a negative duration
the way time.time() can. Sampling it in both branches means even a failed
test reports how long it ran before it broke, which tells you whether it died
instantly or timed out waiting on something.
Chapter 28 timed things too, with a @timed decorator that wrapped a
stage and logged its duration. Same clock, different owner of the number. The
decorator lives inside the production path permanently and reports to the log, for you
watching a live session. TestHarness stays outside the code it measures
and hands the duration back as a value the caller can assert on, store and compare.
Instrumentation watches; a harness judges.
The harness wants a function, not a function's result. Two characters separate those:
runner.test(test_turn_reports_success()) # called it: passes None, not the function
$ uv run python labs/test_harness.py
You: Hello GLaDOS
GLaDOS: Oh. It's you again. (0 messages remembered)
[SPEAKING] Oh. It's you again. (0 messages remembered)
Traceback (most recent call last):
File "/home/you/GladOS/labs/test_harness.py", line 32, in test
func()
~~~~^^
TypeError: 'NoneType' object is not callable
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/home/you/GladOS/labs/test_harness.py", line 106, in main
runner.test(test_turn_reports_success())
~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/you/GladOS/labs/test_harness.py", line 41, in test
result = TestResult(func.__name__, False, elapsed, str(exc))
^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute '__name__'
Read it from the top and the sequence comes apart cleanly. The turn's own output
appears first, before any PASS or FAIL line, because the test ran at the call site
while Python was still evaluating the argument; the redirect never got a chance. The
function returned None, so func is None and
func() raised TypeError, which was caught exactly as
designed. Then the except branch reached for
func.__name__ to label the result, None has no such
attribute, and a second exception escaped from inside the handler and took the run
down. "During handling of the above exception, another exception occurred" means the
crash you are reading is a bug in the error path: read the first traceback for the
cause, the second for where the recovery went wrong. Delete the parentheses and it
works. The lesson underneath is that error paths run at the worst possible moment
and are the least tested lines in any codebase.
Checkpoint, and reactions that need a net under them
- I can explain why
except Exceptionand not a bareexceptintest, and what still gets through it on purpose. - I can say what
str()of a bareAssertionErrorgives, and what putting a comma and a message after an assertion changes in the report. - I can name two properties of
perf_counterthattime.timelacks, and what goes wrong in a duration when a clock is corrected mid-run. - I can point at the constructor argument that lets a test drive a full turn with no model on the machine, and say what the resulting duration is measuring.
- Handed a suite that dies with two stacked tracebacks, I can tell which one names the original fault and which one names the broken handler.
Exercise 1 — a test per case. Add
test_many(func, cases) that runs func once per case and
records each as its own result, labelled with the case, so one bad input does not
hide behind five good ones.
Pull the timing body out into a private _record(name, func) so
test and test_many share it, then loop:
self._record(f"{func.__name__}[{case!r}]", lambda c=case: func(c)).
The c=case default argument is the part that matters. A plain
lambda: func(case) captures the variable, not its value, so every
queued call would run with whatever case held last, and all three
results would report the same input. Run it over
["hello", "glados", ""] against a check that the transcript is
non-empty and you should see two PASS rows and one FAIL row naming the empty
string.
Exercise 2 — make the exit code mean something. Have
main() exit non-zero when anything failed, then run the suite from a
shell and print $?.
Two lines: import sys at the top, then
sys.exit(1 if runner.summary()["failed"] else 0) at the end of
main. With every test passing echo $? prints
0; break one and it prints 1. That number is the whole
interface between your suite and anything that automates it: a git hook, a
Makefile target, a job on a build machine. A suite that always exits zero can be
run automatically and ignored automatically.
Exercise 3 — compare two runs. Save each report under a
timestamped filename, then write compare.py that loads two of them and
prints the per-test change in milliseconds.
Name the files with datetime.now().strftime("%Y%m%dT%H%M%S") so
they sort chronologically, build a dict from name to duration_ms for
each report, and walk the keys they share. Print
f"{name:32} {old:8.4f} -> {new:8.4f} {new - old:+.4f}" for a
column of signed deltas. Now insert time.sleep(0.002) into
fake_llm and rerun: the turn tests jump by about two milliseconds
and the silence test, which never reaches the brain, does not move. The deltas
point at the stage, not just at the suite.
There is a net under her now: change the turn logic, run four tests in a blink, and see both correctness and cost before you commit. The next thing to change is how she decides what to do at all. Her reactions still live in an if/elif chain that grows a branch every time she learns a trick, and that chain cannot say which reaction wins when two of them fire on the same event. Chapter 46 moves those reactions into data with a priority on each, and the tests you just built are what make that rewrite safe.