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-strykeris 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→Lastare C#-specific mutations that catch business logic gaps. --sinceflag 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-strykerVerify installation:
dotnet stryker --versionNo 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 strykerStryker discovers which production project the test project references and runs mutations against that code. For multi-project solutions:
dotnet stryker --solution ../YourSolution.slnFor a single project pair:
dotnet stryker --project YourProject.csprojThe 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; fastStandard— 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.htmlThe 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:
- The exact code change Stryker made
- Which tests ran against this mutant (and all passed)
- 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:mainThis 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
lowandhigh: yellow; tests pass - Between
breakandlow: 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.