DEV Community

Sam Hartley
Sam Hartley

Posted on

The Test Wasn't Wrong. It Was Answering an Older Question.

The Test Wasn't Wrong. It Was Answering an Older Question.

I shipped a change I had reviewed, simulated, and deployed. An hour later my acceptance suite was red.

The obvious reading is that the change broke something. I've made that reading correct plenty of times, so I started where anyone starts: at the failure. And the failure was, in a narrow sense, true — an assertion had stopped holding. It just wasn't an assertion about the code as it existed that afternoon. It was an assertion about the code as it existed three weeks earlier, frozen into a file nobody had touched since.

This post is about that gap, because red is not one thing. In my suite — and in most suites I've seen — a red run can mean at least three very different things, and only one of them is "you broke it."

Three kinds of red

Here's the taxonomy I now use before I touch anything:

  1. A missing artefact. The suite opens a fixture by name and the file isn't there. The stack trace ends in FileNotFoundError naming the fixture, not a line of product code. This is an environment problem wearing a test failure's clothes.
  2. A stale expectation. The test asserts a concrete value or a concrete shape that the product used to produce. The product changed on purpose; the assertion didn't. The test is answering a question about a version of the code that no longer exists.
  3. A real regression. Behaviour that should still hold, doesn't.

All three show up as a red X with a line number. The difference lives in what the assertion says and where it dies. So the first thing I do now is not "find the bug" — it's classify. Which of the three am I looking at?

Classifying wrong costs the most when it costs the least to check. A FileNotFoundError takes thirty seconds to confirm. A stale expectation takes reading one file. A real regression takes an afternoon. But if you skip the first two checks because you "know" your change was fine, you'll eventually skip them on the day the third one is true.

The oracle that pinned a number

My suite has a behaviour oracle: a small module that calls the shipped functions and asserts specific outputs. It exists so that a refactor that changes behaviour without changing structure still fails. (The numbers below are stand-ins, not my actual defaults.)

That's a good idea with a sharp edge. The oracle held a literal from the previous version — a default parameter value that fed into a scoring function. When I changed that default deliberately (the old one was wrong for the data I now have), the oracle's assertion failed on correct output.

The failure message was honest: expected 0.20, got 0.35. Nothing in that message says "the expectation is three weeks old." The number just looks wrong, so the reflex is to look at the code that produced the new number.

What makes this class insidious is that the oracle is supposed to be rigid. I don't want a test that shrugs and says "close enough." I want it to pin behaviour so drift is visible. The cost of that rigidity is that every intentional behaviour change requires an explicit rebaseline of the expectation — and if you forget, you get red on green code.

The check that requires exactly one thing to change

The second red flag was structural. One of my integrity checks doesn't assert a value; it asserts a diff. Specifically: exactly one function may differ from a reference snapshot, and a short list of protected functions must be byte-identical.

That is a genuinely good guard against drive-by edits — the thing it catches is real. But it has an implicit assumption baked in: that history stopped at the snapshot. It doesn't know that two documented follow-up changes landed afterwards. Each of those changed additional functions, so the diff is now "one function plus the ones I meant to change," and the check says red.

changed = ['open_trade', 'score_signal', 'load_config']
expected = ['open_trade']
=> FAIL: protected_same=False
Enter fullscreen mode Exit fullscreen mode

Reading that output, "the change broke something" is a perfectly reasonable first hypothesis. It's also wrong. The message is not lying; it's comparing against a museum piece and reporting the difference.

The tell: the failure lists functions you know you changed on purpose, and a protected one that moved as a direct consequence. If every entry in the diff has a commit message, you're not looking at a regression. You're looking at a baseline that stopped being maintained.

The driver that called the old signature

The third one is my favourite, because it points at the wrong file.

The suite's own driver called a function and then indexed the result:

trade = scanner.open_trade(signal, size)
confidence = trade["confidence"]
Enter fullscreen mode Exit fullscreen mode

Then a gate I'd added made that function return None when the signal didn't qualify. Which is the correct behaviour — a gated signal shouldn't produce a trade. But now the driver does None["confidence"], and the traceback says:

TypeError: 'NoneType' object is not subscriptable
  at scanner.py line ...
Enter fullscreen mode Exit fullscreen mode

The traceback names the product file, so that's where people look. The actual defect is in the test: it assumes a call always returns an object. The test was written before the gate existed, and the gate made an impossible path possible.

The general shape: a test that encodes an old contract fails when the contract changes, and blames the callee. If the caller is the test, the fix belongs in the test. If the caller is production code, the fix belongs in production code. The traceback never tells you which — only the call site does.

The dangerous fix

Every one of these three failures has the same tempting remedy: make it green.

For the first two, "make it green" means editing the expectation. Sometimes that's right. But the version of it I've caught myself doing is worse: bending the product back into the shape the test expects. Reverting a correct gate because a caller crashes. Putting the old default back because an oracle asserted it. That's how a suite stops being a safety net and becomes a museum with an admission policy.

A test suite is a model of the behaviour you want. When the model and the code disagree, one of them is stale. Deciding which one is the entire job, and it's a judgement call that shouldn't be made by whoever is closest to the red X.

Rebaselining is a decision, not an edit

When all three flags trace to stale expectations, the correct action is a rebaseline. It's also the action that quietly makes your suite weaker, so I don't do it silently anymore.

The concrete loss in my case: the side-effect audit only inspects functions that appear in the diff. Fold a function into a new baseline and it drops out of the audit — including, if you're careless, a brand-new function that opens files or calls things it shouldn't. The suite goes green by forgetting to look. That's a real trade, and it belongs to the person who owns the risk, not to the agent who wants a clean run.

What I do instead:

  • Say what's being weakened. Not "rebaselined, all green" — but "these four assertions now describe the new behaviour, and this function is no longer audited."
  • Simulate the whole rebaseline on a copy. A partial simulation is worse than none: it can pass the cheap checks and miss the one that reads the live directory.
  • Move every anchor in one edit. A manifest here, a hardcoded digest there, a driver call somewhere else — fix one and the others still fail, and you'll spend an hour convinced the fix didn't work.
  • Stop the background writer. My suite has a hermetic check that requires certain files to be byte-identical before and after. A running service writes to them every few seconds, so the check reads zero — which looks exactly like a defect. Pause the writer for the run, then confirm it's back.
  • Re-run against the deployed artefact. Green against my working copy proves my copy is self-consistent. Green against the file that actually runs proves something about the machine.

What I check now, in order

  1. Read where it died, not just that it died. A file-not-found, a value mismatch, and a TypeError are three different investigations.
  2. Ask what the assertion is about. Does it describe behaviour I still want? If yes, the code is suspect. If it describes last month's behaviour, the test is.
  3. Check whether the test's caller is the test. Tracebacks blame the callee; contracts live at the call site.
  4. Never edit the oracle to get green without saying so. If I change an expectation, I write down what guarantee I just gave up.

Four things I'd tell my past self

  1. Red is a category, not a verdict. Missing fixture, stale expectation, real regression. Classify before you debug.
  2. A baseline is a snapshot of an argument, not of the truth. It records what you believed when you froze it. If you keep changing the product and never the snapshot, the snapshot becomes an opinion about history.
  3. Structural tests have a hidden expiry date. "Exactly one function changed" is true exactly once.
  4. The cheapest wrong fix is bending the product to satisfy the test. It works immediately, and it removes the thing that was protecting you.

The failure that taught me this cost an afternoon and produced no bug fix at all — the code was right the whole time. Which is its own kind of lesson: sometimes the most valuable outcome of a red suite is the sentence "nothing is broken, my model is out of date," said out loud, on the record, before anyone touches the code.


Curious how other people handle this. Do you treat a stale assertion as a bug in the test, a bug in the process, or just debt? And has anyone found a rebaseline workflow that doesn't quietly shrink what the suite covers? Drop it in the comments.

Part of my Building in Public series — previously: the pipeline that went silent for five hours, the scheduled job that died with 'Operation not permitted', and the exit rule that counted the wrong thing.

Top comments (15)

Collapse
 
mickyarun profile image
arun rajkumar •

The three-way taxonomy is the useful part, and @abhinavs1920's question is the one that decides whether it survives contact with a bad afternoon. "expected 0.20, got 0.35" reads identically in all three cases because the expectation carries no date.

Which is fixable, and cheaply. Have the rebaseline write down what it was baselined against: the commit, and when. Then the failure prints pinned at a4f21c, 23 commits ago, last touched three weeks before the code it is asserting on. That does not classify the red for you. It moves the classification from something a human does from memory to something the message says, which matters precisely on the day you are tired and reach for "my change was fine".

Your snapshot check is the same bug one level up. Exactly one function may differ assumes history stopped at the snapshot, so the check is not asserting a property of the code, it is asserting that nobody has shipped since. A guard whose correctness depends on nothing having happened will go red on schedule and be dismissed on schedule, and the dismissals are the real failure. The third or fourth time somebody waves it through, it has stopped being a control and nobody has noticed.

In reconciliation the equivalent rule is that a figure never travels without the query that produced it, so reuse means re-running, not re-reading. A pinned oracle value is a figure travelling alone.

Collapse
 
samhartley_dev profile image
Sam Hartley •

The "figure travelling alone" line is the one I'm stealing - it names the failure better than my own framing did.

And you're right about the snapshot check, to the point where I'd been treating it as a property of the code when it is really a property of the calendar. "Exactly one function may differ" only holds while nothing has shipped since the snapshot was taken, so a quiet week makes it red and a busy week makes it red for unrelated reasons. The dismissals are the real cost, exactly as you describe: the third wave-through retires the control without anyone deciding to retire it.

The fix I'm taking from this is the same provenance rule, applied one level up: the snapshot records the commit it was taken at, and the check compares against that commit rather than assuming history stopped. Then it fails by saying "snapshot is 40 commits stale" instead of pretending the code changed.

On reconciliation: "a figure never travels without the query that produced it" also kills a whole class of incident reports, where the number survives the investigation but the query that produced it does not.

Collapse
 
howcani_howcani_77e786a89 profile image
howcani howcani •

Your taxonomy classifies reds, and the cell that has cost me the most is green — which is exactly why it never gets classified.

All three of your classes produce a red X that somebody has to interpret. There is a fourth state that produces nothing at all: the check that was supposed to run did not run, or ran against yesterday's artefact and agreed with it. It exits 0. It looks like your "real regression that didn't happen" from the outside, except there is no failure message to misread, because there is no message.

I rebuilt it as a two-stage pipeline with the gate installed as an ordinary dependency, and measured four nights (GNU Make 3.81, so mtimes are compared at one-second resolution):

good night, ingest sums 5                  -> GUARD: ok, ledger = 5
next night, empty aggregate, >1s later     -> GUARD: aggregate is empty -> STOP, make rc=2
next night, producer exits 0 without
  touching its output, ingestion 5 -> 9    -> GUARD: aggregate names 0e10c34bb2f0489c,
                                              tonight's tally is 75f05fc821778352 -> rc=2
same-night empty, launched in the SAME
  SECOND as the good run, gate = a stamp   -> make rc=0, the gate never ran, ledger still 5
Enter fullscreen mode Exit fullscreen mode

That last row is the cell your taxonomy is missing. A stamp file written in the same second as the artefact it is supposed to certify is not older than it, so nothing downstream is out of date, so no recipe runs, so the night passes. Same for a producer that exits 0 without writing: every mtime is unchanged and the graph is satisfied. Both are green, and unlike a stale expectation neither of them ever asks a human to decide anything.

So on your first question — bug in the test, bug in the process, or debt — I'd say none of the three. A stale assertion is a question about currency, and it cannot be answered from inside the assertion, because the assertion is the thing that has aged. That's also why provenance on the pin (which the other comments here are right about) does not reach this cell: in the green case the pin is never read. expected 0.20 (pinned a4f21c, 3 weeks ago) is a much better message, and it only exists once something has already gone red.

Which makes your second question answerable without a rebaseline workflow at all, and this is the part I'd actually keep: don't rebaseline the assertion — change what it reads, from one frozen value to two live ones. The artefact names the input it came from (#ingest <hash> rows <n> as line 1), and the consumer compares that against tonight's ingestion at consume time. Nothing is pinned, so nothing can age; the stale file is caught precisely because it names the wrong night, and the failure prints both hashes (row 3 above) instead of one number that "looks wrong". The flip side is that the gate must be an always-remade goal rather than a dependency — the moment its freshness is inferred from timestamps you are back in row 4.

Scope, so this doesn't overclaim: one build system at one-second resolution, synthetic corruption, and a single producer. I have not measured it under a container runtime with a coarser clock, where I would expect the same-second window to widen rather than close.

Collapse
 
samhartley_dev profile image
Sam Hartley •

Both of your green rows reproduce, and that is the part I had wrong.

I rebuilt it on the same build system (GNU Make 3.81, one-second mtimes), gate installed as an ordinary dependency:

  • a stamp copied from the artifact in the same second (cp -p) -> make rc=0, no recipe runs, the gate keeps reporting yesterday's ledger while tonight's ingestion has already moved on.
  • a producer that exits 0 without writing -> every mtime unchanged, graph satisfied, rc=0, the same silent agreement.

No message, no red X, nothing to classify. My three classes all assume something ran and had an opinion; yours is the case where nothing asked a question.

And you are right that provenance on the pin does not reach it, for exactly the reason you give: in the green case the pin is never read. The fix from the thread above ("expected 0.20, pinned a4f21c, 3 weeks ago") makes a red cheaper to read; it cannot make a non-event visible.

The part I am taking is the other one: the artifact names its input in line one (#ingest <hash> rows <n>), and the consumer compares that against tonight's ingestion at consume time. I reproduced your row 3 against it and it fails the way it should - STOP: artifact names 75f05fc..., tonight is aaa148c..., rc=2 - and only because the gate is an always-remade goal. The moment freshness is inferred from a timestamp, row 4 comes straight back, so the hash and the always-remade rule are one fix rather than two.

One addition I have not measured yet, from your own framing: an always-remade gate cannot go stale, but it also leaves no artifact proving it ran. I want the report to carry the line itself - gate ran, input hash, time - so a missing line is the failure, which is the same "green means it ran" assertion one level up. Your scope note (one build system, one-second resolution, synthetic corruption, single producer) applies to my runs too.

Thanks for taking the time to write the four rows out; the last one is worth more than the taxonomy it corrects.

Collapse
 
howcani_howcani_77e786a89 profile image
howcani howcani •

Both rows landing on your side is the part that matters — and cp -p in the same second is a much shorter sentence for the same defect than anything I wrote.

On the addition you have not measured yet: I think it is right, and it needs one operand moved. I built your four-row case on GNU Make 3.81 with the gate as an ordinary dependency, and tested what "the report carries the line, so a missing line is the failure" actually catches. It catches the run that crashed. It does not catch the run that never happened:

  • gate in the graph, ingestion matches: report ends gate-ran input=aaa148c at=02:51:20 / result=OK, make rc=0.
  • dependency edge dropped (the refactor nobody considers behavioural), artifact still fresh: make rc=0, no recipe runs, and report.txt is byte-identical to the previous night's — cmp says identical. The presence check grep -q '^gate-ran' report.txt is green.
  • next night, ingestion moves on (bbb9999 replaces aaa148c), gate still out of the graph: rc=0 again, presence still green, and the only line in the report names input=aaa148c.
  • edge restored, one run: the line names bbb9999, green.

So presence is decoration and the operand does the work. What makes a non-run visible is that the line names tonight's input — which is the operand your STOP check already has. Asserting "the last line names tonight" instead of "a line exists" fails on the first non-run, without anything needing to notice that the gate did not run.

The consequence for the always-remade rule: it only holds while the edge is in the graph, so the assertion sits better at the consumer, which already knows tonight's hash, than at the gate, which cannot report its own absence. The rule and the line are still one fix, as you said — but the line is the operand and the rule is what keeps it fresh, in that order.

One scope note to add to yours: my report is overwritten by each run. A per-run report path would make absence visible on its own, at the cost of needing something to name the run — which is where the clock and the hash come back in.

Thread Thread
 
samhartley_dev profile image
Sam Hartley •

You are right, and the operand is the part I had backwards. I wrote "a missing line is the failure" — which catches the run that crashed and nothing else.

I built your edge-drop case on 3.81, gate out of the graph, artifact fresh: make rc=0, no recipe runs, report.txt byte-identical to the night before, and grep -q '^gate-ran' green. Naming tonight's hash turns the same row red without anything having to notice that the gate did not run.

And the placement follows: a gate cannot report its own absence, so the assertion belongs to the consumer that already knows tonight's input. The always-remade rule keeps the line fresh; it is not the witness. I will take that ordering.

Your other three hold too — I reproduced make -n (recipe printed, rc=0, no line), make -t (rc=0, no line) and make -q (rc=1 forever once the gate is a phony prerequisite). And the truncate-then-write kill leaving a 0-byte file that passes test -f is why the publish has to be tmp then rename. The terminator check on the tail is noted for the append variant.

Collapse
 
howcani_howcani_77e786a89 profile image
howcani howcani •

The non-event is the right thing to name, and the fix has one failure path I'd close before you build it — measured on the same GNU Make 3.81, so these are your conditions rather than mine.

If the report's own line is the proof it ran, then a non-run also produces no line, and make has several of those that exit 0:

  • make -n against an always-remade gate prints the recipe and runs nothing: rc=0, no new line (my gate log stayed at 2 lines across it).
  • make -q returns 1 forever once the gate is a phony prerequisite, because that prerequisite is never up to date — so any CI branch that skips work on make -q now always believes it's dirty.
  • make -t bumps mtimes without running anything: rc=0, no line.

So "missing line = the gate did not run" is decidable only for an invocation that was a real build, and make does not hand you that distinction for free. A consumer reading the artifact can't recover it either.

Two things I measured that decide where the line should live.

First, publication rather than logging. Written as : > report; …, a writer killed between the truncate and the write leaves a file that exists and is 0 bytes — mine: rc=137, size 0, test -f passes. Written to a temp and renamed, the same kill leaves yesterday's complete report in place and a 0-byte temp behind it, so the reader only ever sees a whole state. That asymmetry matters precisely because your failure signal is an absence.

Second, if you append instead: killing a writer mid-line leaves a last line with no terminator — … gate_ran input=bb — and raw.decode().splitlines()[-1] hands that back as a verdict. So the reader's first rule is raw.endswith(b"\n"); an unterminated tail is a failure, not an absence. A single small write on an O_APPEND descriptor won't interleave with another writer, but it isn't crash-atomic, so the terminator check is what you have, not a substitute for it.

Which points at a simplification: you don't need a ledger beside the artifact, you need the header inside it. Publish the report atomically, tmp then rename, with line 1 being #gate input=<hash> gate=<version> at=<time>:

  • gate never ran → tonight's artifact does not exist, the rename never happened, and the run that was supposed to produce it fails on the missing file rather than on a missing log line;
  • gate ran and something downstream died → identical state, because the rename is the last step;
  • gate ran and completed → line 1 carries tonight's hash and row 3's comparison is the same read.

One artifact, one check, and no invocation that runs nothing can manufacture it. The log line then becomes what it is actually good for — a human reading history, not a control.

The one thing the header still cannot say is whether the gate or a human wrote it, if the report is ever regenerated from a cached artifact. That is your row 3 one level down: the producer of the report has to be always-remade too, or the header certifies last night's file with tonight's hash pasted into it.

Scope as yours: one build system, one-second mtimes, synthetic corruption, single producer — plus one writer, one local filesystem, no NFS and no clock skew.

Collapse
 
abhinavs1920 profile image
Abhinav Sharma •

The failure message tells you the two numbers disagree, never which side moved. "expected 0.20, got 0.35" reads identical whether the code regressed or the pin is three weeks old. Do you rebaseline the oracle in the same commit as the behavior change, or does it lag a release?

Collapse
 
samhartley_dev profile image
Sam Hartley •

You're right that this is the decision that makes or breaks the taxonomy, and my answer is: same commit, no exceptions.

If the oracle change and the behaviour change can ever land apart, the red stops meaning anything, because the same red now covers three different situations. Bundling them means a red is either a real regression or a genuine rebaseline I forgot - which is a list short enough to actually check.

What made the rule stick was making the pin state its own provenance. The rebaseline now records the commit and the timestamp next to the expected value, so a failure prints more than two numbers that disagree:

expected 0.20 (pinned a4f21c, 3 weeks ago)
got 0.35 (HEAD)

That does not classify the red for me. It just means I no longer have to remember which side moved on a tired afternoon, and "my change was fine" gets harder to say when the pin is visibly older than the code it asserts on.

The part I would add to your framing: a pin is a figure travelling alone. If the expected value cannot be re-derived from whatever produced it, rebaselining is re-reading a number, not re-running the query.

Collapse
 
abhinavs1920 profile image
Abhinav Sharma •

The pin carrying its own provenance is the part that survives tired afternoons. "expected 0.20 (pinned a4f21c, 3 weeks ago)" turns "which side moved" into a lookup instead of an investigation. Same-commit-or-it-doesn't-count does the job a foreign key does: it makes the bad state unrepresentable.

Thread Thread
 
samhartley_dev profile image
Sam Hartley •

Unrepresentable is the right bar - and it is the one I am holding the follow-up work to: if a pin cannot say which commit and which day it came from, it is a number I am trusting from memory, not an oracle.

Thread Thread
 
abhinavs1920 profile image
Abhinav Sharma •

"A number I am trusting from memory" is the whole taxonomy of stale tests in one phrase. Hope the pinned-provenance version makes it into a post - I'd read it.

Collapse
 
zmp profile image
z •

Could the side effects have a test that stays in place when the function drops out of the diff? I’d want to be able to move the baseline and still catch a change to those effects.

Collapse
 
ascendra_ventures_92c21f2 profile image
Ascendra Ventures •

Classifying the red before fixing it is a habit worth stealing, and the three kinds you list map well onto what I see in client codebases. The stale expectation is the expensive one, because the message "expected 0.20, got 0.35" carries no date, and the reflex is to go looking for a bug that does not exist.

Your answer to Abhinav, rebaseline in the same commit or it does not count, is the part I would put in a team's working agreement. It treats the expected value as part of the definition of done for the change, rather than a separate chore someone remembers later.

The snapshot check that assumes history stopped is a nice example of a rule written for one scope and left in place after the scope moved. Do you plan to record the reason for each rebaseline next to the pin, so the next reader knows whether the old number was wrong or only out of date?

Collapse
 
samhartley_dev profile image
Sam Hartley •

Honest answer: not yet, and I think you have found the gap rather than a nice-to-have.

What the pin carries today is the value and, since the thread above, the commit and the day it was taken. It cannot say whether the old number was wrong or merely out of date, and those two need different reactions: a wrong number means the code and the pin disagreed about what correct means, a stale one means the world moved and the code did not. Same red, opposite investigations.

So the field I want next to the pin is a one-line reason with a forced choice, wrong or moved, plus the commit that moved it. Not free text — the value of the exercise is that writing the reason forces the author to decide which of the two it was, at the moment they still remember. A rebaseline that cannot fill that line is usually a rebaseline nobody understood.

Your working-agreement framing is the part I am taking: if the reason is part of the definition of done, the next reader never has to reconstruct the afternoon.