Mutation Testing with Stryker.NET: Are Your Tests Actually Good?

Code coverage tells you which lines your tests execute. It says nothing about whether your tests would catch a bug on those lines. You can have 100% coverage with tests that never assert anything meaningful. Mutation testing flips the question: instead of asking "did my tests run this code?", it asks "would my tests catch it if this code were wrong?"

How Mutation Testing Works

Stryker.NET modifies your source code — creating "mutants" — and runs your tests against each mutation. If a test fails, the mutant is "killed" (good — your tests caught the change). If all tests pass despite the mutation, the mutant "survived" (bad — your tests missed a potential bug).

Common mutations include:

Installation

Stryker.NET is installed as a .NET tool:

Example.cs
dotnet tool install -g dotnet-stryker

Running Your First Mutation Test

Navigate to your test project and run:

Example.cs
dotnet stryker --project MyApp.csproj

The --project flag points to the production project you want to mutate. Stryker finds the test project automatically from the current directory.

After the run completes, Stryker generates an HTML report showing every mutant, whether it was killed or survived, and the exact code change.

Reading the Results

Mutation score: 78.5%

Killed:   157
Survived:  43
Timeout:    5
No coverage: 12

The mutation score is the percentage of mutants killed. A score of 78.5% means your tests caught 78.5% of the deliberate bugs Stryker introduced. The surviving mutants are the interesting ones — each represents a change to your code that your tests didn't notice.

A Practical Example

Consider this discount calculator:

Example.cs
public class DiscountCalculator
{
    public decimal CalculateDiscount(decimal orderTotal, CustomerTier tier)
    {
        if (orderTotal <= 0)
            throw new ArgumentException("Order total must be positive");

        return tier switch
        {
            CustomerTier.Bronze => orderTotal * 0.05m,
            CustomerTier.Silver => orderTotal * 0.10m,
            CustomerTier.Gold => orderTotal * 0.15m,
            _ => 0m
        };
    }
}

And these tests:

Example.cs
public class DiscountCalculatorTests
{
    private readonly DiscountCalculator _calculator = new();

    [Fact]
    public void CalculateDiscount_GoldTier_Returns15Percent()
    {
        var result = _calculator.CalculateDiscount(100m, CustomerTier.Gold);
        Assert.Equal(15m, result);
    }

    [Fact]
    public void CalculateDiscount_NegativeTotal_ThrowsArgumentException()
    {
        Assert.Throws<ArgumentException>(
            () => _calculator.CalculateDiscount(-10m, CustomerTier.Gold));
    }
}

These tests look reasonable. But Stryker would find surviving mutants:

The fix: add tests for the missing scenarios:

Example.cs
[Theory]
[InlineData(CustomerTier.Bronze, 5)]
[InlineData(CustomerTier.Silver, 10)]
[InlineData(CustomerTier.Gold, 15)]
public void CalculateDiscount_AllTiers_ReturnsCorrectPercentage(
    CustomerTier tier, decimal expectedDiscount)
{
    var result = _calculator.CalculateDiscount(100m, tier);
    Assert.Equal(expectedDiscount, result);
}

[Fact]
public void CalculateDiscount_ZeroTotal_ThrowsArgumentException()
{
    Assert.Throws<ArgumentException>(
        () => _calculator.CalculateDiscount(0m, CustomerTier.Gold));
}

Configuration

Create a stryker-config.json in your test project for persistent configuration:

stryker-config.json
{
    "stryker-config": {
        "project": "MyApp.csproj",
        "reporters": ["html", "progress"],
        "thresholds": {
            "high": 80,
            "low": 60,
            "break": 50
        },
        "mutate": [
            "!**/Migrations/**",
            "!**/Program.cs"
        ]
    }
}

The thresholds section is particularly useful for CI: break fails the build if the mutation score drops below the specified percentage.

The mutate property accepts glob patterns. The ! prefix excludes patterns — here, we skip EF migrations and the application entry point.

Performance Considerations

Mutation testing is slow. Stryker creates hundreds or thousands of mutants and runs your test suite against each one. For a medium-sized project, this can take 10-30 minutes.

Strategies to manage this:

What Mutation Score to Aim For

Don't chase 100%. Some surviving mutants are genuinely not worth testing — logging calls, defensive null checks in infrastructure code, configuration defaults. A mutation score of 80%+ for business logic is a good target. Focus on killing mutants in your domain and application layers, where bugs have the highest impact.

Mutation testing doesn't replace code coverage — it complements it. Coverage tells you what's untested; mutation testing tells you what's poorly tested. Together, they give you a much clearer picture of your test suite's actual effectiveness.