testing

We broke our scripts 51 times. The real bugs were where we didn't look

51 hand-picked mutants against our own tooling scripts: 4 survived, each naming a missing test. Both real bugs that night hid in wiring we never mutated.

A floor lamp lighting one patch of wall in an otherwise dark room
Photograph by Ritupon Baishya on Unsplash

A test that passes the moment you write it is unproven. So on 2026-09-18 we spent a night breaking our own scripts one line at a time, to find out which tests could actually fail.

That night a web-side agent and a service-side agent moved Hearso's live trivia rooms onto a new Rust WebSocket service. A handful of small scripts decided whether that was going well: a health checker, a bot workload, a database invariants reader, and a runner judging a two-hour soak against seven numbered conditions.

Those scripts were the only thing between "the system is fine" and "nobody measured", and one had already fooled us. An Uptime Kuma checker wrote examined: 0 and exit 1 for three different causes: Kuma down, Kuma unreachable, or its credentials missing. Five coordinator runs went by before we could tell them apart.

So I wanted to know: were the rest of these scripts genuinely tested, or just accompanied by tests?

We hand-picked 51 mutants across three scripts, one deliberate change each, and ran the suite against every one.

Short answer: 47 died on the first pass, and the 4 that survived each named a test nobody had written. One more survivor, on another file earlier that day, named a line that shouldn't exist.

The catch is where it didn't help. Both real bugs of the night were in the thin wiring layer we had exempted as too simple to mutate.

A mutant is a smoke test for your tests

A smoke alarm that's never gone off isn't a working smoke alarm; it's an object on a ceiling. You hold something smoky under it and see what happens.

Precisely: a mutant is one deliberate change to the code under test: an operator flipped, a guard deleted, a constant moved by one. Run the suite. If a test goes red, the mutant is killed and that test is proven to hold that behaviour. If everything stays green, the mutant survived, and it has named a behaviour nothing checks.

Here's a real example. In our Kuma checker, up = values.count(1) became up = values.count(1) + values.count(2), so "pending" counted as up. One test went red, so that mutant died and the behaviour is pinned.

It mattered because these scripts decided whether a two-hour soak passed, and a green nobody can prove wrong isn't evidence.

Flow diagram: write a test, change one line of the code, run the suite; a red test means the mutant is killed, an all-green suite means the mutant survived and names a missing test or a dead line.

Figure 1. The loop we ran by hand on every new test. Of 51 mutants across three scripts on 2026-09-18 (UTC), 4 took the lower branch.

We picked every mutant by hand rather than generating them. These were files of a few hundred lines, and the mutants worth running were the ones you could name in a sentence.

One mutant, end to end

The replacement Kuma checker reads Kuma's Prometheus endpoint over plain HTTP and should be green only when every monitor is UP. Kuma encodes status as 1 UP, 0 DOWN, 2 PENDING, 3 MAINTENANCE.

# the code
up = values.count(1)
return (0 if values and up == len(values) else 1), {"examined": len(values), ...}

# the mutant: PENDING quietly counted as UP
up = values.count(1) + values.count(2)
# the test that kills it
def test_pending_is_not_up(self):
    code, ev = check.verdict(ENV, answers(200, row("a", 1) + row("b", 2)))
    self.assertEqual((code, ev["metrics"]["pending"]), (1, 1))

One line changed, one test red, mutant dead. The checker has 11 tests and took 8 mutants, and all 8 died:

  1. pending counted as up;
  2. an empty Kuma reported green;
  3. missing credentials reported red instead of "not configured";
  4. a 401 parsed as a 200;
  5. every metric row counted as a monitor;
  6. examined floored at 1;
  7. the configured Kuma address ignored;
  8. http_status dropped.

The count isn't the point. The point is that the checker now gives a different reading for each state it can meet, where the old one gave one reading for three causes:

StateExit codeEvidence
Real credentials, all monitors up0examined 7
Wrong password1http_status 401
No credentials125not configured
A URL nobody serves1http_status 0

What 51 mutants caught: four missing tests

Stacked bar chart: 8 of 8 Kuma mutants were killed at once, 3 of the soak runner's 29 mutants survived the first pass, and 1 of the progress-entry fix's 14 survived.

Figure 2. Every mutant run by hand against three tooling scripts on 2026-09-18 (UTC): 51 in total, 4 of which survived their first pass.

ScriptTestsMutantsSurvived at firstWhere
Kuma checker, plain HTTP1180scratch, offered to the other side
Soak runner, first landing85293commit d700c5ff
Lock adoption (a later change, outside the 51)941 named0commit 0b117b61
Progress-entry fix153141commit fe01e001

The soak runner's three survivors are the whole argument, because each named a test nobody had written:

  • -1 passing a zero-condition. The runner marks an unmeasured number -1. Letting -1 satisfy a "must be zero" condition broke no test, which is a soak passing by never measuring.
  • Memory from the first sample instead of minute 15. The rule is RSS growth under 10% from minute 15, to skip warm-up. Measuring from minute 0 still produced a number, and everything stayed green.
  • No preflight. Removing the refusal to start (before the lock, before four GREEN runs exist, before the service answers) broke nothing.

Each got its test before the file landed. The lock-adoption change had one mutant worth naming, and it died: a birth time that's nearly right must not be accepted.

The progress-entry fix had one survivor, also given a test: a title that could say "healthy so far" above a list of named reasons it wasn't.

Bar chart: the soak runner's test file grew from 71 tests to 85, 94 and 153 as mutants named behaviours nothing was checking.

Figure 3. Tests passing on the soak runner at four points on 2026-09-18 (UTC): 71 as handed over, 85 landed, 94 after lock adoption, 153 in the progress-entry fix (commit fe01e001).

Figure 3 is one file growing: 71 tests as a delegated sub-agent handed it over, 85 when it landed after the mutant sweep, 94 with lock adoption, and 153 in the progress-entry fix. Not all 14 tests between 71 and 85 came from survivors: three did, by name, and the rest from the same review pass.

What it cost: about a minute of machine time

Less than it sounds, and I'm not going to invent a number for the part nobody timed.

The landing window stamped itself. It held the shared lock for seven seconds, from 18:25:16Z to 18:25:23Z. Inside it, the 85-test file ran in about 1 second (red at 18:25:17Z, green at 18:25:18Z), and the whole 770-test scripts suite in about 3 seconds (18:25:18Z to 18:25:21Z). At roughly a second a run, 29 mutants is under a minute of machine time.

The real cost is human: choosing a mutant worth running, and writing the test that kills it. That wasn't measured, so it isn't reported.

Where it didn't help

That is what a minute of machine time bought. This is what it did not buy, and it is the more useful half.

Hand-picked mutants aren't coverage

We chose mutants we could name, so we tested the properties we already had in mind. Eight mutants against a 40-line checker is a good afternoon, not a proof the checker is right.

Some mutants can't be killed, and that's a finding too

An equivalent mutant changes the code without changing its behaviour, so no test can catch it. Ours was on the browser's protocol codec: accepting a JSON list where a plain object is required broke nothing, because a list carries no v claim and is refused a step earlier anyway.

The guard was dead code, so we deleted it and retargeted the test at JSON null, which does throw without it. A survivor isn't always a missing test; sometimes it's a line that shouldn't exist.

The thin wiring stayed untested, and that's where the real bugs were

A test seam is the split between logic you can call with fake inputs and the wiring that touches the world. Here it was pure exported functions, plus a realDeps() object holding the process, network and filesystem calls.

The seam decided where a mutant could reach, so it also decided where a bug could sit unvisited. The runner's notes admitted it: "realDeps() was never executed. By me or by the tests." Both of the night's real bugs were in there:

  • greenRunAlive() used pgrep -f <path>, which matches any process whose command line merely contains the path, our own monitoring shells included;
  • a missing signal handler let SIGINT skip the finally and leave the lock directory on disk.

A fully tested function can still be wrong about its job

The runner built its half-hourly progress entry by running the end verdict over a run still in progress. That function had tests, and they passed.

Nobody had read a mid-run entry the way a stranger under time pressure would. The minute-30 entry of a healthy soak (106 games, 0 violations, memory 12% below baseline) listed nine failed conditions, and the other side stopped the run at minute 38. It was right to, given what the entry said.

No mutant could have found this, because no line was wrong. The function did what its tests said, on a run it was never meant to judge.

A green from a tool that didn't look is worse than a red

In that same seven-second window our scratch lint reported 0 errors on the two files: it had ignored both as outside its base path. We re-ran eslint inside the repository and didn't count the first result.

Our issue tracker had already named the shape. A coverage threshold is "a check whose null case reads as success. An excluded path reports as covered by being absent" (hearso-web issue #250).

Predictions vs outcome

What we expectedWhat happenedRight?
The Kuma checker's tests pinned its behaviourAll 8 mutants died on the first passYes
A mutant sweep would be cheap enough to run on every new testAbout a second per run; 29 mutants in under a minute of machine timeYes
The soak runner's zero-conditions were coveredLetting -1 pass a zero-condition broke no testNo
Every survivor would mean a missing testOne was an equivalent mutant: the fix was deleting a dead guardNo
The thin I/O layer was too simple to need mutantsBoth real bugs of the night were in itNo
A tested verdict function would read correctly mid-runIts minute-30 entry printed nine failures over a healthy soakNo

Key takeaways

  • If a test passed on arrival, it's unproven. Change one line and make sure something goes red.
  • Treat a surviving mutant as the useful outcome. It names the test you didn't write, or the line you should delete.
  • Give each real failure its own reading. Three causes collapsing into one examined: 0 cost five coordinator runs.
  • Mutate the wiring too. Ours was exempted as "thin", and both real bugs were in it.
  • Remember what mutants can't tell you. They prove a test can fail, not that the pinned behaviour is the right one.

What I'd do next

Extract the wiring instead of exempting it. The progress-entry fix does that: coordinatorAlive(psText) and onStopSignal(emitter, cleanup, exit) are exported as pure functions so a mutant can reach them, leaving realDeps() as plumbing rather than a hiding place.

That fix waited for a while. It was prepared during the soak but held back, because the soak sampled the tree once a minute and any commit under a runtime path would have voided it.

Update, 2026-09-28: the fix landed later that night as commit fe01e001, after the final GREEN run; its message gives the same counts, 153 tests and 14 mutants.

I'd also keep the mutant list beside the file rather than in a scratch directory that dies with the session, and run the sweep as a command instead of a habit. It held for one night because two agents were checking each other, which is not a plan.

Our sibling repository already does this properly. online-leaderboard records a mutation score on every pull request: 91.05% (PR #46), 99.30% (PR #50), and 93.22% overall with the submit handler alone at 81.09% (PR #51). Each hearso-web pull request, meanwhile, states plainly "mutation: not measured in this repository" (PR #370).

That per-module split is our own lesson in someone else's numbers: one blanket score hides the module nobody attacked.

How we measured

The harness was twenty lines of Python: back up the file, apply one anchored replacement, run vitest (or the Python tests), restore, and diff to prove the restore. Every mutant was chosen by hand and run one at a time.

Timings come from the landing window's own stamps, not a benchmark. We didn't time the human part, and we didn't run a mutation tool, so there is no mutation score here to compare with anyone else's.

Evidence

All times UTC, all 2026-09-18.

ClaimSource
Kuma checker: 11 tests, 8 mutants, 8 killed; the eight named; five runs lostcutover log, web-side entry 17:16:02Z
Four states, four readings (exit 0/1/125; http_status 200/401/0)same entry; kuma/check-kuma-http.py, kuma/test_check_kuma_http.py
test_pending_is_not_up and the up = values.count(1) linekuma/test_check_kuma_http.py; kuma/check-kuma-http.py
Soak runner: 85 tests, 29 mutants, none surviving at landing; the three first-pass survivors namedcommit d700c5ff; cutover log, web-side entry 18:26:08Z
Lock adoption: 94 tests; the near-match mutant killedcommit 0b117b61
Progress-entry fix: 153 tests (94 before), 14 mutants, one survivor namedsoakrun2/commit3.txt; landed as commit fe01e001 (cutover log, web-side entry 22:15:20Z)
71 tests as handed over by the sub-agentsoakrun/NOTES.md, final line
1 second for 85 tests; 3 seconds for the 770-test scripts suitesoakrun/window.status, 18:25:17Z–18:25:21Z
The landing window: lock held 18:25:16Z to 18:25:23Z, 7 seconds; the scratch lint ignored both files and was not counted, eslint 0 inside the repositorycutover log, web-side entry 18:26:08Z
The harness: back up, one anchored replacement, run, restore, diffscratch files mutate.py, mutate2.py
Equivalent mutant: a JSON list accepted where an object is requiredcutover log, web-side entry 08:38:11Z
"realDeps() was never executed: by me or by the tests"soakrun/NOTES.md, "Unsure / not verified"
pgrep -f self-match, found by the other side at 19:14Zcutover log, web-side entry 19:31:35Z
No signal handler; SIGINT skipped finallycutover log, web-side entry 19:54:35Z; service-side entry 19:53:32Z
Minute-30 entry: nine failed conditions on 106 games, 0 violations, RSS 12% below baselinecutover log, web-side entries 19:44:43Z and 19:54:35Z
Scratch lint reported 0 errors on two files it had ignoredcutover log, web-side entry 18:26:08Z
coordinatorAlive and onStopSignal exported so they can be testedsoakrun2/NOTES.md; commit fe01e001
"A check whose null case reads as success" (coverage denominator)hearso-web issue #250, closed 2026-09-06
Sibling mutation scores 91.05%, 99.30%, 93.22% (submit handler 81.09%)online-leaderboard PRs #46, #50, #51
"mutation: not measured in this repository" on hearso-web PRshearso-web PR #370, merged 2026-09-09

Get the next one

We write these up when something is worth writing up: roughly once a month, never on a schedule. Every number in them comes from a run we can point at.

One email when there is something to read. Unsubscribe in a click.

HearsoHEARSO · LOADING

Loading, 0%

Never goes backwards. Never lies about being done. Under a second on a good day.

ASSETS · STATE · HANDSHAKE
Help improve Hearso

With your permission, we measure how you use Hearso (pages, buttons, your display name, your squad's name and your games) and record sessions and click heatmaps of how pages are used. We never send your first name, last name, email, phone number or passwords to analytics, and recordings hide them. You can change this in Settings after signing in. Hearso also keeps anonymous totals of rounds, players, and live rooms without this permission; see the player guide.