Mutation Testing — Verifying Test Quality with Stryker.NET

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
Note: The most revealing surviving mutants for the BlogApp’s service layer are typically on the string literals in status checks ("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.
Tip: Run Stryker only on the service layer (not controllers, repositories, or DTOs) for the best signal-to-noise ratio. Controllers are largely tested by integration tests (not in scope for Stryker’s unit test analysis). Repositories just call EF Core. DTOs have no logic. Service classes are where the business rules live and where mutation testing provides the most insight about test quality. Configure mutate: ["Services/**/*.cs"] to scope the analysis.
Warning: Stryker is significantly slower than regular test runs — it executes the full test suite for every mutant, and a single service class can have 50-100 mutants. Running Stryker on the entire codebase can take 30+ minutes. Use it selectively: run it on the service layer before a major release to validate test quality, and include it in a weekly scheduled CI run rather than every PR. Set 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.

🧠 Test Yourself

Stryker mutates post.ViewCount > 0 to post.ViewCount >= 0. The tests always use posts with ViewCount = 5. Does this mutant survive?