My test suite was right by luck
A green test that's correct by coincidence is worse than a red one. At least the red one is telling you something.
Yesterday I found a bug that my test suite had been actively vouching for. Not missing — vouching for. The test asserted the exact behavior, the behavior was wrong, and the test passed anyway. It’s the most instructive bug I’ve hit in years, and it’s a class of failure worth naming.
The bug
I have an analyzer that reads legacy .NET solutions and computes a migration order from the project reference graph — leaves first, so shared libraries migrate before the things that depend on them.
Old-style csproj files store references as Windows-style paths:
<ProjectReference Include="..\InventoryCore\InventoryCore.csproj">
My code extracted the project name with Path.GetFileNameWithoutExtension. On Windows
that returns InventoryCore. On Linux — where this analyzer runs — backslash isn’t a
path separator, so it returns ..\InventoryCore\InventoryCore. Which matches no
project name. Which means every reference silently failed to resolve. Which means every
project looked like a leaf. Which means my “topological sort” was, in fact,
an alphabetical one.
The lie
Here’s the part that stings. I had a test for exactly this:
[Fact]
public void MigrationOrder_IsLeavesFirst()
{
Assessment.SuggestedMigrationOrder.First().Should().Be("InventoryCore",
"both other projects reference it");
}
InventoryCore is the correct first answer — it’s the leaf. InventoryCore is also
the alphabetically first project in the solution. The broken sort and the correct sort
produce the same output on this input. The test stayed green through every commit that followed, not
because the code was right, but because my test fixture couldn’t tell right from
broken.
The bug surfaced the first time I pointed the analyzer at a codebase I didn’t author —
a 17-project Codeplex-era app — and saw Terminals.Common scheduled for migration
after the application that depends on it. A human glance caught in two seconds what
the green suite had been happily certifying.
The class of failure
Call it right by luck: the test’s expected answer coincides with the degenerate answer. Every degenerate fallback has a signature — alphabetical order, empty collections, zeros, the input echoed back — and if your fixture’s correct answer matches the signature, your test is a rubber stamp.
The fix is adversarial fixture design: choose test data where the correct answer and
the lazy answer visibly differ. My regression test now uses a project graph where
Beta depends on Zed — alphabetical says Beta first; correctness says Zed. The
degenerate path can never pass it again.
Three habits I’m keeping from this:
- When writing a fixture, ask: what would a broken implementation output here? If it’s the same as the right answer, change the fixture until it isn’t.
- Feed your tools at least one input you didn’t author. Synthetic fixtures catch regressions cheaply; wild specimens catch the failures you didn’t know to plant. You need both — my synthetic suite still runs on every commit, but it no longer testifies alone.
- Treat “it passed” with the same suspicion as “it works on my machine.” A test only means what its fixture can distinguish.
Nothing about this is new wisdom — mutation-testing people have preached it for decades. But there’s a modern reason to re-learn it: I build with AI agents, and agents generate plausible tests fast. Plausible is exactly the danger. A test suite that grows quickly is a test suite whose fixtures nobody stared at long enough to ask what a broken implementation would say. The staring is still our job.
Built and written in collaboration with Claude (Anthropic’s Fable 5) — the workflow this blog is about. The semantic code tools from post one: github.com/sharpdaddy59/RoslynMcp.