Mutation testing goes beyond code coverage — it verifies that tests can actually detect bugs. Stryker.NET modifies the production code (creates “mutants”) and runs the test suite. If tests fail when the code is mutated, the mutant is “killed” — the tests are catching that change. If tests pass with modified code, the mutant “survives” — revealing either missing assertions or tests that execute code without checking its effect. A high mutation score (>70%) indicates a genuinely effective test suite.
Stryker.NET Mutation Testing
// ── Installation and configuration ────────────────────────────────────────
// dotnet tool install -g dotnet-stryker
//
// stryker.config.json:
// {
// "stryker-config": {
// "project": "BlogApp.Api.csproj",
// "test-projects": ["BlogApp.UnitTests/BlogApp.UnitTests.csproj"],
// "mutation-level": "Standard",
// "reporters": ["progress", "html", "dashboard"],
// "threshold-high": 80,
// "threshold-low": 60,
// "break-at": 40,
// "mutate": [
// "BlogApp.Api/Services/**/*.cs", // only mutate service layer
// "!BlogApp.Api/Services/**/*Dto.cs" // exclude DTOs
// ]
// }
// }
//
// Run: dotnet stryker
// ── Understanding mutation types ───────────────────────────────────────────
// Stryker creates mutants by changing:
// - Arithmetic operators: + → -, * → /, % → *
// - Comparison operators: > → >=, == → !=, < → <=
// - Logical operators: && → ||, ! → (remove)
// - String literals: "review" → ""
// - Method calls: (remove the call entirely)
// - Conditionals: if (condition) → if (true), if (false)
// ── Example: original code ─────────────────────────────────────────────────
public async Task<PostDto> PublishAsync(int postId, CancellationToken ct)
{
var post = await _repo.GetByIdAsync(postId, ct)
?? throw new NotFoundException($"Post {postId} not found.");
if (post.AuthorId != _user.UserId) // ← mutated: != → ==
throw new ForbiddenException("...");
if (post.Status != "review") // ← mutated: != → ==, "review" → ""
throw new DomainException("...");
post.IsPublished = true; // ← mutated: true → false
post.Status = "published"; // ← mutated: "published" → ""
await _repo.UpdateAsync(post, ct);
return post.ToDto();
}
// ── Surviving mutants — these indicate weak tests ─────────────────────────
// SURVIVED: post.Status = "" (mutant of "published")
// Why survived? None of the tests assert on result.Status after PublishAsync!
// Fix: add assertion: result.Status.Should().Be("published");
// SURVIVED: post.IsPublished = false (mutant of = true)
// Why survived? Test asserts result.IsPublished but doesn't check the value!
// The test had: result.IsPublished.Should().NotBeNull() ← bug: null check only
// Fix: result.IsPublished.Should().BeTrue();
// ── Mutation score interpretation ─────────────────────────────────────────
// Score = Killed Mutants / Total Mutants × 100
// 80%+ = Good — tests catch most bugs
// 60-80% = Acceptable — some gaps, improve high-value code
// <60% = Poor — tests execute code but don't verify correctness
"review" → "") and on the boolean assignments (IsPublished = true → false). These survive when tests call the service and assert that it didn’t throw, but never assert on the actual state change. A test that only checks “no exception thrown” when the expectation is “post is now published” catches nothing about the publishing logic — exactly the class of bug mutation testing reveals.mutate: ["Services/**/*.cs"] to scope the analysis.break-at: 40 to fail only when mutation score falls below 40% — a sign of seriously inadequate tests.Common Mistakes
Mistake 1 — Tests that execute code without asserting results (survivors everywhere)
❌ Wrong — await _sut.PublishAsync(1, default); // no assertion; every mutant on the post-publish state survives.
✅ Correct — always assert on the return value AND the side effects: result.Status.Should().Be("published"); result.IsPublished.Should().BeTrue();
Mistake 2 — Running Stryker on the entire codebase (takes hours)
❌ Wrong — no mutate config; Stryker mutates all C# files including DTOs, migrations, controllers; 2-hour run for no added value.
✅ Correct — scope to service layer: "mutate": ["Services/**/*.cs"]; 10-minute focused analysis.