Spec First and Mutation Testing in .NET
By Syncfusion®, Inc. · Last updated
Make C# tests fail on the old code first, turn ticket criteria into xUnit tests, and measure them with mutants: Stryker.NET in real projects, hand-made mutants in the lab, and how to read the report.
Spec First and Mutants, in C#
Two ideas from the main course matter most when an agent writes C#:
- Fail first from Spec First: Tests Before Code: a new test must be red on the old code.
- Mutation testing from Auditing AI-Written Tests: tests must kill small planted bugs.
In .NET, the mutation tool is Stryker.NET. In this lesson, you will run both ideas in C#, and read a real Stryker.NET report.
Simple lesson: red first, then green, then mutants. The same steps in every language.
A Real-World Example
Think about a new lock fitted on a door.
- Before you trust it, you try your key on the old lock. It should not open the new one. That is fail first.
- Then you try the new key. It opens. That is green.
- Then you try ten keys that are almost right. None should open it. Those are the mutants.
What You Will Learn
In this lesson, you will learn:
- How to show a C# test fails on the old code first.
- How to turn ticket criteria into xUnit tests.
- What Stryker.NET does, and how to read its report.
- How to kill surviving mutants with exact checks at the edges.
Fail First in C#
The ticket is SHOP-42, the SAVE10 coupon from Spec First: Tests Before Code:
- SAVE10 gives 10% off orders of ₹500 or more.
- Orders under ₹500 pay the full price.
- The discount is never more than ₹200.
- Any case works: save10, Save10, SAVE10.
- Any other code throws
ArgumentException.
Red on the old code, green on the new
using Xunit;
using DevTutorials.Lab;
// In a real project you would run `dotnet test` on the old commit, then on the new one.
// Here the lab runs the same xUnit class twice, with each version of the code.
Console.WriteLine("Old code (no coupons yet):");
Shop.ApplyCoupon = Shop.OldCoupon;
Console.WriteLine(XunitLab.Report(XunitLab.Run(typeof(CouponTests))));
Console.WriteLine();
Console.WriteLine("New code:");
Shop.ApplyCoupon = Shop.NewCoupon;
Console.WriteLine(XunitLab.Report(XunitLab.Run(typeof(CouponTests))));
public class CouponTests
{
[Fact]
public void TenPercentOffOneThousand() => Assert.Equal(900m, Shop.ApplyCoupon(1000m, "SAVE10"));
}
public static class Shop
{
public static Func<decimal, string, decimal> ApplyCoupon = NewCoupon;
public static decimal OldCoupon(decimal total, string code) => total;
public static decimal NewCoupon(decimal total, string code)
{
if (code.Trim().ToUpperInvariant() != "SAVE10") throw new ArgumentException("Unknown coupon");
if (total < 500m) return total;
decimal off = Math.Min(Math.Round(total * 10 / 100, 2, MidpointRounding.AwayFromZero), 200m);
return total - off;
}
}
Press Run.
- Old code: ✗ red. xUnit says "Expected: 900, Actual: 1000".
- New code: ✓ green.
XunitLab.Runis the lab's small test host. It runs a real xUnit class, so you can run it twice in one box.
XunitLab exists only in this lab. In a real project, dotnet test finds and runs your test classes. The tests themselves ([Fact], Assert.Equal) are the same real xUnit code in both places.
Simple lesson: a test that was never red has not proved anything yet.
The Agent's First Try
The agent forgot criterion 3
using Xunit;
public class CouponTests
{
[Fact]
public void TenPercentOffOneThousand() => Assert.Equal(900m, Coupon.Apply(1000m, "SAVE10"));
[Fact]
public void NeverMoreThanTwoHundredOff() => Assert.Equal(4800m, Coupon.Apply(5000m, "SAVE10"));
}
// The agent's first try.
public static class Coupon
{
public static decimal Apply(decimal total, string code)
{
if (code.Trim().ToUpperInvariant() != "SAVE10") throw new ArgumentException("Unknown coupon");
if (total < 500m) return total;
return total - Math.Round(total * 10 / 100, 2, MidpointRounding.AwayFromZero);
}
}
Press Run.
- The second test: red. "Expected: 4800, Actual: 4500".
- Fix it: wrap the discount in
Math.Min(..., 200m).
The Edge in the Words
Exactly ₹500
using Xunit;
public class CouponEdgeTests
{
[Theory]
[InlineData(499, 499)]
[InlineData(500, 450)]
[InlineData(2000, 1800)]
[InlineData(5000, 4800)]
public void SaveTenEdges(int total, int expected) => Assert.Equal((decimal)expected, Coupon.Apply(total, "SAVE10"));
}
public static class Coupon
{
public static decimal Apply(decimal total, string code)
{
if (total <= 500m) return total;
return total - Math.Min(Math.Round(total * 10 / 100, 2, MidpointRounding.AwayFromZero), 200m);
}
}
Press Run.
- The ₹500 row: red. The code uses
<=where the ticket says "₹500 or more". - The other rows: green. So you need both sides of the edge, and the cap at ₹2000.
Simple lesson: every "or more", "at least" and "never more than" in a ticket needs a test on each side.
Stryker.NET
What It Does
Stryker.NET makes mutants of your C# code and runs your tests on each one.
dotnet tool install -g dotnet-stryker
cd tests/Shop.Tests
dotnet stryker --break-at 80
--break-at 80makes the run fail when the mutation score is below 80%. That turns it into a gate.- It writes an HTML report. Each survivor shows the line and the change.
A config file keeps the settings in the repo:
{
"stryker-config": {
"project": "Shop.csproj",
"thresholds": { "high": 90, "low": 80, "break": 80 },
"mutation-level": "Standard"
}
}
Mutants by Hand
The lab cannot run Stryker.NET. So here the mutants are written by hand, the way Stryker makes them. Your real xUnit class runs once on the real code and once on each mutant.
The AI's tests against six mutants
using Xunit;
using DevTutorials.Lab;
// Runs the xUnit class on the real code, then on six mutants.
Console.Write(XunitLab.Grade(typeof(ShippingTests),
("correct code", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg)),
("mutant: total >= 499 → total > 499", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg, freeAtLimit: false)),
("mutant: 499 → 500", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg, freeFrom: 500m)),
("mutant: 40 → 41", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg, baseFee: 41m)),
("mutant: kg > 5 → kg >= 5", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg, heavyAtLimit: true)),
("mutant: 5 → 6", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg, heavyFrom: 6m)),
("mutant: 20 → 21", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg, extra: 21m))));
// The AI's tests: every line of Fee runs, but the checks are loose.
public class ShippingTests
{
[Fact]
public void SmallOrdersPay() => Assert.True(Shop.ShippingFee(100m, 1m) > 0);
[Fact]
public void BigOrdersAreFree() => Assert.Equal(0m, Shop.ShippingFee(1000m, 1m));
[Fact]
public void HeavyOrdersWork() => Assert.True(Shop.ShippingFee(100m, 10m) >= 0);
}
public static class Shop
{
public static Func<decimal, decimal, decimal> ShippingFee = (total, kg) => 0m;
// Free from ₹499. Otherwise ₹40, plus ₹20 more over 5 kg.
public static decimal Fee(decimal total, decimal kg, bool freeAtLimit = true, decimal freeFrom = 499m,
decimal baseFee = 40m, bool heavyAtLimit = false, decimal heavyFrom = 5m, decimal extra = 20m)
{
if (freeAtLimit ? total >= freeFrom : total > freeFrom) return 0m;
decimal fee = baseFee;
if (heavyAtLimit ? kg >= heavyFrom : kg > heavyFrom) fee += extra;
return fee;
}
}
Press Run.
- The output: the real code passes, and every mutant says escaped. Mutation score 0%.
- Why:
Assert.True(fee > 0)passes for 40, 41 and 60.
Equivalent mutants. Sometimes a mutant changes the code but not what it does. No test can kill it. Stryker.NET marks some of these for you. That is why teams ask for 80%, not 100%.
Kill Them With Exact Edges
Exact checks on both sides of each limit
using Xunit;
using DevTutorials.Lab;
Console.Write(XunitLab.Grade(typeof(ShippingTests),
("correct code", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg)),
("mutant: total >= 499 → total > 499", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg, freeAtLimit: false)),
("mutant: 499 → 500", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg, freeFrom: 500m)),
("mutant: 40 → 41", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg, baseFee: 41m)),
("mutant: kg > 5 → kg >= 5", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg, heavyAtLimit: true)),
("mutant: 5 → 6", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg, heavyFrom: 6m)),
("mutant: 20 → 21", () => Shop.ShippingFee = (t, kg) => Shop.Fee(t, kg, extra: 21m))));
public class ShippingTests
{
[Theory]
[InlineData(498, 1, 40)]
[InlineData(499, 1, 0)]
[InlineData(100, 5, 40)]
[InlineData(100, 6, 60)]
public void Fee(int total, int kg, int expected) => Assert.Equal((decimal)expected, Shop.ShippingFee(total, kg));
}
public static class Shop
{
public static Func<decimal, decimal, decimal> ShippingFee = (total, kg) => 0m;
// Free from ₹499. Otherwise ₹40, plus ₹20 more over 5 kg.
public static decimal Fee(decimal total, decimal kg, bool freeAtLimit = true, decimal freeFrom = 499m,
decimal baseFee = 40m, bool heavyAtLimit = false, decimal heavyFrom = 5m, decimal extra = 20m)
{
if (freeAtLimit ? total >= freeFrom : total > freeFrom) return 0m;
decimal fee = baseFee;
if (heavyAtLimit ? kg >= heavyFrom : kg > heavyFrom) fee += extra;
return fee;
}
}
Press Run.
- The output: all six mutants caught (killed). Mutation score 100%.
- Why: each check has an exact answer, and the checks sit on both sides of ₹499 and 5 kg.
Simple lesson: loose checks let mutants live. Exact checks at the edges kill them.
Reading a Stryker.NET Report
A real report lists each survivor like this:
[Survived] Equality mutation on line 12: 'total >= 499m' ==> 'total > 499m'
[Survived] Arithmetic mutation on line 14: 'fee += 20m' ==> 'fee -= 20m'
[Killed] Boolean mutation on line 18
Mutation score: 66.67 % (threshold: break 80)
Final mutation score of 66.67 is below the break threshold of 80
How to act on it:
- Open the line of each Survived mutant.
- Ask: "Which input gives a different answer with this change?"
- Write a test with that input and its exact answer.
- Run Stryker.NET again.
Do not lower the threshold to get green. If an agent changes "break": 80 to "break": 50, that is the same trick as .skip. Your test-diff guard should treat config files that hold gates as protected.
Simple lesson: each survivor is a missing test. The report tells you exactly where.
TL;DR
- Fail first = run the new test on the old code. It must be red.
- One test per criterion, and both sides of every edge.
- Stryker.NET = mutation testing for .NET:
dotnet stryker --break-at 80. - Survived = no test noticed the change. Killed = a test failed.
- Equivalent mutant = a change that does not change behaviour. It cannot be killed.
- Kill survivors with exact
Assert.Equalchecks at the edges. - Never lower the threshold to get green.
Common mistakes
| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| Never seeing red | "It passed on the new code, done." | Run it on the old code first. | The test cannot see the change. |
Assert.True(x > 0) | "It checks the fee is set." | Assert.Equal(40m, fee). | 41 and 60 also pass. |
| One side of an edge | "₹1000 gets the discount." | Test ₹499 and ₹500. | <= vs < slips through. |
| Chasing 100% | "Kill every mutant." | Aim for the threshold. Some are equivalent. | Time is wasted on mutants that change nothing. |
Lowering break | "80 is too strict today." | Add tests instead. | The gate stops meaning anything. |
Check Your Understanding
Exercises
What to Remember
- Run every new test on the old code first. Red, then green.
- One test per criterion. Both sides of every edge.
- Stryker.NET finds tests that run code without checking it.
- Each survivor is a missing test. Kill it with an exact check.
- Never lower the threshold to get green.
Further Reading
- Stryker.NET documentation: install, config and thresholds.
- xUnit theories: facts, theories and
dotnet test.
Next: Properties and Integration in .NET. You will test rules on random inputs, check API contracts with System.Text.Json, and pin old code before a refactor.