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

Юнит тесты помогают нам удостовериться, что код работает так, как мы этого хотим. Одной из метрик тестов является процент покрытия строк кода (Line Code Coverage).

Но насколько корректен данный показатель? Имеет ли он практический смысл и можем ли мы ему доверять? Ведь если мы удалим все assert строки из тестов, или просто заменим их на assertSame(1, 1), то по-прежнему будем иметь 100% Code Coverage, при этом тесты ровным счетом не будут тестировать ничего.

Насколько вы уверены в своих тестах? Покрывают ли они все ветки выполнения ваших функций? Тестируют ли они вообще хоть что-нибудь?

Ответ на этот вопрос даёт мутационное тестирование.

Мутационное тестирование — это метод тестирования ПО, основанный на всевозможных изменениях исходного кода и проверке реакции на эти изменения набора автоматических тестов. Если тесты после изменения кода успешно выполняются, значит либо код не покрыт тестами, либо написанные тесты неэффективны. Критерий, определяющий эффективность набора автоматических тестов, называется Mutation Score Indicator (MSI).

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

Для применения этой технологии у нас, очевидно, должен быть исходный код (source code), некоторый набор тестов (для простоты будем говорить о модульных — unit tests).

После этого можно начинать изменять отдельные части исходного кода и смотреть, как реагируют на это тесты.

Одно изменение исходного кода будем называть Мутацией (Mutation). Например, изменение бинарного оператора "+" на бинарный "-" является мутацией кода.

Результатом мутации является Мутант (Mutant) — то есть это новый мутированный исходный код.

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

Кроме изменения "+" на "-", существует множество других мутационных операторов (Mutation Operator, Mutator) — отрицание условий, изменение возвращаемого значения функции, удаление строк кода и т.д.

Итак, мутационное тестирование создает множество мутантов из вашего кода, для каждого из них запускает тесты и проверяет, выполнились они успешно или нет. Если тесты упали — значит всё хорошо, они отреагировали на изменение в коде и поймали ошибку. Такой мутант считается убитым (Killed mutant). Если тесты выполнились успешно после мутирования — это говорит о том, что либо ваш код не покрыт в этом месте тестами вовсе, либо тесты, покрывающие мутированную строку, неэффективны и в недостаточной степени тестируют данный участок кода. Такой мутант называется выжившим (Survived, Escaped Mutant).

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

Рассмотрим пример. Будем использовать мутационный фреймворк (МФ) для PHP — Infection.

Пусть у нас есть какой-то фильтр, умеющий отфильтровывать коллекцию пользователей по признаку совершеннолетия, написанный в объектно-ориентированном стиле:

class UserFilterAge
{
    const AGE_THRESHOLD = 18;
    public function __invoke(array $collection)
    {
        return array_filter(
            $collection,
            function (array $item) {
                return $item['age'] >= self::AGE_THRESHOLD;
            }
        );
    }
}

И для этого фильтра есть юнит тест:

public function test_it_filters_adults()
{
    $filter = new UserFilterAge();
    $users = [
        ['age' => 20],
        ['age' => 15],
    ];
    $this->assertCount(1, $filter($users));
}

Тест очень простой — добавляем двух пользователей и ожидаем, что фильтр вернет только одного из них, возраст которого 20 лет.

Заметьте, при наличии только лишь этого теста, мы уже имеем 100% покрытие исходного кода класса UserFilterAge. Запустим мутационное тестирование и проанализируем результат:

./infection.phar --threads=4

При стопроцентном покрытии кода мы имеем только 67% MSI — это уже подозрительно.

Как считается MSI

Давайте посмотрим на сгенерированные мутации.

Первая мутация:

class UserFilterAge
{
    const AGE_THRESHOLD = 18;
    public function __invoke(array $collection)
    {
        return array_filter(
            $collection,
            function (array $item) {
-                return $item['age'] >= self::AGE_THRESHOLD;
+                return $item['age'] > self::AGE_THRESHOLD;
            }
        );
    }
}

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

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

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

Давайте исправим ситуацию и убьём мутанта:

/**
 * @dataProvider usersProvider
 */
public function test_it_filters_adults(array $users, int $expectedCount)
{
    $filter = new UserFilterAge();
    $this->assertCount($expectedCount, $filter($users));
}
public function usersProvider()
{
    return [
        [
            [
                ['age' => 15],
                ['age' => 20],
            ],
            1
        ],
        [
            [
                ['age' => 18],
            ],
            1
        ]
    ];
}

Мы добавили один тест на граничное значение — 18. Теперь, если опять запустить тесты с мутированным кодом, они упадут, так как все значения отфильтруются и вернется пустая коллекция, что, естественно, неверно.

Вторая мутация:

class UserFilterAge
{
    const AGE_THRESHOLD = 18;
    public function __invoke(array $collection)
    {
-       return array_filter(
+       array_filter(
            $collection,
            function (array $item) {
                return $item['age'] >= self::AGE_THRESHOLD;
            }
        );
+      return null;
    }
}

Тут не сразу очевидно, что произошло. Это довольно интересный мутационный оператор, заменяющий вызов функции в выражении "return functionCall();" на "functionCall(); return null;".

Но почему вообще произошла такая мутация? Верно ли возвращать null, когда мы ожидаем отфильтрованный массив? Конечно не верно, и происходит это, потому что мы не указали тип возвращаемого значения в функции. МФ видит, что возвращаемое значение может быть null, и пытается подсунуть его. Infection довольно умный в этом плане, и, если функция содержит конкретный тип (не nullable, например int) возвращаемого значения, то мутировать код не будет. Анализируя этого мутанта, приходим к выводу, что надо добавить typehint:

-    public function __invoke(array $collection)
+    public function __invoke(array $collection): array

Теперь сигнатура метода абсолютна понятна — передаем в фильтр массив, ожидаем массив.

Запустим еще раз и проверим результат:

Количество мутаций ожидаемо уменьшилось из-за добавления типа возвращаемого значения, и все мутанты убиты. Теперь мы имеем не только Code Coverage 100%, но и Mutation Code Coverage 100%, что является гораздо более показательным критерием качества ваших тестов.

Этот простой пример показывает, что даже при наличии стопроцентного покрытия кода тестами, мутационное тестирование все еще может выявить проблемы и как бы покрывает ваш код «на более чем 100%».

Если еще не прониклись, рассмотрим мутационные операторы помощнее — PublicVisibility и ProtectedVisibility. Смысл их в том, чтобы для каждого метода класса (кроме некоторых магических и абстрактных) менять модификатор доступа с public на protected, с protected на private.

Это позволяет проверить необходимость открытости методов. Если такие мутанты оказываются выжившими, то можно сделать вывод, что публичный интерфейс вашего класса может быть уменьшен и, скорее всего, является избыточным. А в случае с ProtectedVisibility оператором — выживший мутант говорит о том, что метод должен быть изменен на private и нет ни одного наследника у класса, который бы использовал/переопределял родительский protected метод.

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

./infection.php --threads=4 --show-mutations --mutators=PublicVisibility,ProtectedVisibility

Кроме этих двух случаев с выжившим и убитым мутантом, есть и другие. Например, изменение в цикле унарного оператора "++" у переменной счетчика на "--" может привести к тому, что цикл никогда не закончится, т.к. будет бесконечным. Задача фреймворка для мутационного тестирования — корректно обрабатывать такие ситуцаии и помечать мутанта особым статусом — Timeout. Такой исход является положительным и мутант не считается выжившим.

В целом, с теорией разобрались, теперь посмотрим, что представляет собой Infection более детально, и какие есть еще альтернативы для PHP.

Infection PHP

Для работы, Infection требует установленное расширение xDebug для Code Coverage и PHP 7.0+.

Рекомендуемым способом установки, с возможностью автоматического обновления (infection.phar self-update), является Phar архив.

На данный момент поддерживаются из коробки два фреймворка для тестирования — PHPUnit (5, 6+) и PhpSpec.

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

Мутационное тестирование в целом требует человеческого анализа, поэтому все сгенерированные мутации после завершения работы МФ попадают в лог файл в той же папке — infection-log.txt.

Опции

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

--threads

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

--show-mutations

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

--mutators

Перечисление мутационных операторов, мутирующих код. Удобно, если вы например хотите проверить только PublicVisibility и ProtectedVisibility операторы.

./infection.phar --mutators=PublicVisibility,ProtectedVisibility
--min-msi и --min-covered-msi

Эти две опции полезны, если вы запускаете Infection как один из шагов процесса сборки вашего проекта на Continious Integration сервере.

--min-msi позволяет указать минимальное значение (в процентах) Mutation Score Indicator. Если указанное значение будет меньше фактического, то билд упадет. Данная опция заставляет при каждом билде покрывать большее количество строк кода.

--min-covered-msi соответственно позволяет указать минимальное значение Covered Code MSI. Данная опция при каждом билде заставляет писать более эффективные и надежные тесты.

Обе опции могут использоваться как по отдельности, так и вместе.

./infection.phar --min-msi=80 --min-covered-msi=95

Использование с Travis CI

before_script:
    - wget https://github.com/infection/infection/releases/download/0.5.0/infection.phar
    - wget https://github.com/infection/infection/releases/download/0.5.0/infection.phar.pubkey
    - chmod +x infection.phar
script:
    - ./infection.phar --min-covered-msi=90 --threads=4

Каждый релиз (Phar архив) подписывается приватным openssl ключом, поэтому кроме самого архива вам необходимо скачивать и публичный ключ.

Как использовать мутационное тестирование?

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

Ежедневное использование для разработчика

Мутационное тестирование может быть полезно в ежедневной работе при написании новых тестов. Схема работы выглядит примерно так:

  • вы написали новый функционал, например тот же UserFilterAge из примера выше
  • этот код уже покрыт тестами
  • для проверки тестов, вы запускаете мутационное тестирование только для этого файла
./infection.phar --threads=4 --filter=UserFilterAge.php --show-mutations

Анализируете выжившие мутанты и пытаетесь добиться хорошего показателя Covered Code MSI — т.е. чтобы процент убитых мутантов из всех сгенерированных для покрытого тестами кода стремился к 100. Это позволит максимально эффективно писать тесты.

При использовании МТ вы заметите, что пишите более лаконичный код с большим количеством тестов. При этом будет использовано покрытие путей (branch coverage), когда все пути вашего кода протестированы, вместо обычного покрытия строк кода (line coverage).

Ежедневное использование в проекте

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

На мой взгляд, генерируя лишь только отчет, хороших показателей не добиться, поэтому лучше использовать опции --min-msi и/или --min-covered-msi.

Например, мутационный фреймворк Infection мутационно тестирует сам себя при каждом билде. И если показатели падают, билд тоже падает.

При постоянном использовании МТ, показатели MSI в проекте будут расти и вы сможете постепенно увеличивать значения опций --min-msi и --min-covered-msi.

Почему иногда невозможно добиться 100% MSI?

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

public function calculateExpectedValueAt(DateTimeInterface $date)
{
    $diffInDays = (int) $this->startedAt->diff($date)->format('%a');
    $multiplier = $this->initialValue < $this->targetValue ? 1 : -1;
    $initialAveragePerDay = $this->calculateInitialAveragePerDay();
-    return $this->initialValue + ($initialAveragePerDay * $diffInDays * $multiplier);
+    return $this->initialValue + ($initialAveragePerDay * $diffInDays / $multiplier);
}

Смысл в том, что умножение числа и деление числа на ±1 приводит к идентичному результату, и такой мутант оказывается выжившим.

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

Альтернативы для PHP

Единственной полноценной работающей альтернативой для Infection в PHP является Humbug — это вообще первый МФ в PHP. Из плюсов, в нем есть экспериментальная поддержка кеширования мутаций (incremental cache). То есть если какой-то файл не меняется и при этом никакие тесты, покрывающие его строки не удалялись при очередном запуске, то мутация не запускается и берется результат последнего запуска. Теоретически, это может значительно увеличить скорость работы, но может привести к ложным срабатываниям и погрешностям в метриках.

С другой стороны, Humbug пока что не поддерживает PHPUnit 6+ и PhpSpec. Однако, главным отличием между Infection и Humbug на текущий момент является то, что Infection использует абстрактное синтаксическое дерево для мутирования кода (Abstract Syntax Tree (AST)). Построение AST возможно благодaря замечательному проекту Никиты Попова — PHP-Parser.

Что же дает использование AST? Рассмотрим подробнее.

Чтобы начать мутировать код, необходимо

  • разбить код файла на токены (функция token_get_all()), сложить их в массив
  • пробежаться по массиву и каждый токен, если это необходимо, заменить на другой, согласно мутационному оператору
  • из нового набора токенов собрать новый мутированный исходный код

Пример токенов

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

  • Находимся ли мы в теле функции? Заменять T_OPEN_TAG ('<?php ') нет никакого смысла
  • Будет ли валидным код после мутации? (например, сложение массивов ['a'] + ['b'] — это валидный код. А вот вычитание массивов ['a'] - ['b'] — это уже Fatal Error. Следовательно такую мутацию делать не нужно, и МФ должен проверять, не находится ли токен сложения между массивами.

В результате, используя массив токенов, ответить на эти вопросы довольно затруднительно с точки зрения кода. Напротив, имея абстрактное синтаксическое дерево, это легко сделать, оперируя объектами, представляющими исходным код (Node\Expr\BinaryOp\Plus, Node\Expr\BinaryOp\Minus, Node\Expr\Array_).

Вот как выглядят реализации мутационного оператора, меняющего "+" на "-" с проверкой массивов:

Infection

Humbug

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


В общем, мутационное тестирование — это еще одно средство для повышения качества ваших тестов и кода в целом, стоящее обращения внимания.

Если у вас есть опыт использования МТ на реальных проектах, или вы попробуете Infection и найдете интересные ошибки в коде — делитесь в комментариях о любых полезных кейсах.