Stryker.NET: Mutation Testing for C# and .NET Projects

Stryker.NET: Mutation Testing for C# and .NET Projects

Stryker.NET is the .NET port of the Stryker mutation testing framework. It works with xUnit, NUnit, and MSTest, integrates with the .NET CLI, and produces the same mutation score reports that help you identify exactly which parts of your C# code aren't genuinely tested.

Key Takeaways

  • Stryker.NET installs as a .NET global tool — dotnet tool install -g dotnet-stryker is all you need to start.
  • Supports all major .NET test frameworks — xUnit, NUnit, and MSTest work out of the box.
  • Mutation types match the Stryker family — arithmetic, logical, string, conditional, LINQ, and statement mutations.
  • LINQ mutations are unique to .NET — Where → WhereNot, Any → All, First → Last are C#-specific mutations that catch business logic gaps.
  • --since flag enables incremental runs — only mutate changed files, making mutation testing practical in PR workflows.

The Stryker mutation testing framework started in JavaScript and has since expanded to support multiple languages. The .NET port, Stryker.NET, brings the same gray-box mutation analysis to C# projects. If your team writes business logic in C#, Stryker.NET is the most direct way to find tests that pass but don't actually verify anything.

Why Mutation Testing for C#

C# projects frequently achieve high line coverage through integration tests that exercise code paths without verifying outcomes. A service method gets called, the test asserts it didn't throw, and coverage shows green.

Mutation testing exposes this pattern. If you change a business rule — flip a comparison operator, remove a null check, change a LINQ filter — and the tests still pass, the tests aren't testing the rule.

This is especially common in:

  • Domain services with complex conditional logic
  • Repository methods with LINQ queries
  • Validation logic in model classes
  • Factory and builder patterns

Installation

dotnet tool install -g dotnet-stryker

Verify installation:

dotnet stryker --version

No other dependencies are required. Stryker.NET reads your project structure and test runner configuration automatically.

Running Stryker.NET

Navigate to your test project directory and run:

cd YourProject.Tests
dotnet stryker

Stryker discovers which production project the test project references and runs mutations against that code. For multi-project solutions:

dotnet stryker --solution ../YourSolution.sln

For a single project pair:

dotnet stryker --project YourProject.csproj

The first run takes time proportional to the number of mutants generated. A medium-sized project (10,000 lines) typically generates 2,000–5,000 mutants and takes 15–45 minutes.

Configuration File

Create stryker-config.json in your test project directory:

{
  "stryker-config": {
    "project": "YourProject.csproj",
    "test-runner": "dotnet-test",
    "reporters": ["html", "progress", "cleartext"],
    "mutation-level": "Standard",
    "thresholds": {
      "high": 80,
      "low": 60,
      "break": 50
    },
    "coverage-analysis": "perTest",
    "ignore-mutations": [
      "StringLiteral",
      "InterpolatedString"
    ],
    "mutate": [
      "**/*.cs",
      "!**/*Extensions.cs",
      "!**/Migrations/**/*.cs",
      "!**/*.Designer.cs"
    ]
  }
}

Key options:

mutation-level controls which mutant categories are applied:

  • Basic — arithmetic and boolean mutations only; fast
  • Standard — adds LINQ, string, and statement mutations (recommended)
  • Advanced — adds Regex and initializer mutations; slower

coverage-analysis: perTest — the most important performance option. Stryker maps which tests cover which code. Only those tests run for each mutant. With this enabled, a project that takes 45 minutes drops to 10–15 minutes.

ignore-mutations — exclude mutation types that generate low-value results. String literal mutations create many mutants in code with lots of logging, error messages, or configuration strings.

Mutation Types in C#

Arithmetic Mutations

// Original
return price * quantity;

// Mutants
return price / quantity;
return price + quantity;
return price - quantity;

Logical/Boolean Mutations

// Original
if (user.IsActive && !user.IsBlocked)

// Mutants
if (!user.IsActive && !user.IsBlocked)
if (user.IsActive || !user.IsBlocked)
if (user.IsActive && user.IsBlocked)

Comparison Mutations

// Original
if (balance >= minimumBalance)

// Mutants
if (balance > minimumBalance)
if (balance <= minimumBalance)
if (balance < minimumBalance)
if (balance == minimumBalance)
if (balance != minimumBalance)

LINQ Mutations (C#-specific)

LINQ mutations are one of Stryker.NET's most valuable features for .NET codebases:

// Original
var activeUsers = users.Where(u => u.IsActive).ToList();

// Mutants
var activeUsers = users.WhereNot(u => u.IsActive).ToList(); // filter inverted
var activeUsers = users.ToList(); // filter removed

// Original
return orders.Any(o => o.Status == OrderStatus.Pending);

// Mutants
return orders.All(o => o.Status == OrderStatus.Pending);
return !orders.Any(o => o.Status == OrderStatus.Pending);

// Original
var first = items.First(i => i.IsDefault);

// Mutants
var first = items.Last(i => i.IsDefault);
var first = items.FirstOrDefault(i => i.IsDefault);

If WhereNot survives, you're not testing that inactive users are excluded. This is exactly the kind of business logic bug that slips through coverage-based testing.

Null Coalescing Mutations

// Original
var name = user.DisplayName ?? user.Email;

// Mutants
var name = user.DisplayName;
var name = user.Email;

Statement Mutations

// Original
logger.LogError("Payment failed for order {orderId}", orderId);
await notificationService.SendAsync(userId, "Payment failed");
throw new PaymentException("Payment processing failed");

// Mutant: statement removed
await notificationService.SendAsync(userId, "Payment failed");
throw new PaymentException("Payment processing failed");

Statement removal mutants are valuable — they verify that each significant statement has observable effects that tests check.

Reading the HTML Report

# Report opens automatically after run
# Also saved to: StrykerOutput/YYYY-MM-DD.hh-mm-ss/reports/mutation-report.html

The HTML report shows your source files with each mutant highlighted:

  • Green = killed (tests caught this change)
  • Red = survived (no test caught this change)
  • Blue = no coverage

For each surviving mutant, you see:

  1. The exact code change Stryker made
  2. Which tests ran against this mutant (and all passed)
  3. The file and line number

Use this to write targeted tests rather than trying to guess what's missing.

Writing Tests to Kill Surviving Mutants

Pattern: Assertion-Per-Behavior

A common reason for surviving mutants is testing outcomes but not the inputs that affect them:

// Test that passes but doesn't kill the IsActive mutation
[Fact]
public async Task GetActiveUsers_ReturnsUsers()
{
    var users = await _service.GetActiveUsersAsync();
    Assert.NotEmpty(users);
}

// Test that kills the mutation
[Fact]
public async Task GetActiveUsers_ExcludesInactiveUsers()
{
    // Arrange
    _repository.Setup(r => r.GetAllUsersAsync()).ReturnsAsync(new[]
    {
        new User { Id = 1, IsActive = true },
        new User { Id = 2, IsActive = false },
        new User { Id = 3, IsActive = true },
    });

    // Act
    var users = await _service.GetActiveUsersAsync();

    // Assert
    Assert.Equal(2, users.Count);
    Assert.All(users, u => Assert.True(u.IsActive));
    Assert.DoesNotContain(users, u => u.Id == 2);
}

Pattern: Boundary Testing for Comparisons

Comparison mutations survive when tests don't check boundary values:

// Kills > and >= mutations
[Theory]
[InlineData(100, true)]    // exactly at minimum — passes >= but fails >
[InlineData(99, false)]    // just below minimum
[InlineData(101, true)]    // above minimum
public void CanWithdraw_RespectsMinimumBalance(decimal balance, bool expected)
{
    var account = new Account { Balance = balance };
    Assert.Equal(expected, account.CanWithdraw(1m, minimumBalance: 100m));
}

Incremental Runs for CI

Running full mutation testing on every commit is expensive. Use --since to only mutate changed files:

dotnet stryker --since:main

This compares the current branch to main, finds changed C# files, and only generates mutants for those files. Test execution is limited to tests that cover the changed code.

# .github/workflows/mutation.yml
name: Mutation Testing

on:
  pull_request:
    branches: [main]

jobs:
  stryker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # required for --since to work
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0'
      
      - name: Install Stryker
        run: dotnet tool install -g dotnet-stryker
      
      - name: Restore
        run: dotnet restore
      
      - name: Build
        run: dotnet build --no-restore
      
      - name: Run Stryker (changed files only)
        run: |
          cd YourProject.Tests
          dotnet stryker --since:origin/main --reporter html
      
      - name: Upload mutation report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: mutation-report
          path: '**/StrykerOutput/**/mutation-report.html'

The fetch-depth: 0 is required — a shallow clone doesn't have the history needed to diff against main.

Setting Thresholds

Prevent mutation score regression by configuring thresholds that fail the CI build:

{
  "stryker-config": {
    "thresholds": {
      "high": 80,
      "low": 65,
      "break": 60
    }
  }
}
  • Above high: green in the report
  • Between low and high: yellow; tests pass
  • Between break and low: orange; tests pass
  • Below break: Stryker exits with non-zero code; CI fails

Start with break at 0 (never fail) and raise it gradually as you improve coverage. Setting it too high immediately creates resistance — teams start excluding files to hit the threshold rather than improving tests.

Excluding Generated Code

Generated code (Entity Framework migrations, Protobuf-generated classes, auto-generated proxies) generates many mutants that aren't worth testing:

{
  "stryker-config": {
    "mutate": [
      "**/*.cs",
      "!**/Migrations/**",
      "!**/*.g.cs",
      "!**/*.Generated.cs",
      "!**/obj/**",
      "!**/Program.cs"
    ]
  }
}

Excluding these focuses mutation testing on business logic where the investment has the most impact.

Read more

Start now free