A chat bot I run follows sports scores. One evening someone asked why there
hadn’t been any updates from a team they follow, and the answer turned out to be
sitting in the runlog: thirteen consecutive polls, thirteen errors, all reading
no game matched, every five minutes for an hour. Two other follows in the same
state file were running clean.
The cause was one line. A scoreboard fetch took a league argument with a
default, and the follow loop was calling it bare:
games = fetch_scoreboard() # defaults to one specific leagueSo every non-default follow was fetching the wrong league’s scoreboard and searching it for its own team. One of the working follows worked by accident — its league happened to be the default.
Easy fix. And this is where the interesting part starts.
One cause, four sites, one already fixed
I grepped for other bare calls before writing the fix, and found four call sites in three modules. One of them had already been fixed, months earlier, with a fan-out helper that queries every league and merges the results. Somebody — me — had understood this bug completely, built the right abstraction for it, applied it to the site in front of them, and moved on.
The three survivors produced a symptom pattern that had been visible from the user’s chair for weeks and that nobody had connected:
- a follow for a team in a non-default league silently matched nothing, 13 out of 13;
- asking for that team’s card by name worked — the named path already fanned out;
- asking
card?contextually in the same room did not — the contextual resolver was still bare.
Same room, same team, same minute, two different answers. That split was the only real signal, and it lived in the difference between paths, not in any one path’s logs.
So: understanding the cause was never the problem. Applying it everywhere was. The previous fix proved the cause was understood, and it did not prevent the recurrence at all.
Why the obvious regression test is the bug
The instinct after a fix like this is to write a test that pins the reported behaviour: “the follow loop passes a league argument”. It is a fine test. It passes. It will keep passing.
And it is the bug, written down and made permanent.
The thing that went wrong was fixing one member of a class. A test that covers one member of the class is that same mistake in test form. It creates exactly the feeling of safety that let the first fix stop at one site. Next year, when someone adds a fifth call site, the test stays green.
So the regression test I actually wrote asserts something else:
for mod in (module_a, module_b, module_c):
source = strip_comments(inspect.getsource(mod))
assert "fetch_scoreboard()" not in source, \
f"{mod.__name__} has a bare fetch_scoreboard() call"No bare call exists anywhere. That assertion would have caught all three survivors on the day the first one was fixed — months before the bug was reported — and it will catch the fifth when someone adds it.
When to reach for a class assertion
The trigger is specific and easy to recognise: the fix is “call the helper”.
Whenever a bug is repaired by routing a call through something that already existed — a wrapper, a sanitiser, a fan-out, a retry policy, a permission check, a path-normalising function — the interesting question is never “is this site fixed?”. It is “how many other sites are there?”. You already have to run the grep to answer that. Make the grep the test.
Yes, this is a source-level assertion, and source-level assertions are usually a smell — they couple tests to formatting, they break on refactors, they test the letter rather than the behaviour. That objection is right in general and wrong here, because of what the invariant is about. The claim “this dangerous default is never taken implicitly” is genuinely a claim about the source text. A behavioural test can assert that a given call is correct. No behavioural test can express “and nowhere else.” The universal quantifier is the thing you need, and the source is the only place it lives.
A few practical notes from using these for a while. Strip comments and strings
first, or a docstring showing the bad pattern will fail the test. Enumerate the
modules explicitly rather than walking the package, so that a new module has
to be consciously added — an auto-discovering version silently stops covering
things it can’t import. And phrase the failure message as the instruction, not
the observation: “call fetch_scoreboard(league=…) or the fan-out helper” saves
the next person a git-blame.
Always prove the test fails first
This is the step people skip, and skipping it converts the whole exercise into theatre.
A class assertion that is silently vacuous is worse than no test at all,
because it reads as coverage. A typo in the grep string, a module that isn’t
imported, a strip_comments that ate the whole file — every one of those
produces a green test that asserts precisely nothing, and a green test is a
closed question.
So before committing: restore the unfixed sources, run the test, confirm it fails, and count the failures. When I did that on this fix it reported seven failing assertions across the two bugs I was closing. Without that step, I would have had no way to distinguish “the code is clean” from “the test can’t see the code”.
It is the same discipline as deliberately breaking a guard to check that the guard notices — which, the one time I bothered, found two bugs in the guard.
The rule: if the fix is “call the helper”, the test is “nothing calls it bare” — and prove that test fails before you trust it green.
sources: concepts/the-test-must-assert-the-class-not-the-case,
concepts/a-plausible-negative-hides-the-wrong-question