Когда тесты проходят, но код всё равно «сломан»
Представьте ситуацию: вы написали модульные тесты для C# проекта, dotnet test выдаёт зелёный свет, покрытие кода (code coverage) — 85%. Команда довольна, CI/CD прогоняется идеально. Но через месяц в production вылетает баг, который, казалось бы, должны были поймать юнит-тесты. Знакомо?
Проблема в том, что покрытие кода (code coverage) — это метрика-обманщица. Она показывает, какие строки кода были затронуты, но не проверяет качество самих проверок. Вы можете иметь 100% покрытие, но ни одного осмысленного assert'а. Именно здесь на сцену выходит мутационное тестирование в C# — методика, которая «ломает» ваш код, чтобы проверить, насколько хорошо ваши тесты умеют ловить ошибки.
В этой статье мы разберём, что такое mutation testing, как внедрить его в .NET проекты с помощью Stryker.NET, и почему это must-have для любого серьёзного разработчика на C#.
Что такое мутационное тестирование (Mutation Testing)?
Мутационное тестирование — это метод оценки качества тестов, основанный на внесении небольших преднамеренных изменений (мутаций) в исходный код программы. Каждая такая изменённая версия кода называется мутантом (mutant).
Логика простая:
- Берётся ваш production-код.
- Автоматически генерируются мутанты — например, оператор
+заменяется на-, условиеif (x > 5)меняется наif (x >= 5), аtrueнаfalse. - Запускаются ваши unit-тесты против каждого мутанта.
- Если тест падает — мутант «убит» (killed). Хороший тест.
- Если тест проходит — мутант «выжил» (survived). Плохой знак: ваш тест не заметил изменение логики.
Цель — добиться высокого процента убитых мутантов (обычно 80%+). Это реальная гарантия, что ваши тесты проверяют поведение, а не просто «пробегают» по коду.
Почему code coverage недостаточен?
Рассмотрим простой пример на C#. Допустим, у нас есть метод, который проверяет, является ли число чётным:
public class MathUtils
{
public bool IsEven(int number)
{
return number % 2 == 0;
}
}
И «идеальный» тест с 100% покрытием:
[Test]
public void IsEven_ShouldReturnTrueForEvenNumber()
{
var utils = new MathUtils();
var result = utils.IsEven(4);
Assert.IsTrue(result);
}
Тест зелёный, покрытие 100%. Но что, если кто-то случайно (или злонамеренно) изменит метод на return number % 2 == 1;? Наш единственный тест упадёт? Нет! Он проверит число 4 (4 % 2 == 0, но теперь метод вернёт false), assert упадёт — мутант будет убит. Всё хорошо? Погодите.
А если изменить код на return number % 2 != 1;? Логически это то же самое, но мутационное тестирование создаст мутанта: return number % 2 != 0;. Наш тест с числом 4 всё ещё пройдёт (4 % 2 != 0 — false, метод вернёт false, но assert ждёт true — тест упадёт). Тут всё ок. Но представьте, что мы написали тест так:
[Test]
public void IsEven_ShouldNotThrow()
{
var utils = new MathUtils();
Assert.DoesNotThrow(() => utils.IsEven(4));
}
Покрытие всё ещё 100%, но assert не проверяет результат! Теперь любой мутант выживет. Мутационное тестирование сразу покажет, что ваши тесты — пустышки.
Инструменты для мутационного тестирования в C#
Главный и фактически стандартный инструмент для .NET — Stryker.NET. Это open-source фреймворк, который легко интегрируется в любой C# проект. Он поддерживает xUnit, NUnit и MSTest.
Другие инструменты (менее популярные):
- VisualMutator — устаревший плагин для Visual Studio.
- Nester — экспериментальный инструмент.
Мы будем работать со Stryker — он активно развивается, имеет понятный HTML-отчёт и работает из командной строки.
Пошаговое внедрение Stryker в .NET проект
Шаг 1: Установка
Stryker устанавливается как глобальный dotnet-инструмент:
dotnet tool install -g dotnet-stryker
Шаг 2: Запуск
Перейдите в корневую папку вашего решения и выполните:
cd MySolution
dotnet stryker
Stryker сам найдёт тестовые проекты и предложит конфигурацию. Если вы хотите явно указать проект с тестами:
dotnet stryker --project "MyProject.csproj" --test-project "MyProject.Tests.csproj"
Шаг 3: Анализ результатов
После завершения Stryker создаст папку StrykerOutput с HTML-отчётом. Откройте его в браузере — вы увидите красные и зелёные строки кода. Зелёные — мутанты убиты, красные — выжили. Кликая на строки, можно увидеть, какие именно мутации были применены.
Реальный пример: мутационное тестирование в действии
Рассмотрим более сложный фрагмент — метод расчёта скидки:
public class DiscountCalculator
{
public decimal Calculate(decimal price, bool isPremiumMember)
{
if (price < 0)
throw new ArgumentException("Price cannot be negative");
if (isPremiumMember)
return price * 0.9m; // 10% скидка
else
return price * 0.95m; // 5% скидка
}
}
Напишем тесты, которые кажутся адекватными:
[Test]
public void Calculate_PremiumMember_ReturnsDiscountedPrice()
{
var calc = new DiscountCalculator();
var result = calc.Calculate(100, true);
Assert.AreEqual(90, result);
}
[Test]
public void Calculate_NonPremiumMember_ReturnsDiscountedPrice()
{
var calc = new DiscountCalculator();
var result = calc.Calculate(100, false);
Assert.AreEqual(95, result);
}
[Test]
public void Calculate_NegativePrice_ThrowsException()
{
var calc = new DiscountCalculator();
Assert.Throws<ArgumentException>(() => calc.Calculate(-1, false));
}
Запускаем Stryker. Что мы увидим?
- Мутант 1: