Мутационное тестирование в PHP
Макс Рафалко
function fibonacci(int $n) {
+    return ($n === 0 || $n === 1) ? $n : fibonacci($n - 1) + fibonacci($n - 2);
}
fibonacci(0); // 00, 1, 1, 2, 3, 5, 8, 13, 21, 34 ...
<?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>
Покрытие кода, 100%
Протестированный
код, 60%
class IdGenerator
{
    public function uuid(): string
    {
        // generating logic ...
        return $id;
    }
}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
        );
    }
}class UserCreatorTest extends TestCase
{
    public function test(): void
    {
        $userCreator = new UserCreator(
            new IdGenerator()
        );
        self::assertInstanceOf(
            User::class,
            $userCreator->create('Ivan', 33)
        );
        // .. other asserts
    }
}<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()
        );
        // ...
    }
}Coverage показывает, какой код не протестирован
- $a = $b + $c;
+ $a = $b - $c;| Оригинал | Мутация | 
|---|---|
| > | >= | 
| === | !== | 
| && | || | 
| '/^test$/' | '/test$/' и '/^test/' | 
| return true; | return false; | 
| foreach ($someVar as …) | foreach ([] as …); | 
| ... | ... | 
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);
}
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;
    }
}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;
            }
        );
    }
}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();
}- $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 --git-diff-filter=AM --threads=4 --show-mutations$ infection --min-covered-msi=1005) /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();Поддерживаемые фреймворки:
Поддерживаемые Coverage драйвера:
Версии PHP: ^7.1 || ^8.0
Абстрактные Синтаксические Деревья
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;
    }
}