Skip to content

Commit 75412af

Browse files
sigmadeIEvangelist
andauthored
Mutation Testing (#44615)
* Mutation Testing * added row to toc.yml * cleaned * Apply suggestions from code review * Update mutation testing documentation with Stryker.NET tool. * Apply suggestions from code review * Update docs/core/testing/mutation-testing.md Co-authored-by: David Pine <david.pine@microsoft.com> * Update docs/core/testing/mutation-testing.md Co-authored-by: David Pine <david.pine@microsoft.com> * Update docs/core/testing/mutation-testing.md Co-authored-by: David Pine <david.pine@microsoft.com> * Apply suggestions from code review --------- Co-authored-by: David Pine <david.pine@microsoft.com>
1 parent f7c7339 commit 75412af

File tree

6 files changed

+174
-0
lines changed

6 files changed

+174
-0
lines changed
Loading
Loading
Loading
Loading

docs/core/testing/mutation-testing.md

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
---
2+
title: Mutation testing
3+
author: sigmade
4+
description: Learn about the Stryker.net tool for mutation testing, to evaluate the quality of your unit tests.
5+
ms.date: 03/11/2025
6+
---
7+
8+
# Mutation testing
9+
10+
Mutation testing is a way to evaluate the quality of our unit tests. For mutation testing, the **Stryker.NET** tool automatically performs mutations in your code, runs tests, and generates a detailed report with the results.
11+
12+
## Example test scenario
13+
14+
Consider a sample _PriceCalculator.cs_ class with a `Calculate` method that calculates the price, taking into account the discount.
15+
16+
```csharp
17+
public class PriceCalculator
18+
{
19+
public decimal CalculatePrice(decimal price, decimal discountPercent)
20+
{
21+
if (price <= 0)
22+
{
23+
throw new ArgumentException("Price must be greater than zero.");
24+
}
25+
26+
if (discountPercent < 0 || discountPercent > 100)
27+
{
28+
throw new ArgumentException("Discount percent must be between 0 and 100.");
29+
}
30+
31+
var discount = price * (discountPercent / 100);
32+
var discountedPrice = price - discount;
33+
34+
return Math.Round(discountedPrice, 2);
35+
}
36+
}
37+
```
38+
39+
The preceding method is covered by the following unit tests:
40+
41+
```csharp
42+
[Fact]
43+
public void ApplyDiscountCorrectly()
44+
{
45+
decimal price = 100;
46+
decimal discountPercent = 10;
47+
48+
var calculator = new PriceCalculator();
49+
50+
var result = calculator.CalculatePrice(price, discountPercent);
51+
52+
Assert.Equal(90.00m, result);
53+
}
54+
55+
[Fact]
56+
public void InvalidDiscountPercent_ShouldThrowException()
57+
{
58+
var calculator = new PriceCalculator();
59+
60+
Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(100, -1));
61+
Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(100, 101));
62+
}
63+
64+
[Fact]
65+
public void InvalidPrice_ShouldThrowException()
66+
{
67+
var calculator = new PriceCalculator();
68+
69+
Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(-10, 10));
70+
}
71+
```
72+
73+
The preceding code highlights two projects, one for the service that acts as a `PriceCalculator` and the other is the test project.
74+
75+
## Install the global tool
76+
77+
First, install **Stryker.NET**.
78+
To do this, you need to execute the command:
79+
80+
```dotnetcli
81+
dotnet tool install -g dotnet-stryker
82+
```
83+
84+
To run `stryker`, invoke it from the command line in the directory where the unit test project is located:
85+
86+
```dotnetcli
87+
dotnet stryker
88+
```
89+
90+
After the tests have run, a report is displayed in the console.
91+
92+
:::image type="content" source="media/stryker-console-report.png" lightbox="media/stryker-console-report.png" alt-text="Stryker console report":::
93+
94+
**Stryker.NET** saves a detailed HTML report in the StrykerOutput directory.
95+
96+
:::image type="content" source="media/stryker-first-report.png" lightbox="media/stryker-first-report.png" alt-text="Stryker first report":::
97+
98+
Now, consider what mutants are and what 'survived' and 'killed' mean. A mutant is a small change in your code that Stryker makes on purpose. The idea is simple: if your tests are good, they should catch the change and fail. If they still pass, your tests might not be strong enough.
99+
100+
In our example, a mutant will be the replacement of the expression `price <= 0`, for example, with `price < 0`, after which unit tests are run.
101+
102+
Stryker supports several types of mutations:
103+
104+
| Type | Description |
105+
|--|--|
106+
| Equivalent | The equivalent operator is used to replace an operator with its equivalent. For example, `x < y` becomes `x <= y`. |
107+
| Arithmetic | The arithmetic operator is used to replace an arithmetic operator with its equivalent. For example, `x + y` becomes `x - y`. |
108+
| String | The string operator is used to replace a string with its equivalent. For example, `"text"` becomes `""`. |
109+
| Logical | The logical operator is used to replace a logical operator with its equivalent. For example, `x && y` becomes `x \|\| y`. |
110+
111+
For additional mutation types, see the [Stryker.NET: Mutations](https://stryker-mutator.io/docs/stryker-net/mutations) documentation.
112+
113+
## Incremental improvement
114+
115+
If, after changing your code, the unit tests pass successfully, then they aren't sufficiently robust, and the mutant survived.
116+
After mutation testing, five mutants survive.
117+
118+
Let's add test data for boundary values and run mutation testing again.
119+
120+
```csharp
121+
[Fact]
122+
public void InvalidPrice_ShouldThrowException()
123+
{
124+
var calculator = new PriceCalculator();
125+
126+
// changed price from -10 to 0
127+
Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(0, 10));
128+
}
129+
130+
[Fact] // Added test for 0 and 100 discount
131+
public void NoExceptionForZeroAnd100Discount()
132+
{
133+
var calculator = new PriceCalculator();
134+
135+
var exceptionWhen0 = Record.Exception(() => calculator.CalculatePrice(100, 0));
136+
var exceptionWhen100 = Record.Exception(() => calculator.CalculatePrice(100, 100));
137+
138+
Assert.Null(exceptionWhen0);
139+
Assert.Null(exceptionWhen100);
140+
}
141+
```
142+
143+
:::image type="content" source="media/stryker-second-report.png" lightbox="media/stryker-second-report.png" alt-text="Stryker second report":::
144+
145+
As you can see, after correcting the equivalent mutants, we only have string mutations left, which we can easily 'kill' by checking the text of the exception message.
146+
147+
```csharp
148+
[Fact]
149+
public void InvalidDiscountPercent_ShouldThrowExceptionWithCorrectMessage()
150+
{
151+
var calculator = new PriceCalculator();
152+
153+
var ex1 = Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(100, -1));
154+
Assert.Equal("Discount percent must be between 0 and 100.", ex1.Message);
155+
156+
var ex2 = Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(100, 101));
157+
Assert.Equal("Discount percent must be between 0 and 100.", ex2.Message);
158+
}
159+
160+
[Fact]
161+
public void InvalidPrice_ShouldThrowExceptionWithCorrectMessage()
162+
{
163+
var calculator = new PriceCalculator();
164+
165+
var ex = Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(0, 10));
166+
Assert.Equal("Price must be greater than zero.", ex.Message);
167+
}
168+
```
169+
170+
:::image type="content" source="media/stryker-final-report.png" lightbox="media/stryker-final-report.png" alt-text="Stryker final report":::
171+
172+
Mutation testing helps to find opportunities to improve tests that make them more reliable. It forces you to check not only the 'happy path', but also complex boundary cases, reducing the likelihood of bugs in production.

docs/navigate/devops-testing/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ items:
5555
displayName: tutorials, cli
5656
- name: Generate unit tests with GitHub Copilot
5757
href: ../../core/testing/unit-testing-with-copilot.md
58+
- name: Mutation testing
59+
href: ../../core/testing/mutation-testing.md
5860
- name: NUnit
5961
items:
6062
- name: C# unit testing

0 commit comments

Comments
 (0)