Мутационное тестирование в C#: ломаем код, чтобы сделать его лучше

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Когда тесты проходят, но код всё равно «сломан»

Представьте ситуацию: вы написали модульные тесты для 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:

Блоги

Рекомендаций по книгам