Мутационное тестирование в PHP
Макс Рафалко
Минск, Беларусь
Infection
@infection_php
Макс Рафалко
Что же такое
Code Coverage?
Типы Code Coverage
- Line Coverage
- Branch Coverage
- Condition Coverage
function fibonacci(int $n) {
+ return ($n === 0 || $n === 1) ? $n : fibonacci($n - 1) + fibonacci($n - 2);
}
fibonacci(0); // 0
0, 1, 1, 2, 3, 5, 8, 13, 21, 34 ...
Path Coverage в PHPUnit
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage pathCoverage="true">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
</phpunit>
Тип Line Coverage
- Показывает выполненные строки, а не протестированные
Покрытие кода, 100%
Протестированный
код, 60%
Проблема #1
class IdGenerator
{
public function uuid(): string
{
// generating logic ...
return $id;
}
}
Проблема #2: неявное покрытие
class UserCreator
{
public function __construct(
private IdGenerator $idGenerator
) {}
public function create(string $name, int $age): User
{
// some logic ...
return new User(
$this->idGenerator->uuid(),
$age,
$name
);
}
}
Проблема #2: неявное покрытие
class UserCreatorTest extends TestCase
{
public function test(): void
{
$userCreator = new UserCreator(
new IdGenerator()
);
self::assertInstanceOf(
User::class,
$userCreator->create('Ivan', 33)
);
// .. other asserts
}
}
Проблема #2: неявное покрытие
@covers
<phpunit>
<!-- ... -->
<listeners>
<listener class="Symfony\Bridge\PhpUnit\CoverageListener" />
</listeners>
</phpunit>
/**
* @covers App\UserCreator
*/
class UserCreatorTest extends TestCase
{
public function test(): void
{
$userCreator = new UserCreator(
new IdGenerator()
);
// ...
}
}
Code Coverage помечает как выполненный весь "затронутый" код
Используйте @covers-аннотацию
Coverage показывает, какой код не протестирован
"Assertion Free"-тестирование
Эксперимент
100% Coverage ничего не говорит о качестве тестов
Как измерить качество автоматических тестов?
Мутационное Тестирование
Терминология Мутационного Тестирования
- Mutation - единичное изменение кода
- $a = $b + $c;
+ $a = $b - $c;
- Mutant - мутированный исходный код
- Mutation Operator
Оригинал | Мутация |
---|---|
> | >= |
=== | !== |
&& | || |
'/^test$/' | '/test$/' и '/^test/' |
return true; | return false; |
foreach ($someVar as …) | foreach ([] as …); |
... | ... |
Алгоритм Мутационного Тестирования
- Шаг за шагом мутируем исходный код
- Запускаем тесты для каждого Мутанта
- Тесты падают - Мутант считается убитым 👍
- Тесты проходят - Мутант считается выжившим 👎
- Получаем метрику (Mutation Score Indicator)
- Идемпотентность
MSI = (TotalKilledMutants / TotalMutantsCount) * 100;
Тест # Мутант # |
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Результат |
---|---|---|---|---|---|---|---|---|---|
1 ("+" ➝ "-") | ✅ | ✅ | ✅ | ❌ | Убит | ||||
2 (">" ➝ ">=") | ⏳ | ||||||||
3 ("==" ➝ "!=) | |||||||||
4 (true ➝ false) |
Тест # Мутант # |
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Результат |
---|---|---|---|---|---|---|---|---|---|
1 ("+" ➝ "-") | ✅ | ✅ | ✅ | ❌ | Убит | ||||
2 (">" ➝ ">=") | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Выжил |
3 ("==" ➝ "!=) | ⏳ | ||||||||
4 (true ➝ false) |
Тест # Мутант # |
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Результат |
---|---|---|---|---|---|---|---|---|---|
1 ("+" ➝ "-") | ✅ | ✅ | ✅ | ❌ | Убит | ||||
2 (">" ➝ ">=") | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Выжил |
3 ("==" ➝ "!=) | ✅ | ❌ | Убит | ||||||
4 (true ➝ false) | ✅ | ✅ | ❌ | Убит |
class UserAgeFilter
{
private const AGE_THRESHOLD = 18;
private int $ageThreshold;
public function __construct(int $ageThreshold = self::AGE_THRESHOLD)
{
$this->ageThreshold = $ageThreshold;
}
/** @param array<int, User> $collection */
public function __invoke(array $collection)
{
return array_filter(
$collection,
function (User $user) {
return $user->getAge() >= $this->ageThreshold;
}
);
}
}
Пример
public function test_it_filters_adults(): void
{
$users = [
User::withAge(20),
];
$filter = new UserAgeFilter();
$filteredUsers = $filter($users);
assertCount(1, $filteredUsers);
}
Infection
PHP Mutation Testing Library
Результат
class UserAgeFilter
{
// ...
/** @param array<int, User> $collection */
public function __invoke(array $collection)
{
- return array_filter($collection, function (User $user) {
- return $user->getAge() >= $this->ageThreshold;
- });
+ return $collection;
}
}
Мутация #1
public function test_it_filters_adults(): void
{
$users = [
User::withAge(20),
];
$filter = new UserAgeFilter();
$filteredUsers = $filter($users);
assertCount(1, $filteredUsers);
}
public function test_it_filters_adults(): void
{
$users = [
+ User::withAge(15),
User::withAge(20),
];
$filter = new UserAgeFilter();
$filteredUsers = $filter($users);
assertCount(1, $filteredUsers);
}
class UserAgeFilter
{
// ...
/** @param array<int, User> $collection */
public function __invoke(array $collection)
{
return array_filter(
$collection,
function (User $user) {
- return $user->getAge() >= $this->ageThreshold;
+ return $user->getAge() > $this->ageThreshold;
}
);
}
}
Мутация #2
public function test_it_filters_adults(): void
{
$users = [
User::withAge(15),
User::withAge(20),
];
$filter = new UserAgeFilter();
$filteredUsers = $filter($users);
assertCount(1, $filteredUsers);
}
public function test_it_filters_adults(): void
{
$users = [
User::withAge(15),
+ User::withAge(18),
User::withAge(20),
];
$filter = new UserAgeFilter();
$filteredUsers = $filter($users);
- assertCount(1, $filteredUsers);
+ assertCount(2, $filteredUsers);
}
public function deactivateInactiveUsers(): void
{
$users = $this->userRepository->findInactiveUsers();
foreach ($users as $user) {
$user->deactivate();
- $this->entityManager->persist($user);
}
$this->entitymanager->flush();
}
Мутационное Тестирование находит бесполезный код
public function deletePhoneNumber(User $data, int $id): void
{
// ...
$data->getPhoneNumbers()->removeElement($phoneNumber);
- $this->em->remove($phoneNumber);
$this->em->flush();
}
Мутационное Тестирование находит бесполезный код
Проблемы
-
Скорость (N * t)
- N - количество Мутантов
- t - время выполнения тестов
- Эквивалентные Мутанты
- $a = $b * (-1);
+ $a = $b / (-1);
- $a = $b * $c;
+ $a = $b / $c;
Как ускорить Мутационное Тестирование?
- Запускать тесты только для мутированной строки
- Запускать наиболее быстрые тесты первыми
- Запускать мутации параллельно
- Избегать бесполезные мутации
- Запускать МТ только для измененных файлов
- $result = [$a, $b] + [$c, $d];
+ $result = [$a, $b] - [$c, $d];
infection --git-diff-filter=AM --git-diff-base=master
Запуск для измененных файлов
Запуск "убивающих" тестов первыми
Тест # Мутация # |
1 | 2 | 3 | Результат |
---|---|---|---|---|
1 | ✅ 0.132с | ✅ 0.387с |
❌ 0.399с |
Убит |
2 | ✅ | ✅ | ✅ | Выжил |
3 | ✅ | ❌ | Убит | |
4 | ✅ | ✅ | ❌ | Убит |
Тест # Мутация # |
1 | 2 | 3 | Результат |
---|---|---|---|---|
1 | ❌ 0.399с |
✅ 0.387с |
✅ 0.132с | Убит |
2 | ✅ | ✅ | ✅ | Выжил |
3 | ❌ | ✅ | Убит | |
4 | ❌ | ✅ | ✅ | Убит |
Запуск "убивающих" тестов первыми
Тест # Мутация # |
1 | 2 | 3 | Результат |
---|---|---|---|---|
1 | ❌ 0.399с |
Убит | ||
2 | ✅ | ✅ | ✅ | Выжил |
3 | ❌ | Убит | ||
4 | ❌ | Убит |
Запуск "убивающих" тестов первыми
Как использовать МТ ежедневно?
- Пишете новый функционал, покрываете тестами
- Запускаете Infection
$ infection --git-diff-filter=AM --threads=4 --show-mutations
- Запуск на CI сервере
$ infection --min-covered-msi=100
5) /srv/api/src/User/Status/StatusTransitioner.php:22 [M] PublicVisibility
--- Original
+++ New
@@ @@
- public function isAllowedTransitionTo(User $user, UserStatus $toStatus) : bool
+ protected function isAllowedTransitionTo(User $user, UserStatus $toStatus) : bool
{
if ($user->isDraft()) {
return $toStatus->isActive() || $toStatus->isDeleted();
Как МТ помогает с Code Review?
Infection Playground
Infection Playground
Поддерживаемые фреймворки:
- PHPUnit
- Pest
- Codeception
- phpspec
Поддерживаемые Coverage драйвера:
- Xdebug
- phpdbg
- pcov
Версии PHP: ^7.1 || ^8.0
AST
Abstract Syntax Tree
Абстрактные Синтаксические Деревья
$a = $b + $c;
array(
0: Expr_Assign(
var: Expr_Variable(
name: a
)
expr: Expr_BinaryOp_Plus(
left: Expr_Variable(
name: b
)
right: Expr_Variable(
name: c
)
)
)
)
=
a
+
b
c
array(
0: Expr_Assign(
var: Expr_Variable(
name: a
)
- expr: Expr_BinaryOp_Plus(
+ expr: Expr_BinaryOp_Minus(
left: Expr_Variable(
name: b
)
right: Expr_Variable(
name: c
)
)
)
)
=
a
-
b
c
Code → AST → Мутация AST → Мутант
- $a = $b + $c;
+ $a = $b - $c;
final class Plus extends Mutator
{
/**
* Replaces "+" with "-"
*
* @param Node\Expr\BinaryOp\Plus $node
*/
public function mutate(Node $node): iterable
{
yield new Node\Expr\BinaryOp\Minus($node->left, $node->right, $node->getAttributes());
}
public function canMutate(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;
}
}
Выводы
- Более надежные тесты
- Меньше ошибок в коде
- Помощь в Code Review
- Поиск мертвого и бесполезного кода
- "Condition coverage" вместо "Line Coverage"
- @infection_php - Twitter
- https://github.com/infection/infection
- Документация https://infection.github.io/guide
- Playground - https://infection-php.dev/
Ссылки
Вопросы?
Мутационное тестирование в PHP - PHP Russia 2021
By born_free
Мутационное тестирование в PHP - PHP Russia 2021
Мутационное тестирование в PHP - Infection
- 221