Build a feature from a spec, tests first, then let Stryker.NET list what the spec forgot
A written spec with examples becomes failing xUnit tests, the agent codes until they pass, and a mutation run names the boundaries nobody pinned. About 30 minutes, .NET 10 and any agent.

A pricing rule arrives as a ticket: promo codes, a cap on one of them, a minimum spend on another, and “obviously only one code per order”. The agent writes the endpoint in a minute. It looks right. Two weeks later a customer with a 30.00 basket gets told the minimum isn’t met, because the check says <= where the ticket meant <, and nobody can say which of the two the ticket actually meant.
That bug is a missing example, and no amount of reading the diff finds a missing example. What finds it is writing the examples first, as a table, then turning every row into a test before any implementation exists, then asking a mutation tool which behaviours the tests still don’t pin.
This Playbook builds that loop on a small ASP.NET Core feature. The spec is a Markdown file, the tests are xUnit, the implementation is whatever passes, and Stryker.NET is the auditor. The loop works with any agent, because the agent only has to obey two rules: the tests are read-only, and dotnet test decides when it’s finished.
The loop
- Write the spec as rules plus an examples table.
- Turn each row into a test, against a stub that throws. All tests fail.
- Give the agent the spec and the failing tests. It implements until green and may not edit the tests.
- Run Stryker.NET. Every surviving mutant is a behaviour the spec never pinned.
- Add the missing examples to the spec, then to the tests. Repeat until the survivors are things you’ve decided don’t matter.
Step 4 is where most of the value is. Green tests tell you the code does what the examples say. A mutation run tells you how much the code does that the examples never say.
Set up the project
You need the .NET 10 SDK and any editor. Everything below runs from an empty folder:
dotnet new web -n Promo -o src/Promo --framework net10.0
dotnet new xunit -n Promo.Tests -o tests/Promo.Tests --framework net10.0
dotnet new sln -n Promo
dotnet sln add src/Promo tests/Promo.Tests
dotnet add tests/Promo.Tests reference src/Promo
dotnet add tests/Promo.Tests package Microsoft.AspNetCore.Mvc.Testing --version "10.0.*"
rm tests/Promo.Tests/UnitTest1.cs
Microsoft.AspNetCore.Mvc.Testing provides WebApplicationFactory, which boots the app in memory so one test can go through the real HTTP pipeline. The unit tests don’t need it, but a spec that stops at a pure function can still leave the endpoint wrong.
Write the spec
Save this as docs/promo-spec.md. The rules are short on purpose. The table is the contract.
# Promo codes
An order has a subtotal and zero or one promo code. Price it.
Codes (case-insensitive, surrounding whitespace ignored):
- SAVE10: 10% off the subtotal, discount capped at 20.00
- FIVER: 5.00 off, only when the subtotal is 30.00 or more
- WELCOME: 8.00 off, no minimum
Rules:
- Money is decimal. Round the discount to 2 places, halves away from zero.
- The discount never exceeds the subtotal. The total is never negative.
- More than one code is rejected.
- A subtotal of zero or less is rejected.
Errors: unknown_code, minimum_not_met, one_code_only, invalid_subtotal.
| subtotal | codes | discount | total | error |
|----------|---------------|----------|--------|------------------|
| 50.00 | SAVE10 | 5.00 | 45.00 | |
| 300.00 | SAVE10 | 20.00 | 280.00 | |
| 12.25 | SAVE10 | 1.23 | 11.02 | |
| 30.00 | FIVER | 5.00 | 25.00 | |
| 29.99 | FIVER | | | minimum_not_met |
| 50.00 | " save10 " | 5.00 | 45.00 | |
| 50.00 | SAVE10, FIVER | | | one_code_only |
| 3.00 | WELCOME | 3.00 | 0.00 | |
| 50.00 | NOPE | | | unknown_code |
| 50.00 | (none) | 0.00 | 50.00 | |
| 0.00 | SAVE10 | | | invalid_subtotal |
Look at what the examples are doing. The 12.25 row exists because 10% of it is 1.225, a midpoint, and the two rounding modes in .NET disagree about midpoints: MidpointRounding.AwayFromZero gives 1.23 and ToEven gives 1.22, per the Microsoft docs. The 30.00 and 29.99 rows sit either side of the minimum. The 300.00 row exercises the cap. A good spec row is a place where two plausible implementations differ.
Stub and tests
The stub gives the tests something to compile against. src/Promo/PromoCalculator.cs:
namespace Promo;
public sealed record PriceResult(decimal Discount, decimal Total, string? Error = null);
public static class PromoCalculator
{
public static PriceResult Price(decimal subtotal, IReadOnlyList<string> codes) =>
throw new NotImplementedException();
}
And the endpoint in src/Promo/Program.cs:
using Promo;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/orders/price", (PriceRequest request) =>
{
var result = PromoCalculator.Price(request.Subtotal, request.Codes ?? []);
return result.Error is null
? Results.Ok(new { discount = result.Discount, total = result.Total })
: Results.UnprocessableEntity(new { error = result.Error });
});
app.Run();
public sealed record PriceRequest(decimal Subtotal, string[]? Codes);
public partial class Program;
The last line is there for portability. On .NET 8 the test project fails to build without public partial class Program;, with CS0122: 'Program' is inaccessible due to its protection level, because top-level statements generate an internal class. On .NET 10 the same tests build and pass without it. Keep it if the project might target an older framework.
Now the tests. One Theory row per spec row, in tests/Promo.Tests/PromoCalculatorTests.cs:
using Promo;
using Xunit;
namespace Promo.Tests;
public class PromoCalculatorTests
{
public static TheoryData<decimal, string[], decimal, decimal> Priced => new()
{
{ 50.00m, ["SAVE10"], 5.00m, 45.00m },
{ 300.00m, ["SAVE10"], 20.00m, 280.00m },
{ 12.25m, ["SAVE10"], 1.23m, 11.02m },
{ 30.00m, ["FIVER"], 5.00m, 25.00m },
{ 50.00m, [" save10 "], 5.00m, 45.00m },
{ 3.00m, ["WELCOME"], 3.00m, 0.00m },
{ 50.00m, [], 0.00m, 50.00m },
};
[Theory]
[MemberData(nameof(Priced))]
public void Prices_the_order(decimal subtotal, string[] codes, decimal discount, decimal total)
{
var result = PromoCalculator.Price(subtotal, codes);
Assert.Null(result.Error);
Assert.Equal(discount, result.Discount);
Assert.Equal(total, result.Total);
}
public static TheoryData<decimal, string[], string> Rejected => new()
{
{ 29.99m, ["FIVER"], "minimum_not_met" },
{ 50.00m, ["SAVE10", "FIVER"], "one_code_only" },
{ 50.00m, ["NOPE"], "unknown_code" },
{ 0.00m, ["SAVE10"], "invalid_subtotal" },
};
[Theory]
[MemberData(nameof(Rejected))]
public void Rejects_the_order(decimal subtotal, string[] codes, string error)
{
Assert.Equal(error, PromoCalculator.Price(subtotal, codes).Error);
}
}
Add two endpoint tests in tests/Promo.Tests/PriceEndpointTests.cs, one for a 200 and one for a 422 with the error code:
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace Promo.Tests;
public class PriceEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Returns_200_with_the_priced_order()
{
var response = await factory.CreateClient()
.PostAsJsonAsync("/orders/price", new { subtotal = 50.00m, codes = new[] { "SAVE10" } });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, decimal>>();
Assert.Equal(5.00m, body!["discount"]);
Assert.Equal(45.00m, body["total"]);
}
[Fact]
public async Task Returns_422_with_the_error_code()
{
var response = await factory.CreateClient()
.PostAsJsonAsync("/orders/price", new { subtotal = 50.00m, codes = new[] { "NOPE" } });
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.Equal("unknown_code", body!["error"]);
}
}
Red
Run dotnet test. Every test fails, and the summary line says so:
Failed! - Failed: 13, Passed: 0, Skipped: 0, Total: 13, Duration: 519 ms - Promo.Tests.dll (net10.0)
Thirteen red tests from a stub is the point of the exercise. The endpoint tests fail with a 500 because the stub throws, which is also a free check that the pipeline is wired. If any test passes against the stub, it’s asserting nothing and should be fixed before the agent sees it.
Commit the spec, the stub and the red tests together, so the agent’s work is a diff against a known contract.
Hand it to the agent
This is a recommended prompt, written for any agent that can run a shell:
Implement PromoCalculator.Price in src/Promo/PromoCalculator.cs so that
`dotnet test` passes. The contract is docs/promo-spec.md and the tests in
tests/Promo.Tests.
Rules:
- Do not edit anything under tests/ or docs/. If a test looks wrong, stop
and tell me which one and why.
- Run `dotnet test` after each change and report the summary line.
- Do not add behaviour the spec doesn't describe.
- Finish when all tests pass. Show the final summary line.
The third rule is the one people skip, and it’s what makes the mutation run informative later. An agent that adds a 15% code “because it seemed useful” produces behaviour with no test, and Stryker will say so.
Here’s an implementation that satisfies the tests. An agent’s version will differ in shape, and the tests don’t care:
namespace Promo;
public sealed record PriceResult(decimal Discount, decimal Total, string? Error = null);
public static class PromoCalculator
{
public static PriceResult Price(decimal subtotal, IReadOnlyList<string> codes)
{
if (subtotal <= 0m) return Reject("invalid_subtotal");
if (codes.Count > 1) return Reject("one_code_only");
if (codes.Count == 0) return new PriceResult(0.00m, subtotal);
decimal discount;
switch (codes[0].Trim().ToUpperInvariant())
{
case "SAVE10":
discount = Math.Min(subtotal * 0.10m, 20.00m);
break;
case "FIVER":
if (subtotal < 30.00m) return Reject("minimum_not_met");
discount = 5.00m;
break;
case "WELCOME":
discount = 8.00m;
break;
default:
return Reject("unknown_code");
}
discount = Math.Round(Math.Min(discount, subtotal), 2, MidpointRounding.AwayFromZero);
return new PriceResult(discount, subtotal - discount);
}
private static PriceResult Reject(string error) => new(0m, 0m, error);
}
Run dotnet test again:
Passed! - Failed: 0, Passed: 13, Skipped: 0, Total: 13, Duration: 462 ms - Promo.Tests.dll (net10.0)
Green. Most workflows stop here.
Ask the tests what they don’t know
Stryker.NET changes the code a little at a time (flip < to <=, blank a string, drop a statement) and reruns the tests for each change. A mutant that makes a test fail is killed. One that doesn’t is a behaviour your tests can’t tell apart from the original.
Install it as a local tool from the solution root. Version 5.0.0 was the latest on NuGet on 29 October 2026 and installs on the .NET 10 SDK. On the .NET 8 SDK (8.0.133) it failed with Settings file 'DotnetToolSettings.xml' was not found in the package, and --version 4.16.0 installed instead, so pin that if you’re stuck on .NET 8:
dotnet new tool-manifest
dotnet tool install dotnet-stryker
cd tests/Promo.Tests
dotnet stryker --project Promo.csproj --reporter json --mutate "**/PromoCalculator.cs"
Two details from Stryker’s configuration docs: --project takes a project file name and not a path, and you run the command from the test project’s folder. The json reporter writes a report under StrykerOutput, and it’s the easiest thing to read from a script or an agent. With the full tests above, the run takes about 30 seconds for this project and ends with:
The final mutation score is 100.00 %
Sixteen mutants tested, sixteen killed. Seven more were skipped as compile errors, which Stryker reports and ignores. That’s the result the thirteen tests earned, and every row of the table had a part in it.
What a thinner spec leaves behind
A spec with a few happy-path examples looks complete too. To see what it costs, trim the tests to one row per code and a single rejection:
{ 50.00m, ["SAVE10"], 5.00m, 45.00m },
{ 300.00m, ["SAVE10"], 20.00m, 280.00m },
{ 50.00m, ["FIVER"], 5.00m, 45.00m },
{ 50.00m, ["WELCOME"], 8.00m, 42.00m },
{ 50.00m, [], 0.00m, 50.00m },
// rejected: { 50.00m, ["NOPE"], "unknown_code" }
Eight tests, all green. The mutation score drops to 68.75 percent, and the JSON report names the survivors:
Survived line 9: subtotal <= 0m -> subtotal < 0m
Survived line 20: subtotal < 30.00m -> subtotal <= 30.00m
NoCoverage line 9: "invalid_subtotal" -> ""
NoCoverage line 10: "one_code_only" -> ""
NoCoverage line 20: "minimum_not_met" -> ""
Each line is a missing spec row. The two survivors are exactly the boundary bugs from the opening: a subtotal of exactly zero, and a subtotal of exactly 30.00. The three NoCoverage lines are error codes no test asserts, so a typo in one_code_only ships. The fix for every line is the same, and it’s an edit to docs/promo-spec.md first: add the 30.00 row, the 29.99 row, the zero-subtotal row and the two-code row, then the tests, then rerun. Put the full tests back and the score returns to 100.
That’s the loop in miniature. The agent never needed to be smarter. The spec needed one more row.
Make it yours
Existing codebase: run Stryker with --since:main so only the code changed on the branch is mutated, which keeps the run short enough for a pull request. Without it, mutating a whole service can take far longer than the 30 seconds here.
TypeScript front end: the same loop applies with Vitest and StrykerJS. The tool names differ, the rule doesn’t. This piece didn’t run that variant, so check StrykerJS’s own docs for its install and options.
Team habit: keep the spec next to the code and make the examples table the thing reviewers read first. A pull request that changes a rule without changing a row is the one to question.
Other agents: nothing above depends on one. The prompt asks for a shell, dotnet test and the ability to leave files alone.
What goes wrong
The agent edits the tests. It’s the standard failure of this approach: a failing test is the fastest obstacle to remove. Tell it not to, and check git diff --stat tests/ before reading anything else.
The spec is wrong. Tests derived from a wrong example pass against the wrong behaviour, and mutation testing blesses them. A 100 percent score says the tests pin what the code does. Whether the table is what the business wanted is a human question.
Equivalent mutants. Some changes can’t alter behaviour (x * 1, a reordered pair of independent checks), so no test can kill them. A handful of survivors that you’ve read and judged harmless is normal. A list of survivors nobody has read isn’t.
Cost. Stryker reruns the tests per mutant. Sixteen mutants took about half a minute here. A big service with a slow suite will take much longer, which is why --since and --mutate exist.
Money. The examples only catch decimal and rounding problems if you put them in. The 12.25 row exists because a human thought about midpoints. Nothing forces an agent to pick a midpoint when it writes its own examples.
When not to bother
Throwaway scripts, glue code and layout work, where the cost of a missed boundary is a shrug. Rules you can’t state as examples either: if the owner can’t say what the right answer is for a given input, a table won’t help and a conversation will. And legacy code with no spec at all, where characterisation tests are the better first move.
First 15 minutes
- Pick one rule from your backlog that has numbers, thresholds or “only when” in it.
- Write it as rules plus six to ten examples, with at least one example either side of every boundary.
- Turn the rows into failing tests against a stub, and confirm they all fail.
- Hand the spec and tests to your agent with the four rules above.
- Run Stryker on the one file it changed, and add a row for every survivor.