Skip to content

Мутационный анализ

Изменено: at 15:22Предложить изменение

Мутационный анализ: проверка качества тестирования программного обеспечения

Введение

В современной разработке программного обеспечения тестирование играет ключевую роль в обеспечении качества продукта. Однако возникает закономерный вопрос: насколько качественны сами тесты? Кто может дать гарантию того, что при внесении ошибки в код ваши тесты гарантированно обнаружат проблему? Мутационное тестирование предлагает ответ на этот вопрос, предоставляя метод для оценки эффективности тестовых наборов.

Мутационное тестирование — это техника, при которой в исходный код программы вносятся небольшие изменения (мутации), а затем запускаются тесты для проверки, обнаруживают ли они эти изменения. Если тесты не обнаруживают мутацию, это может указывать на недостатки в тестовом наборе.

Математическое обоснование мутационного тестирования

Мутационное тестирование имеет строгое математическое обоснование, основанное на гипотезе компетентного программиста (Competent Programmer Hypothesis, CPH) и гипотезе связи ошибок (Coupling Effect Hypothesis, CEH).

Гипотеза компетентного программиста предполагает, что программисты создают программы, которые близки к корректным. Это означает, что ошибки в программах обычно представляют собой небольшие синтаксические изменения, которые не сильно отличаются от правильной версии. Формально это можно выразить так:

PN(P)P' \in N(P)

Где PP — корректная программа, PP' — программа с ошибкой, а N(P)N(P) — окрестность программы PP, состоящая из программ, которые отличаются от PP небольшими синтаксическими изменениями.

Гипотеза связи ошибок утверждает, что сложные ошибки связаны с простыми ошибками таким образом, что тестовый набор, который обнаруживает все простые ошибки, будет обнаруживать и высокий процент сложных ошибок. Математически это можно представить так:

tT:(mM:t(P)t(Pm))(cC:t(P)t(Pc))\forall t \in T: (\forall m \in M: t(P) \neq t(P_m)) \Rightarrow (\forall c \in C: t(P) \neq t(P_c))

где TT — набор тестов, MM — множество простых мутаций, CC — множество сложных ошибок, PmP_m — программа с простой мутацией mm , а PcP_c — программа со сложной ошибкой cc .

Эффективность тестового набора TT для программы PP можно оценить с помощью мутационного счета (mutation score):

MS(P,T)=DMEMS(P, T) = \frac{D}{M - E}

Где DD — количество обнаруженных (убитых) мутантов, MM — общее количество мутантов, а EE — количество эквивалентных мутантов (тех, которые не могут быть обнаружены никакими тестами).

Мутационный счет принимает значения от 0 до 1, где 1 означает, что все неэквивалентные мутанты были обнаружены тестами, что свидетельствует о высоком качестве тестового набора.

В данной статье мы рассмотрим принципы мутационного тестирования, виды мутаций, критерии применимости и практические примеры использования этой техники для улучшения качества тестирования.

general_scheme.png

Тестирование и его парадигмы

Прежде чем погрузиться в мутационное тестирование, давайте рассмотрим существующие подходы к тестированию:

Основная цель любого тестирования — проверка соответствия программного обеспечения требованиям. Особенно важно тщательное тестирование в областях повышенного риска:

История знает немало случаев, когда недостаточное тестирование приводило к серьезным последствиям, включая аварии с автопилотами и системами управления.

Существующие парадигмы тестирования включают:

testing_paradigms.png

Определение мутационного тестирования

Мутационное тестирование — это преднамеренное внесение в код специальных изменений (мутаций) с целью проверки эффективности программного обеспечения и его тестов.

Мутационный анализ — это мероприятие для выявления потенциально опасных изменений кода и последующий рефакторинг для минимизации рисков.

Существует два основных способа сопротивления мутациям:

  1. Типы и компиляция (статический анализ)
  2. Наборы тестов (динамический анализ)

Хороший тест должен обнаруживать разницу между оригинальной и мутированной программой. Математически это можно выразить так:

tT:t(SM)t(S)∃ t ∈ T: t(SM) ≠ t(S)

Где T — набор тестов, S — исходная программа, SM — мутированная программа.

Пример мутации

Рассмотрим простой пример:

public static bool Method(bool a, bool b)
{
    if (a && b)
        return true;
    return false;
}

Мутация может заменить оператор && на ||:

public static bool Method(bool a, bool b)
{
    if (a || b)  // мутация && -> ||
        return true;
    return false;
}

Для проверки этой мутации недостаточно такого теста:

[Theory]
[InlineData(false, false)]
public void Method_Data_ReturnFalse(bool a, bool b)
{
    // Act
    var result = SampleClass.Method(a, b);
    // Assert
    Assert.False(result);
}

Этот тест не обнаружит мутацию, так как для входных данных (false, false) оба варианта метода (оригинальный и мутированный) вернут false. Чтобы обнаружить мутацию, необходимо расширить набор тестовых данных:

[Theory]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
public void Method_Data_ReturnFalse(bool a, bool b)
{
    // Act
    var result = SampleClass.Method(a, b);
    // Assert
    Assert.False(result);
}

Теперь тест обнаружит мутацию, так как для входных данных (true, false) и (false, true) оригинальный метод вернет false, а мутированный — true.

Условия для обнаружения мутации

Для того чтобы тест обнаружил мутацию, должны быть соблюдены следующие условия:

  1. Покрытие мутанта тестом — тест должен достигать мутированного кода (покрытие этих строк должно быть 100%)
  2. Полные входные данные — входные данные для теста должны быть достаточными, чтобы выявить разницу в поведении между оригинальным и мутированным кодом
  3. Выходные параметры проверены — значение выходных параметров должно проверяться в тестах

ma_levels_of_implementing.png

Виды мутаций

Мутации — это не хаотичные изменения кода, а точно выверенные модификации, которые могут возникнуть из-за человеческого фактора или при рефакторинге приложения. Мутации можно разделить на несколько категорий:

mutatios_types.png

1. ООП мутации (зависят от языка и его реализации)

2. Общие языковые мутации

3. Специфические мутации для языка и библиотек

Например, для LINQ в C#:

Примеры выживших мутаций и их влияние на тесты и код

Статистика показывает, что после применения мутационного тестирования:

Существует два способа борьбы с мутациями:

  1. Честный — изменение кода или тестов для обнаружения мутации
  2. Нечестный — перенос зоны ответственности за код туда, где мутационное тестирование не может быть применено

Строковый мутант

Самый распространенный выживший мутант — строковый. Рассмотрим пример:

private static bool ExceptionMethod(bool con)
{
    if (!con)
        throw new ArgumentException("USER_DB is not path pattern user_adminXXXX", nameof(con));
    return true;
}

public static string LibraryMethod(bool con)
{
    try
    {
        if (ExceptionMethod(con)) return "Successfully";
    }
    catch (ArgumentException e)
    { 
        throw new Exception("Something happened", e); 
    }
    return string.Empty;
}

Тест, который не обнаруживает строковую мутацию:

[Theory]
[InlineData(false)]
public void LibraryMethod_NegativeData_ThrowException(bool val)
{
    // Act
    var ex = Assert.Throws<Exception>(() => SampleClass.LibraryMethod(val));
    // Assert
    Assert.Equal("Something happened", ex.Message);
}

Если в коде изменится текст исключения, тест этого не обнаружит. Правильный тест должен проверять и внутреннее исключение:

[Theory]
[InlineData(false)]
public void LibraryMethod_NegativeData_ThrowException(bool val)
{
    // Act
    var ex = Assert.Throws<Exception>(() => SampleClass.LibraryMethod(val));
    // Assert
    Assert.Equal("Something happened", ex.Message);
    Assert.Equal("USER_DB is not path pattern user_adminXXXX (Parameter 'con')",
        ex.InnerException?.Message);
}

Практический пример применения строковых мутаций

В реальном проекте Ecwid был обнаружен баг при использовании мутационного тестирования. Сервис не мог запуститься, когда один из токенов был невалидный, а не когда все были невалидные. Тесты были написаны, исключения генерировались, но тексты ошибок не проверялись должным образом:

[Fact]
public void Credentials_Fail()
{
    Assert.Throws<Exception>(() => new Ecwid(0, Token, Token));
    Assert.Throws<Exception>(() => new Ecwid(ShopId));
    Assert.Throws<Exception>(() => new Ecwid(ShopId, " ", Token));
    Assert.Throws<Exception>(() => new Ecwid(ShopId, Token, " "));
}

После применения мутационного тестирования и обнаружения выживших строковых мутантов, тесты были доработаны для проверки конкретных сообщений об ошибках, что позволило выявить логическую ошибку в обработке невалидных токенов.

Мутация сравнения

Второй по распространенности тип мутаций — мутации операторов сравнения. Рассмотрим пример:

public static int Limit(int limit)
{
    if (limit < 0)
        throw new ArgumentException("Limit must be greater than 0", nameof(limit));
    if (limit > 99) limit = 100;
    return limit;
}

Недостаточные тесты:

[Theory]
[InlineData(101)]
public void Limit_Greater100_ReturnCorrectData(int value)
{
    Assert.Equal(100, SampleClass.Limit(value));
}

[Theory]
[InlineData(0)]
public void Limit_Smaller100_ReturnCorrectData(int value)
{
    Assert.Equal(value, SampleClass.Limit(value));
}

Эти тесты не обнаружат мутацию > на >=. Правильные тесты должны включать граничное значение:

[Theory]
[InlineData(100)]
[InlineData(101)]
public void Limit_Greater100_ReturnCorrectData(int value)
{
    Assert.Equal(100, SampleClass.Limit(value));
}

[Theory]
[InlineData(0)]
[InlineData(99)]
public void Limit_Smaller100_ReturnCorrectData(int value)
{
    Assert.Equal(value, SampleClass.Limit(value));
}

Практический пример применения мутаций сравнения

В проекте по разработке API для платежной системы мутационное тестирование выявило критическую уязвимость в коде обработки лимитов транзакций:

public bool IsTransactionValid(decimal amount, decimal dailyLimit)
{
    // Проверка других условий...
    
    if (amount > dailyLimit)
    {
        Logger.LogWarning($"Transaction exceeds daily limit: {amount}");
        return false;
    }
    
    return true;
}

Тесты для этого метода проверяли только случаи, когда сумма значительно превышала лимит или была значительно меньше. Мутационное тестирование создало мутацию, заменив > на >=, и все тесты прошли успешно, что указывало на недостаточное тестирование граничных условий.

После доработки тестов для проверки граничного случая (когда сумма равна лимиту) команда обнаружила, что в некоторых случаях транзакции на сумму, равную дневному лимиту, неправильно отклонялись, что приводило к ухудшению пользовательского опыта.

LINQ мутации

LINQ мутации включают замену методов расширения, таких как First на Last, Any на All, FirstOrDefault на Default. Например:

public static async Task<string?> GetValueAsync(string url)
{
    var values = await GetFromUrlAsync(url);
    // ... other work
    return values.FirstOrDefault();
}

Тест, который не обнаруживает мутацию FirstOrDefault() на LastOrDefault():

[Fact]
public async Task GetValue_ReturnCorrectDataAsync()
{
    //... mock data с одним элементом
    var result = await SampleClass.GetValueAsync("some");
    Assert.Equal("some", result);
}

Для обнаружения такой мутации необходимо тестировать с коллекцией, содержащей несколько элементов, где первый и последний элементы различаются.

Практический пример применения LINQ мутаций

В системе для анализа клиентских данных был обнаружен потенциальный баг благодаря мутационному тестированию LINQ-выражений. Метод, отвечающий за получение данных клиента, использовал FirstOrDefault() для выбора записи из результатов запроса:

public async Task<ClientData> GetClientDataAsync(string clientId)
{
    var records = await _repository.GetClientRecordsAsync(clientId);
    return records.FirstOrDefault();
}

Мутационное тестирование заменило FirstOrDefault() на LastOrDefault(), и все тесты прошли успешно. Это заставило команду глубже изучить логику работы с данными и обнаружить, что при определенных условиях API хранилища может возвращать несколько записей для одного клиента, отсортированных по региональным настройкам.

В результате этого открытия метод был модифицирован для явного указания сортировки:

public async Task<ClientData> GetClientDataAsync(string clientId)
{
    var records = await _repository.GetClientRecordsAsync(clientId);
    return records.OrderBy(r => r.CreatedAt).FirstOrDefault();
}

Это предотвратило потенциальную ошибку, которая могла бы проявиться только в продакшене при определенных региональных настройках.

Эквивалентные мутации

Эквивалентные мутации — это мутации, которые не могут быть обнаружены никакими тестами, так как они не изменяют поведение программы. Например:

public static int ForMethod()
{
    var index = 0;
    while (true)
    {
        index++;
        if (index == 10) // мутация на >=10 не изменит поведение
            break;
    }
    return index;
}

Более сложный пример эквивалентной мутации:

public static void Remove(IList<Value> container, Value? data)
{
    if (data == null) return;
    if (container.All(c => c.SomeField != data.SomeField)) return;
    container.Remove(data);
}

Здесь мутация условия c.SomeField != data.SomeField на c.SomeField == data.SomeField изменит логику, но может остаться необнаруженной, если тесты не проверяют все возможные сценарии.

Работа с эквивалентными мутациями часто приводит к рефакторингу кода. Например, приведенный выше метод можно переписать так:

public static void Remove(IList<Value> container, Value? data)
{
    if (data == null) return;
    var deleteT = container.SingleOrDefault(c => c.SomeField == data.SomeField);
    if (deleteT == null) return;
    container.Remove(deleteT);
}

Практический пример работы с эквивалентными мутациями

В проекте по разработке системы управления контентом была обнаружена эквивалентная мутация в методе, отвечающем за удаление элементов из коллекции:

public void RemoveItem(Container container, Item item)
{
    if (item == null) return;
    
    if (container.Items.All(i => i.Id != item.Id)) return;
    
    container.Items.Remove(item);
}

Мутационное тестирование создало мутацию, изменив условие i.Id != item.Id на i.Id == item.Id, и тесты не смогли обнаружить эту мутацию. Анализ показал, что логика метода была некорректной: даже если условие не выполнится, удаление может не произойти, если объект item не содержится в коллекции (сравнение по ссылке).

После рефакторинга метод стал более надежным:

public void RemoveItem(Container container, Item item)
{
    if (item == null) return;
    
    var itemToRemove = container.Items.SingleOrDefault(i => i.Id == item.Id);
    if (itemToRemove == null) return;
    
    container.Items.Remove(itemToRemove);
}

Дальнейший анализ выявил еще одну потенциальную проблему: что делать, если в коллекции несколько элементов с одинаковым Id? Это привело к дополнительному тесту и обработке исключения:

[Fact]
public void RemoveItem_DuplicateIds_ThrowsException()
{
    // Arrange
    var container = new Container();
    var id = Guid.NewGuid();
    container.Items.Add(new Item { Id = id });
    container.Items.Add(new Item { Id = id });
    var itemToRemove = new Item { Id = id };
    
    // Act & Assert
    Assert.Throws<InvalidOperationException>(() => _service.RemoveItem(container, itemToRemove));
}

Этот пример показывает, как работа с эквивалентными мутациями может привести к обнаружению логических ошибок и улучшению дизайна кода.

Арифметические мутации

Арифметические мутации заменяют арифметические операторы, например, + на -, * на / и т.д. Рассмотрим пример:

public decimal CalculateDiscount(decimal price, decimal discountPercent)
{
    return price - (price * discountPercent / 100);
}

Практический пример применения арифметических мутаций

В финансовом приложении для расчета налогов мутационное тестирование выявило недостаточное тестирование метода расчета суммы с НДС:

public decimal CalculateWithVAT(decimal amount, decimal vatRate)
{
    return amount + (amount * vatRate / 100);
}

Мутационное тестирование создало несколько мутаций, заменив + на -, * на / и / на *. Некоторые из этих мутаций выжили, что указывало на недостаточное разнообразие тестовых данных.

После анализа были добавлены тесты с различными значениями суммы и ставки НДС, включая граничные случаи (нулевая сумма, нулевая ставка, отрицательная сумма). Это позволило обнаружить ошибку в обработке отрицательных сумм, которая могла привести к неправильным расчетам при оформлении возвратов.

Мутации булевых значений

Мутации булевых значений заменяют true на false и наоборот. Рассмотрим пример:

public bool IsValidEmail(string email)
{
    if (string.IsNullOrEmpty(email))
        return false;
    
    // Проверка формата email
    return true;
}

Практический пример применения мутаций булевых значений

В системе аутентификации мутационное тестирование выявило проблему в методе проверки учетных данных:

public async Task<bool> ValidateCredentialsAsync(string username, string password)
{
    var user = await _userRepository.GetUserByUsernameAsync(username);
    if (user == null)
        return false;
    
    if (!VerifyPasswordHash(password, user.PasswordHash))
        return false;
    
    if (!user.IsActive)
        return false;
    
    return true;
}

Мутационное тестирование заменило возвращаемое значение false на true в различных ветвях кода, и некоторые из этих мутаций выжили. Анализ показал, что тесты проверяли только положительный сценарий (валидные учетные данные) и один негативный сценарий (неверный пароль).

После доработки тестов для проверки всех возможных сценариев (несуществующий пользователь, неактивный пользователь) была обнаружена уязвимость: при определенных условиях неактивные пользователи могли пройти аутентификацию из-за ошибки в логике проверки.

Инструментарий для мутационного тестирования

Stryker

Stryker — это инструмент для мутационного тестирования, доступный для различных языков программирования, включая .NET Core. Особенности Stryker:

stryker_benefits.png

Как работает Stryker:

  1. Запускает все тесты для проверки их корректности и замера времени выполнения
  2. Получает из проекта все файлы с кодом и строит для них синтаксические деревья
  3. Генерирует мутации по правилам для определенных выражений
  4. Компилирует мутированный код
  5. Инъектирует мутированный код в тестовый проект
  6. Запускает тесты для каждой мутации
  7. Собирает статистику и формирует отчет

Создание собственного мутатора

Вы можете создать собственный мутатор, реализовав интерфейс:

interface IMutator {
    IEnumerable<Mutation> Mutate(SyntaxNode node);
}

abstract class MutatorBase<T> where T : SyntaxNode {
    abstract IEnumerable<Mutation> ApplyMutations(T node);

    public IEnumerable<Mutation> Mutate(SyntaxNode node) {
        if (node is T tNode) return ApplyMutations(tNode);
        else return Enumerable.Empty<Mutation>();
    }
}

Пример реализации мутатора для булевых значений:

Dictionary<SyntaxKind, SyntaxKind> _kindsToMutate { get; }

BooleanMutator() {
    _kindsToMutate = new Dictionary<SyntaxKind, SyntaxKind> {
        {SyntaxKind.TrueLiteralExpression, SyntaxKind.FalseLiteralExpression },
        {SyntaxKind.FalseLiteralExpression, SyntaxKind.TrueLiteralExpression }
    };
}

override IEnumerable<Mutation> ApplyMutations(LiteralExpressionSyntax node) {
    if (_kindsToMutate.ContainsKey(node.Kind())) {
        yield return new Mutation() {
            OriginalNode = node,
            ReplacementNode = SyntaxFactory.LiteralExpression(_kindsToMutate[node.Kind()]),
            DisplayName = "Boolean mutation",
            Type = Mutator.Boolean
        };
    }
}

Критерии применимости мутационного тестирования

Не все проекты подходят для мутационного тестирования. Вот основные критерии применимости:

ma_criteria.png

  1. Высокое покрытие кода тестами (70-90%) — без этого тесты не смогут обнаружить многие мутации, и вы получите множество ложно положительных результатов
  2. Быстрые тесты — поскольку для каждой мутации запускается весь набор тестов, медленные тесты сделают процесс мутационного тестирования неприемлемо долгим
  3. Чистые маленькие методы — код, состоящий из небольших функций с четкой ответственностью, легче тестировать и анализировать
  4. Использование .NET Core — основной инструмент (Stryker) работает только с .NET Core
  5. Готовность к экспериментам — мутационное тестирование требует времени и усилий, особенно на начальных этапах

Интеграция мутационного тестирования в CI/CD

Мутационное тестирование можно интегрировать в конвейер непрерывной интеграции (CI/CD). Stryker предоставляет счетчики, при которых сборка может быть помечена как неуспешная, если уровень обнаружения мутаций ниже заданного порога.

Однако стоит помнить, что мутационное тестирование требует значительных вычислительных ресурсов, поэтому его применение для интеграционных тестов может быть проблематичным.

Интересно отметить, что тестировать следует не только код, но и сам конвейер CI/CD. Например, при использовании AppVeyor для сборки проектов на GitHub, изменение логики обработки ошибок на билд-агентах может привести к тому, что сборка будет помечена как успешная, даже если некоторые тесты не прошли.

Заключение

Мутационное тестирование представляет собой мощный инструмент для оценки качества тестовых наборов, основанный на строгом математическом аппарате. Путем внесения небольших, контролируемых изменений в исходный код и последующего запуска тестов, данный метод позволяет выявить недостатки в тестовом покрытии, которые невозможно обнаружить традиционными методами анализа.

В ходе исследования было установлено, что мутационное тестирование эффективно выявляет три категории проблем: мертвый код (около 10% выживших мутаций), недостаточное качество тестов (около 50% выживших мутаций) и эквивалентные мутации, которые требуют рефакторинга для повышения надежности кода.

Практические примеры демонстрируют, что применение мутационного анализа позволяет обнаружить критические уязвимости в различных типах программных систем: от финансовых приложений до систем аутентификации. Особую ценность представляет возможность выявления проблем в обработке граничных условий, строковых значений и сложных логических выражений.

Несмотря на вычислительную сложность и необходимость соблюдения определенных критериев применимости, мутационное тестирование является перспективным направлением в области обеспечения качества программного обеспечения. Интеграция данного подхода в процесс разработки способствует повышению надежности программных систем, особенно в критически важных областях, где цена ошибки может быть чрезвычайно высокой.

Дальнейшие исследования в этой области могут быть направлены на разработку более эффективных алгоритмов обнаружения эквивалентных мутаций, а также на создание специализированных мутаторов для конкретных предметных областей и технологических стеков.

ma_conclusion.png

Литература и ссылки

  1. Assessing Test Quality by David Schuler
  2. Mutation Testing by Filip van Laenen
  3. Stryker Mutator
  4. Доклад про мутационное тестирование на конференции Dotnext
  5. GitHub репозиторий с примерами кода, запуска

Предыдущая статья
Цикл статей про Domain Driven Design и связанные с ним подходы к разработке ПО