Last week I finished a three-part series on running Claude Code across a whole team. It ends on a principle I still believe: AI is a tool, the developer is accountable. And it ends with two gates that are supposed to enforce it.
Now follow the automated gate backwards for a second.
The gate opens when dotnet test comes back green. The reviewer subagent is happy, CI is green, a human ticks the box. Every one of those signals traces back to the same place: a test suite. And on a team shipping at the speed that series describes, that test suite was written by the same AI that wrote the code — in the same session, with the implementation sitting in its context window.
A test written by reading the implementation cannot disagree with the implementation.
That’s the whole post. Coverage goes up, independence goes to zero, and everyone feels safer than they did before.
I should be clear about what this is not. I’ve written before about getting a service from zero to eighty percent coverage in an afternoon, and that post has a section on the bad tests Claude Code writes — trivial assertions, mock-heavy setups, the usual. Its remedy is to review the tests like you’d review any code. This post is about the tests that survive that review. The ones that look right, because they are a faithful description of code that is wrong.
The suite that agrees with the code
Ask an AI to test a class and it does the sensible thing: it reads the class.
Every branch it covers, it covers because the branch is there. Every boundary it asserts on is a boundary it found in an if. The result is a transcript of the implementation, dressed up in [Fact] attributes and domain vocabulary — a very precise statement about what your code does, and no statement at all about what it should do.
Coverage doesn’t help you here, because coverage measures which lines executed. Executing a wrong line proves nothing.
This is a different failure from the one everybody talks about. A lazy test — result.Should().NotBeNull() — is easy to spot in review, and the automated gate from part 3 will flag it. A faithful test of the wrong rule is invisible, because there is nothing wrong with it except the one thing you can’t see from inside the diff.
A bug, certified
Here’s the shape, as small as I can make it.
The ticket says:
A trip longer than 100 km gets a 10% long-distance surcharge.
Here’s what came back:
public static class Fare
{
public const decimal SurchargeRate = 0.10m;
public static decimal Surcharge(decimal distanceKm, decimal baseFare)
=> distanceKm >= 100 ? baseFare * SurchargeRate : 0m;
}
And here are the tests, written in the same session, a minute later:
public class FareTests
{
[Theory]
[InlineData(40, 100, 0)]
[InlineData(100, 100, 10)]
[InlineData(250, 100, 10)]
public void Surcharge_WhenDistanceGreaterThanOrEqualTo100_AddsTenPercent(
decimal km, decimal baseFare, decimal expected)
=> Fare.Surcharge(km, baseFare).Should().Be(expected);
[Fact]
public void Surcharge_UsesConfiguredRate()
{
var result = Fare.Surcharge(150, 80m);
result.Should().Be(80m * Fare.SurchargeRate);
}
}
Green. Every row, every run. Line coverage on Fare is 100%.
(The surcharge is a flat 10% of the base fare, so 250 km and 100 km both yield 10 on a base fare of 100. The third row isn’t a typo — it’s just not telling you anything the second row didn’t.)
The ticket said longer than 100 km. The code says >=. Every trip of exactly 100 km is being surcharged, and the test suite has certified it: [InlineData(100, 100, 10)] asserts that a 100 km trip gets the surcharge. Nobody lied. The test asked the code what the answer was, and the code told it.
Three tells in C#
You can catch this in review, but not by reading harder. You catch it by knowing what a test written from the implementation looks like.
The assertion sits exactly on a boundary that appears in the code and not in the ticket. 100 is in the requirement. The behaviour at 100 is not. Any time a test pins down a boundary case, ask where that specific expected value came from — a sentence someone wrote, or an operator someone typed.
The expected value is computed from a production constant. result.Should().Be(80m * Fare.SurchargeRate) cannot ever disagree with the implementation about the rate, because it asks the implementation for the rate. Change SurchargeRate to 0.50m and this test stays green. It’s a mirror with an assertion on it.
The test name leaks the implementation. Surcharge_WhenDistanceGreaterThanOrEqualTo100_AddsTenPercent names an operator that appears nowhere in the requirement. That name is a confession about where the test came from. A test named after the rule — Trips_longer_than_100km_get_a_10_percent_surcharge — would have been uncomfortable to write next to that [InlineData(100, 100, 10)] row, and that discomfort is the signal.
Prove the test can fail
There’s a tool for the mechanical half of this, and it’s been in .NET for years without much uptake: mutation testing.
Stryker.NET takes your code, breaks it on purpose — flips > to >=, + to -, true to false — and reruns your suite against each mutant. If the tests still pass, that mutant survived. A survivor is a line your tests execute but do not check.
dotnet tool install -g dotnet-stryker
dotnet stryker --break-at 70
Point it at the domain project and give it a threshold that fails the build:
{
"stryker-config": {
"project": "Acme.Trips.Domain.csproj",
"reporters": ["html", "progress"],
"thresholds": { "high": 85, "low": 70, "break": 70 }
}
}
Once that break threshold is wired into your CI pipeline, “the tests pass” starts to mean something more than “the tests ran”. It’s the first honest number you’ll get about a suite you didn’t write.
What mutation testing will never tell you
Now run it on the example above. I did, with Stryker.NET 4.16.0 on .NET 10:
Killed: 5
Survived: 0
Timeout: 0
The final mutation score is 100.00 %
A perfect score. Mutate >= to > and [InlineData(100, 100, 10)] goes red, so that mutant dies along with the other four. The suite is rigorous by every measure I have. The suite is also wrong.
That’s the sentence I’d want you to take away from this post. Mutation testing proves your tests can fail. It cannot prove they’d fail on the right thing. It kills the lazy tautology and leaves the faithful one standing, perfectly intact, with a green build and an excellent mutation score on top.
I like the tool and I think you should run it. But if I sold it to you as the fix here, I’d be doing exactly what part 3 warned about: the danger of a good automated gate isn’t that it misses things, it’s that it reassures.
Independence is a workflow, not a tool
The only thing that fixes the faithful tautology is provenance. The test cases have to come from the requirement, in a context that has not seen the implementation.
Concretely: derive the case table from the ticket first, in a separate session, and review that table as the artifact. This is what those “five test scenarios that define done” from turning an idea into something implementation-ready were always for — they’re not a planning nicety, they’re the only test cases in your process with an independent origin.
Same three rows of arithmetic, written from the sentence instead of the code:
[Theory]
[InlineData(99.9, 100, 0)] // just under the line: no surcharge
[InlineData(100, 100, 0)] // exactly 100 is not "longer than" 100
[InlineData(100.1, 100, 10)] // just over: surcharge applies
public void Trips_longer_than_100km_get_a_10_percent_surcharge(
decimal km, decimal baseFare, decimal expected)
=> Fare.Surcharge(km, baseFare).Should().Be(expected);
Every number came from a sentence a human wrote. The name states the rule instead of the branch. And the middle row fails against the shipped code — which is the entire point.
Then there’s one habit that costs nothing and changes everything. When a test goes red, never type “make the tests pass.” Type this instead:
“One of these two is wrong — the test or the code. Tell me which, and why.”
The first phrasing has exactly one outcome, and half the time it’s the wrong one. The second makes the model do the thing you actually wanted: compare both against the requirement.
Where to spend it
Mutation testing is slow. You cannot run it across a solution and you shouldn’t try.
You don’t need to. Run it on the functional core — the pure decision code from functional core, imperative shell — because that’s both where it’s fast (no I/O, no mocks, no database to spin up) and where the rules that can be silently wrong actually live. >= instead of > in a pricing rule costs you money every day. The same mutation in a controller usually just fails loudly.
The shell gets integration tests and a human reading the diff. The core gets properties, provenance and a break threshold.
Prove one test can fail
Pick the last class an AI wrote tests for. Not the messiest one — the one you felt good about.
claude
Then:
“Read only the test file for
FareCalculator— do not open the implementation. From the tests alone, write down the business rules they claim to enforce, one sentence per rule, in the language of the domain. Then list every rule where a different implementation would also pass all of these tests.”
Read that second list against the ticket the feature came from.
Everything on it is a rule you have coverage of and no confidence in. Until you put those two documents side by side, those felt like the same thing.
So: what was on your list? One rule your tests cover and don’t actually pin down. Paste it in the comments — you’ll have it in front of you thirty seconds after running that prompt, and I suspect the pattern across a few dozen codebases is a lot more interesting than any single example.
If your team is shipping AI-written C# fast and you’re no longer sure what a green build proves, that’s a conversation I like having. Schedule a call and bring your test suite.
Comments