← All writing
Craft · · 6 min

The test that would have caught it

Writing tests only after a bug is the correct policy, not a failure of discipline. There's a step at the end that nobody does.

Testing

A discount larger than the order total, producing a negative amount, which the payment provider rejected with a message nobody could interpret.

I fixed that in early 2024. I found the commit while investigating it again in September, and the fix was still there, correct, doing its job. A different code path had grown up next to it and reintroduced the same mistake, and nothing anywhere knew that this was a thing that must not happen.

The commit message was decent. There was no test.

Written after the fact is the right time

I have written a lot of tests over the years and I’d guess 90% of the ones that have ever caught anything were written in response to a bug.

For a long time I read that as a failure of discipline: I ought to be writing tests in advance, properly, and I don’t, and the after-the-fact ones are what I manage instead of what I should do.

I’ve changed my mind. I think it’s the correct policy, and the mistake was not noticing it was a policy.

The space of tests you could write is unbounded. The space of tests worth writing is determined by where the system is actually fragile, and you can’t know that in advance. What you can know is where it has already broken, and bugs cluster: the same modules, the same assumptions, the same three or four bits of logic that were complicated when they were written and are complicated now.

A bug that has occurred is the single best piece of evidence you will ever get about where to spend testing effort. It’s specific, it’s real, somebody was affected by it, and it costs nothing to obtain because you’ve already paid for it.

A bug that has already happened is the best evidence you'll ever get about where a system is fragile, and it's free, because you've already paid for it.

Which turns the rule into something enforceable: a bug isn’t finished when the fix works. It’s finished when there’s a test that fails without the fix.

Red first, then the fix, then red again

The order matters more than it sounds like it does.

Reproduce it, which is the first step of any debugging and the one everybody skips.

Write the smallest test that exhibits it, and run it. It must be red. If it’s green, you haven’t reproduced what you think you’ve reproduced, and you’d have found that out much later and more expensively.

Then fix it, and watch the test go green.

Then revert the fix and confirm it goes red again.

That last step is the one nobody does, and it’s the only thing that proves the test is connected to the fix rather than to something incidental. A test written after a fix, without ever having been seen to fail, is exactly the decorative test I was complaining about in March: it might assert nothing, and it will look identical in the report.

30 seconds, and it converts a test you hope works into one you’ve watched work.

Name it after the behavior

Not test_bug_4471. Not test_discount_fix.

discounts.test.js
test('a discount larger than the subtotal reduces the total to zero, not below', () => {
  // Reported by Marsden & Clough, Jan 2024 and again Sept 2025. A 100% voucher
  // on an order with a delivery charge produced a negative total, which the
  // payment gateway rejects with an unhelpful message.
  expect(total({ subtotal: 20, delivery: 5, discount: 40 })).toBe(0);
});
JavaScript

Two reasons for the name.

The ticket number will not survive. Every project I’ve worked on has changed its issue tracker at least once, and the numbers do not transfer, so a test named after a ticket becomes a test named after nothing. That’s the same argument I made about commit messages in 2018 and it keeps being true.

And the name is what somebody reads when the test fails in three years. test_bug_4471 failed tells them nothing. “A discount larger than the subtotal reduces the total to zero, not below” tells them exactly what invariant they’ve just broken and lets them decide whether they meant to.

The comment matters too. The test file is the one artifact guaranteed to be opened by whoever next touches that code, which makes it a better place for this context than a wiki, a ticket, or a commit message.

What the suite becomes

The interesting side effect, after a few years of doing this on one long-running project.

The suite is now a list of everything that has ever gone wrong with that system, in the words of the people it went wrong for. It’s not balanced, it doesn’t cover everything, and the coverage percentage is unimpressive.

It is also an honest document, in a way a coverage-driven suite never is. Every test in it is there because something happened. Reading it is the fastest way for a new person to learn where the sharp edges are, and it’s a much better answer than “read the code and be careful.”

And, in my experience and with the usual caveat that I haven’t measured it properly: bugs that got a regression test don’t come back. Bugs that got a fix without one come back at roughly the rate you’d sadly expect.

What this doesn’t cover

Being fair to it, because “only write tests after bugs” would be a daft absolute.

It’s reactive by definition, so the first occurrence always gets through. For a brochure site that’s an acceptable trade and I argued exactly that back in 2018. For anything where the first failure is catastrophic, it isn’t: payments, authentication, anything that deletes data, anything handling personal information. Those need tests written in advance, and the useful thing is that the list of them is short and you can write it down on one page.

So the honest policy is two-tier. A small, deliberately chosen set of things that get tested before they ship because failing once is unacceptable. And everything else tested reactively, from evidence, as it earns it.

Forty minutes, eighteen months late

I wrote the test in September. It’s 11 lines including the comment and it took about 40 minutes with the reproduction.

The thing that stays with me isn’t the bug, it’s that 18 months earlier I’d diagnosed it, understood it, fixed it correctly, and written a good commit message about it, and then let all of that knowledge sit in a place where the machine couldn’t check it.

Read similar posts
6 min

The agent wrote a test that passes

I asked for test coverage on a module that didn't have any, got 14 tests back, and all 14 passed, and about half of them could not have failed under any circumstances whatsoever.

7 min

Most flaky tests aren't

A test that failed about one run in 15, and a team with a ritual around it. The ritual was clicking a button. It had been going on for eight months.