Vorige week rondde ik een driedelige serie af over Claude Code in een heel team. Die eindigt op een principe waar ik nog steeds achter sta: AI is een hulpmiddel, de developer is verantwoordelijk. En hij eindigt met twee gates die dat moeten afdwingen.
Volg die automatische gate nu eens terug.
De gate gaat open als dotnet test groen terugkomt. De reviewer-subagent is tevreden, CI is groen, een mens zet een vinkje. Al die signalen komen op één plek uit: een testsuite. En in een team dat oplevert op de snelheid die die serie beschrijft, is die testsuite geschreven door dezelfde AI die de code schreef — in dezelfde sessie, met de implementatie in het contextvenster.
Een test die geschreven is door de implementatie te lezen, kan het niet oneens zijn met die implementatie.
Dat is de hele post. De coverage gaat omhoog, de onafhankelijkheid gaat naar nul, en iedereen voelt zich veiliger dan daarvoor.
Even scherp zijn over wat dit niet is. Ik schreef eerder over een service van nul naar tachtig procent coverage in een middag, en in die post staat een sectie over de slechte tests die Claude Code schrijft — triviale asserties, te veel mocks, dat werk. Het advies daar is: review de tests zoals je alle code reviewt. Deze post gaat over de tests die die review overleven. Die er goed uitzien, omdat ze een correcte beschrijving zijn van code die niet klopt.
De suite die het eens is met de code
Vraag een AI om een class te testen en hij doet het verstandige: hij leest die class.
Elke branch die hij afdekt, dekt hij af omdat de branch er staat. Elke grenswaarde waar hij op assert, is een grens die hij in een if vond. Wat je overhoudt is een transcript van de implementatie, aangekleed met [Fact]-attributen en domeintaal — een heel precieze uitspraak over wat je code dóét, en geen enkele uitspraak over wat hij zou moeten doen.
Coverage helpt je hier niet, want coverage meet welke regels zijn uitgevoerd. Een verkeerde regel uitvoeren bewijst niets.
Dit is een andere faalmodus dan die waar iedereen het over heeft. Een luie test — result.Should().NotBeNull() — zie je zo in een review, en de automatische gate uit deel 3 vangt hem af. Een trouwe test van de verkeerde regel is onzichtbaar, want er is niets mis mee behalve het enige dat je vanuit de diff niet kunt zien.
Een bug, gecertificeerd
Zo klein als ik hem kan maken.
Op het ticket staat:
Een rit langer dan 100 km krijgt 10% langeafstandstoeslag.
Dit kwam eruit:
public static class Fare
{
public const decimal SurchargeRate = 0.10m;
public static decimal Surcharge(decimal distanceKm, decimal baseFare)
=> distanceKm >= 100 ? baseFare * SurchargeRate : 0m;
}
En dit waren de tests, een minuut later, in dezelfde sessie:
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);
}
}
Groen. Elke regel, elke run. Line coverage op Fare is 100%.
(De toeslag is een vast percentage van het basistarief, dus 250 km en 100 km leveren allebei 10 op bij een basistarief van 100. De derde regel is geen typefout — hij vertelt je alleen niets wat de tweede niet al zei.)
Op het ticket stond langer dan 100 km. In de code staat >=. Elke rit van precies 100 km krijgt nu toeslag, en de testsuite heeft dat gecertificeerd: [InlineData(100, 100, 10)] legt vast dat een rit van 100 km toeslag krijgt. Niemand heeft gelogen. De test vroeg de code wat het antwoord was, en de code gaf antwoord.
Drie signalen in C#
Je kunt dit in review vangen, maar niet door beter te lezen. Je vangt het door te weten hoe een test eruitziet die vanuit de implementatie is geschreven.
De assertie ligt precies op een grens die in de code staat en niet op het ticket. De 100 staat in de eis. Het gedrag op 100 niet. Zodra een test een grensgeval vastlegt: vraag waar die verwachte waarde vandaan komt — uit een zin die iemand schreef, of uit een operator die iemand typte.
De verwachte waarde is berekend met een constante uit productiecode. result.Should().Be(80m * Fare.SurchargeRate) kan het nooit oneens zijn met de implementatie over het tarief, want hij vraagt het tarief aan de implementatie. Zet SurchargeRate op 0.50m en deze test blijft groen. Het is een spiegel met een assertie erop.
De testnaam verraadt de implementatie. Surcharge_WhenDistanceGreaterThanOrEqualTo100_AddsTenPercent noemt een operator die nergens in de eis voorkomt. Die naam is een bekentenis over de herkomst van de test. Een naam die de regel beschrijft — Ritten_langer_dan_100km_krijgen_10_procent_toeslag — was ongemakkelijk geweest om op te schrijven naast die [InlineData(100, 100, 10)]. Dat ongemak is precies het signaal.
Bewijs dat de test kán falen
Voor de mechanische helft hiervan bestaat gereedschap, en het zit al jaren in .NET zonder dat iemand het gebruikt: mutation testing.
Stryker.NET pakt je code, sloopt hem expres — > wordt >=, + wordt -, true wordt false — en draait je suite opnieuw tegen elke mutant. Blijven de tests groen, dan heeft die mutant het overleefd. Een overlever is een regel die je tests wel uitvoeren maar niet controleren.
dotnet tool install -g dotnet-stryker
dotnet stryker --break-at 70
Richt hem op je domeinproject en geef hem een drempel die de build laat falen:
{
"stryker-config": {
"project": "Acme.Trips.Domain.csproj",
"reporters": ["html", "progress"],
"thresholds": { "high": 85, "low": 70, "break": 70 }
}
}
Zodra die break-drempel in je CI-pipeline hangt, betekent “de tests slagen” iets meer dan “de tests zijn gedraaid”. Het is het eerste eerlijke getal dat je krijgt over een suite die je niet zelf hebt geschreven.
Wat mutation testing je nooit vertelt
Draai hem nu eens op het voorbeeld hierboven. Ik deed het, met Stryker.NET 4.16.0 op .NET 10:
Killed: 5
Survived: 0
Timeout: 0
The final mutation score is 100.00 %
Een perfecte score. Muteer >= naar > en [InlineData(100, 100, 10)] wordt rood, dus die mutant sneuvelt net als de andere vier. De suite is streng volgens elke maatstaf die ik heb. De suite klopt ook niet.
Dat is de zin die ik je wil meegeven. Mutation testing bewijst dat je tests kúnnen falen. Het kan niet bewijzen dat ze op het juiste falen. Het doodt de luie tautologie en laat de trouwe fier overeind staan, met een groene build en een uitstekende mutatiescore erbovenop.
Ik vind het goed gereedschap en je zou het moeten draaien. Maar als ik het je hier als de oplossing verkocht, deed ik precies waar deel 3 voor waarschuwde: het gevaar van een goede automatische poort is niet dat hij dingen mist, maar dat hij geruststelt.
Onafhankelijkheid is een werkwijze, geen tool
Het enige dat de trouwe tautologie oplost, is herkomst. De testgevallen moeten uit de eis komen, in een context die de implementatie niet heeft gezien.
Concreet: leid de gevallentabel éérst af uit het ticket, in een aparte sessie, en review die tabel als artefact. Daar waren die “vijf testscenario’s die klaar definiëren” uit van idee naar implementatie-klaar altijd al voor bedoeld — het is geen planningsversiering, het zijn de enige testgevallen in je proces met een onafhankelijke herkomst.
Dezelfde drie regels rekenwerk, maar geschreven vanuit de zin in plaats van vanuit de code:
[Theory]
[InlineData(99.9, 100, 0)] // net eronder: geen toeslag
[InlineData(100, 100, 0)] // precies 100 is niet "langer dan" 100
[InlineData(100.1, 100, 10)] // net erover: toeslag geldt
public void Ritten_langer_dan_100km_krijgen_10_procent_toeslag(
decimal km, decimal baseFare, decimal expected)
=> Fare.Surcharge(km, baseFare).Should().Be(expected);
Elk getal komt uit een zin die een mens heeft geschreven. De naam beschrijft de regel in plaats van de branch. En de middelste regel faalt tegen de code die live staat — en dát is de hele bedoeling.
Er is daarnaast één gewoonte die niets kost en alles verandert. Als een test op rood springt, typ dan nooit “zorg dat de tests slagen.” Typ dit:
“Een van deze twee klopt niet — de test of de code. Vertel me welke, en waarom.”
De eerste formulering heeft precies één uitkomst, en de helft van de tijd is dat de verkeerde. De tweede laat het model doen wat je eigenlijk wilde: allebei vergelijken met de eis.
Waar je het uitgeeft
Mutation testing is traag. Je kunt het niet over een hele solution draaien en dat moet je ook niet proberen.
Dat hoeft ook niet. Draai het op de functional core — de pure beslissingscode uit functional core, imperative shell — want dat is zowel waar het snel is (geen I/O, geen mocks, geen database om op te starten) als waar de regels wonen die stilletjes fout kunnen zijn. >= in plaats van > in een tariefregel kost je elke dag geld. Dezelfde mutatie in een controller valt meestal hard om.
De shell krijgt integratietests en een mens die de diff leest. De core krijgt herkomst, mutatietests en een break-drempel.
Bewijs dat één test kan falen
Pak de laatste class waar een AI tests voor schreef. Niet de rommeligste — die waar je een goed gevoel over had.
claude
Daarna:
“Lees alleen het testbestand van
FareCalculator— open de implementatie niet. Schrijf op basis van alleen de tests op welke bedrijfsregels ze beweren af te dwingen, één zin per regel, in de taal van het domein. Noem daarna elke regel waarbij een andere implementatie ook al deze tests zou doorstaan.”
Leg die tweede lijst naast het ticket waar de feature uit voortkwam.
Alles wat erop staat is een regel waar je coverage over hebt en geen vertrouwen in. Tot je die twee documenten naast elkaar legde, voelde dat als hetzelfde.
En: wat stond er op jouw lijst? Eén regel die je tests wel afdekken en niet echt vastleggen. Zet ’m in de reacties — je hebt hem dertig seconden na die prompt voor je neus staan, en ik vermoed dat het patroon over een paar dozijn codebases een stuk interessanter is dan welk los voorbeeld ook.
Levert je team in hoog tempo AI-geschreven C# op en weet je niet meer zo goed wat een groene build nu eigenlijk bewijst? Daar praat ik graag over. Plan een gesprek en neem je testsuite mee.
Reacties