Написание тестов должно вселять в нас уверенность в корректной работе кода. Часто мы оперируем степенью покрытости кода, и когда достигаем 100 %, то можем сказать, что решение корректное. Уверены в этом? Быть может, есть инструмент, который даст более точную обратную связь?
Мутационное тестирование
Этот термин описывает ситуацию, когда мы изменяем маленькие куски кода и смотрим, как это влияет на тесты. Если после изменений тесты выполняются корректно, это говорит о том, что для этих фрагментов кода тестов недостаточно. Конечно, всё зависит от того, что именно мы меняем, поскольку нам не нужно тестировать все мельчайшие изменения, например, отступы или имена переменных, поскольку после них тесты тоже должны завершаться корректно. Поэтому в мутационных тестах мы используем так называемые мутаторы (методы-модификаторы), которые заменяют одни фрагменты кода другими, но так, чтобы это было осмысленно. Мы поговорим об этом подробнее ниже. Иногда мы проводим такие тесты самостоятельно, проверяя, сломаются ли тесты, если мы кое-что изменим в коде. Если мы отрефакторили «половину системы» и тесты всё ещё зелёные, то можно сразу сказать, что они плохие. А если кто-то сделал подобное и тесты оказались хорошими, тогда мои поздравления!
Инфекционный фреймворк
Сегодня в PHP самым популярным фреймворком для мутационного тестирования является Infection. Он поддерживает PHPUnit и PHPSpec, а для работы с ним требуются PHP 7.1+ и Xdebug или phpdbg.
Первый запуск и конфигурирование
При первом запуске мы видим интерактивный конфигуратор фреймворка, который создаёт особый файл с настройками — infection.json.dist. Выглядит он примерно так:
{
"timeout": 10,
"source": {
"directories": [
"src"
},
"logs": {
"text": "infection.log",
"perMutator": "per-mutator.md"
},
"mutators": {
"@default": true
}
Timeout
— опция, значение которой должно быть равно максимальной длительности исполнения одного теста. В source
мы указываем директории, из которых будем мутировать код, можно задавать исключения. В logs
есть опция text
, которой мы задаём сбор статистики только по ошибочным тестам, что для нас самое интересное. Опция perMutator
позволяет сохранять использованные мутаторы. Подробнее об этом рассказано в документации.Пример
final class Calculator
{
public function add(int $a, int $b): int
{
return $a + $b;
}
}
Допустим, у нас есть вышеописанный класс. Напишем тест в PHPUnit:
final class CalculatorTest extends TestCase
{
/**
* @var Calculator
*/
private $calculator;
public function setUp(): void
{
$this->calculator = new Calculator();
}
/**
* @dataProvider additionProvider
*/
public function testAdd(int $a, int $b, int $expected): void
{
$this->assertEquals($expected, $this->calculator->add($a, $b));
}
public function additionProvider(): array
{
return [
[0, 0, 0],
[6, 4, 10],
[-1, -2, -3],
[-2, 2, 0]
];
}
}
Конечно, этот тест нужно писать до того, как будем реализовывать метод
add()
. При выполнении./vendor/bin/phpunit
получим:PHPUnit 8.2.2 by Sebastian Bergmann and contributors.
.... 4 / 4 (100%)
Time: 39 ms, Memory: 4.00 MB
OK (4 tests, 4 assertions)
Теперь выполним
./vendor/bin/infection
:You are running Infection with Xdebug enabled.
____ ____ __ _
/ _/___ / __/__ _____/ /_(_)___ ____
/ // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ _/ // / / / __/ __/ /__/ /_/ / /_/ / / / /
/___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/
Running initial test suite...
PHPUnit version: 8.2.2
9 [============================] 1 sec
Generate mutants...
Processing source code files: 1/1Creating mutated files and processes: 0/2
Creating mutated files and processes: 2/2
.: killed, M: escaped, S: uncovered, E: fatal error, T: timed out
.. (2 / 2)
2 mutations were generated:
2 mutants were killed
0 mutants were not covered by tests
0 covered mutants were not detected
0 errors were encountered
0 time outs were encountered
Metrics:
Mutation Score Indicator (MSI): 100%
Mutation Code Coverage: 100%
Covered Code MSI: 100%
Please note that some mutants will inevitably be harmless (i.e. false positives).
Time: 1s. Memory: 10.00MB
Согласно Infection, наши тесты точны. В файле per-mutator.md мы можем посмотреть, какие были использованы мутации:
# Effects per Mutator
| Mutator | Mutations | Killed | Escaped | Errors | Timed Out | MSI | Covered MSI |
| ------- | --------- | ------ | ------- |------- | --------- | --- | ----------- |
| Plus | 1 | 1 | 0 | 0 | 0 | 100| 100|
| PublicVisibility | 1 | 1 | 0 | 0 | 0 | 100| 100|
Mutator Plus — простое изменение знака с плюса на минус, которое должно ломать тесты. А мутатор
PublicVisibility
меняет модификатор доступа этого метода, что тоже должно ломать тесты, и в данном случае это работает.Теперь добавим метод посложнее.
/**
* @param int[] $numbers
*/
public function findGreaterThan(array $numbers, int $threshold): array
{
return \array_values(\array_filter($numbers, static function (int $number) use ($threshold) {
return $number > $threshold;
}));
}
/**
* @dataProvider findGreaterThanProvider
*/
public function testFindGreaterThan(array $numbers, int $threshold, array $expected): void
{
$this->assertEquals($expected, $this->calculator->findGreaterThan($numbers, $threshold));
}
public function findGreaterThanProvider(): array
{
return [
[[1, 2, 3], -1, [1, 2, 3]],
[[-2, -3, -4], 0, []]
];
}
После выполнения мы увидим такой результат:
You are running Infection with Xdebug enabled.
____ ____ __ _
/ _/___ / __/__ _____/ /_(_)___ ____
/ // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ _/ // / / / __/ __/ /__/ /_/ / /_/ / / / /
/___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/
Running initial test suite...
PHPUnit version: 8.2.2
11 [============================] < 1 sec
Generate mutants...
Processing source code files: 1/1Creating mutated files and processes: 0/7
Creating mutated files and processes: 7/7
.: killed, M: escaped, S: uncovered, E: fatal error, T: timed out
..M..M. (7 / 7)
7 mutations were generated:
5 mutants were killed
0 mutants were not covered by tests
2 covered mutants were not detected
0 errors were encountered
0 time outs were encountered
Metrics:
Mutation Score Indicator (MSI): 71%
Mutation Code Coverage: 100%
Covered Code MSI: 71%
Please note that some mutants will inevitably be harmless (i.e. false positives).
Time: 1s. Memory: 10.00MB
С нашими тестами не всё в порядке. Сначала проверим файл infection.log:
Escaped mutants:
================
1) /home/sarven/projects/infection-playground/infection-playground/src/Calculator.php:19 [M] UnwrapArrayValues
--- Original
+++ New
@@ @@
*/
public function findGreaterThan(array $numbers, int $threshold) : array
{
- return \array_values(\array_filter($numbers, static function (int $number) use($threshold) {
+ return \array_filter($numbers, static function (int $number) use($threshold) {
return $number > $threshold;
- }));
+ });
}
2) /home/sarven/projects/infection-playground/infection-playground/src/Calculator.php:20 [M] GreaterThan
--- Original
+++ New
@@ @@
public function findGreaterThan(array $numbers, int $threshold) : array
{
return \array_values(\array_filter($numbers, static function (int $number) use($threshold) {
- return $number > $threshold;
+ return $number >= $threshold;
}));
}
Timed Out mutants:
==================
Not Covered mutants:
====================
Первая непойманная проблема — использование функции
array_values
. Она применена для сброса ключей, потому что array_filter
возвращает значения с ключами из предыдущего массива. К тому же в наших тестах нет случая, когда требуется использовать array_values
, поскольку иначе возвращается массив с теми же значениями, но другими ключами.Вторая проблема связана с пограничными случаями. В сравнении мы использовали знак
>
, но мы не тестируем любые пограничные случаи, поэтому замена на >=
не ломает тесты. Нужно добавить только один тест:public function findGreaterThanProvider(): array
{
return [
[[1, 2, 3], -1, [1, 2, 3]],
[[-2, -3, -4], 0, []],
[[4, 5, 6], 4, [5, 6]]
];
}
И теперь Infection всем доволен:
You are running Infection with Xdebug enabled.
____ ____ __ _
/ _/___ / __/__ _____/ /_(_)___ ____
/ // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ _/ // / / / __/ __/ /__/ /_/ / /_/ / / / /
/___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/
Running initial test suite...
PHPUnit version: 8.2.2
12 [============================] < 1 sec
Generate mutants...
Processing source code files: 1/1Creating mutated files and processes: 0/7
Creating mutated files and processes: 7/7
.: killed, M: escaped, S: uncovered, E: fatal error, T: timed out
....... (7 / 7)
7 mutations were generated:
7 mutants were killed
0 mutants were not covered by tests
0 covered mutants were not detected
0 errors were encountered
0 time outs were encountered
Metrics:
Mutation Score Indicator (MSI): 100%
Mutation Code Coverage: 100%
Covered Code MSI: 100%
Please note that some mutants will inevitably be harmless (i.e. false positives).
Time: 1s. Memory: 10.00MB
Добавим в класс
Calculator
метод subtract
, но без отдельного теста в PHPUnit:public function subtract(int $a, int $b): int
{
return $a - $b;
}
И после выполнения Infection видим:
You are running Infection with Xdebug enabled.
____ ____ __ _
/ _/___ / __/__ _____/ /_(_)___ ____
/ // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ _/ // / / / __/ __/ /__/ /_/ / /_/ / / / /
/___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/
Running initial test suite...
PHPUnit version: 8.2.2
11 [============================] < 1 sec
Generate mutants...
Processing source code files: 1/1Creating mutated files and processes: 0/9
Creating mutated files and processes: 9/9
.: killed, M: escaped, S: uncovered, E: fatal error, T: timed out
.......SS (9 / 9)
9 mutations were generated:
7 mutants were killed
2 mutants were not covered by tests
0 covered mutants were not detected
0 errors were encountered
0 time outs were encountered
Metrics:
Mutation Score Indicator (MSI): 77%
Mutation Code Coverage: 77%
Covered Code MSI: 100%
Please note that some mutants will inevitably be harmless (i.e. false positives).
Time: 1s. Memory: 10.00MB
На этот раз инструмент вернул две не покрытые мутации.
Escaped mutants:
================
Timed Out mutants:
==================
Not Covered mutants:
====================
1) /home/sarven/projects/infection-playground/infection-playground/src/Calculator.php:24 [M] PublicVisibility
--- Original
+++ New
@@ @@
return $number > $threshold;
}));
}
- public function subtract(int $a, int $b) : int
+ protected function subtract(int $a, int $b) : int
{
return $a - $b;
}
2) /home/sarven/projects/infection-playground/infection-playground/src/Calculator.php:26 [M] Minus
--- Original
+++ New
@@ @@
public function subtract(int $a, int $b) : int
{
- return $a - $b;
+ return $a + $b;
}
Метрики
После каждого выполнения инструмент возвращает три метрики:
Metrics:
Mutation Score Indicator (MSI): 47%
Mutation Code Coverage: 67%
Covered Code MSI: 70%
Mutation Score Indicator
— доля мутаций, обнаруженных тестами.Метрика вычисляется так:
TotalDefeatedMutants = KilledCount + TimedOutCount + ErrorCount;
MSI = (TotalDefeatedMutants / TotalMutantsCount) * 100;
Mutation Code Coverage
— доля кода, покрытого мутациями.Метрика вычисляется так:
TotalCoveredByTestsMutants = TotalMutantsCount - NotCoveredByTestsCount;
CoveredRate = (TotalCoveredByTestsMutants / TotalMutantsCount) * 100;
Covered Code Mutation Score Indicator
— определяет эффективность тестов только для кода, который покрыт тестами.Метрика вычисляется так:
TotalCoveredByTestsMutants = TotalMutantsCount - NotCoveredByTestsCount;
TotalDefeatedMutants = KilledCount + TimedOutCount + ErrorCount;
CoveredCodeMSI = (TotalDefeatedMutants / TotalCoveredByTestsMutants) * 100;
Использование в более сложных проектах
В приведённом выше примере есть только один класс, поэтому мы выполняли Infection без параметров. Но в повседневной работе над обычными проектами полезно будет использовать параметр
–filter
, позволяющий задавать набор файлов, к которым мы хотим применить мутации../vendor/bin/infection --filter=Calculator.php
Ложноположительные срабатывания
Некоторые мутации не влияют на работу кода, и Infection возвращает MSI ниже 100 %. Но мы не всегда можем с этим что-то сделать, так что с подобными ситуациями приходится смириться. Нечто подобное показано в этом примере:
public function calcNumber(int $a): int
{
return $a / $this->getRatio();
}
private function getRatio(): int
{
return 1;
}
Конечно, здесь метод
getRatio
не имеет смысла, в обычном проекте вместо него наверняка было бы какое-нибудь вычисление. Но результат мог бы быть и 1
. Infection возвращает:Escaped mutants:
================
1) /home/sarven/projects/infection-playground/infection-playground/src/Calculator.php:26 [M] Division
--- Original
+++ New
@@ @@
public function calcNumber(int $a) : int
{
- return $a / $this->getRatio();
+ return $a * $this->getRatio();
}
private function getRatio() : int
Как мы знаем, умножение и деление на 1 возвращает один и тот же результат, равный исходному числу. Так что эта мутация не должна ломать тесты, и несмотря на недовольство Infection относительно точности наших тестов, всё в порядке.
Оптимизации для больших проектов
В случаях с большими проектами выполнение Infection может занимать очень много времени. Можно оптимизировать выполнение в ходе CI, если обрабатывать только изменённые файлы Подробнее об этом написано в документации: https://infection.github.io/guide/how-to.html
Кроме того, можно параллельно запускать тесты на изменённом коде. Однако это возможно лишь в том случае, если все тесты являются независимыми. А именно такими и должны быть хорошие тесты. Чтобы включить эту опцию, воспользуйтесь параметром
–threads
:./vendor/bin/infection --threads=4
Как это работает?
Фреймворк Infection использует AST (дерево абстрактного синтаксиса), которое представляет код в виде абстрактной структуры данных. Для этого используется парсер, написанный одним из создателей PHP (php-parser).
Упрощённо работу инструмента можно представить так:
- Генерирование AST на основе кода.
- Применение подходящих мутаций (полный список лежит здесь).
- Создание на основе AST изменённого кода.
- Прогон тестов применительно к изменённому коду.
Например, можно проверить мутатор замены минуса на плюс:
<?php
declare(strict_types=1);
namespace Infection\Mutator\Arithmetic;
use Infection\Mutator\Util\Mutator;
use PhpParser\Node;
use PhpParser\Node\Expr\Array_;
/**
* @internal
*/
final class Plus extends Mutator
{
/**
* Replaces "+" with "-"
* @param Node&Node\Expr\BinaryOp\Plus $node
* @return Node\Expr\BinaryOp\Minus
*/
public function mutate(Node $node)
{
return new Node\Expr\BinaryOp\Minus($node->left, $node->right, $node->getAttributes());
}
protected function mutatesNode(Node $node): bool
{
if (!($node instanceof Node\Expr\BinaryOp\Plus)) {
return false;
}
if ($node->left instanceof Array_ || $node->right instanceof Array_) {
return false;
}
return true;
}
}
Метод
mutate()
создаёт новый элемент, который заменяется плюсом. Класс Node
взят из пакета php-parser, он используется для операций с AST и модифицирования PHP-кода. Однако это изменение нельзя применять где угодно, поэтому метод mutatesNode()
содержит дополнительные условия. Если слева от плюса или справа от минуса находится массив, тогда изменение недопустимо. Это условие используется из-за этого кода:$tab = [0] + [1];
is correct, but the following one isn’t correct.
$tab = [0] - [1];
Итог
Мутационное тестирование — отличный инструмент, который дополняет CI-процесс и позволяет оценить качество тестов. Зелёная подсветка тестов не даёт нам уверенности в том, что всё написано хорошо. Повысить точность тестов можно с помощью мутационного тестирования — или тестирования тестов, — что повышает нашу уверенность в работоспособности решения. Конечно, не обязательно стремиться к стопроцентным результатам метрик, потому что это не всегда возможно. Нужно анализировать логи и соответствующим образом настраивать тесты.