Infection

@infection_php

Maks Rafalko

What is really

Code Coverage?

Coverage Types

 

  • Line Coverage
  • Branch Coverage
  • Condition Coverage

0, 1, 1, 2, 3, 5, 8, 13, 21, 34 ...

function fibonacci(int $n) {
+    return ($n === 0 || $n === 1) ? $n : fibonacci($n - 1) + fibonacci($n - 2);
}

fibonacci(0); // 0

Path Coverage in 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

  • Shows executed lines, not tested code

Line Coverage, 100%

Tested

Code, 60%

Issue #1

class IdGenerator
{
    public function uuid(): string
    {
        // generating logic ...

        return $id;
    }
}

Issue #2: implicit coverage

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
        );
    }
}

Issue #2: implicit coverage

class UserCreatorTest extends TestCase
{
    public function test(): void
    {
        $userCreator = new UserCreator(
            new IdGenerator()
        );

        self::assertInstanceOf(
            User::class,
            $userCreator->create('Ivan', 33)
        );

        // .. other asserts
    }
}

Issue #2: implicit coverage

@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 marks  as executed not only subject under test, but *all* related code.

 

Use @covers annotation

Coverage shows what code is not tested

Assertion Free Testing

Experiment

100% Coverage says nothing about tests quality

How to measure​ the effectiveness of the test suite?

Mutation Testing

Basic of Mutation Testing

  • Mutation - single change of code
- $a = $b + $c;
+ $a = $b - $c;
  • Mutant - mutated source code
  • Mutation Operator
Original Mutated
> >=
=== !==
&& ||
'/^test$/' '/test$/' and '/^test/'
return true; return false;
foreach ($someVar as …) foreach ([] as …);
... ...

Mutation Testing Algorithm

  • Step by step Mutates the source code
  • Runs tests for *each* Mutant
  • Tests fail - Mutant has been killed 👍
  • Tests pass - Mutant has been survived 👎
  • Provides results (Mutation Score Indicator)
  • Idempotency
MSI = (TotalKilledMutants / TotalMutantsCount) * 100;
                        Test #
Mutation #
1 2 3 4 5 6 7 8 Result
         1 ("+" ➝ "-") Killed
         2 (">" ➝ ">=")
         3 ("==" ➝ "!=)
         4 (true ➝ false)
                        Test #
Mutation #
1 2 3 4 5 6 7 8 Result
         1 ("+" ➝ "-") Killed
         2 (">" ➝ ">=") Escaped
         3 ("==" ➝ "!=)
         4 (true ➝ false)
                        Test #
Mutation #
1 2 3 4 5 6 7 8 Result
         1 ("+" ➝ "-") Killed
         2 (">" ➝ ">=") Escaped
         3 ("==" ➝ "!=) Killed
         4 (true ➝ false)
class UserAgeFilter
{
    private const AGE_THRESHOLD = 18;

    private $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;
            }
        );
    }
}

Example

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

Result

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;
    }
}

Mutation #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;
            }
        );
    }
}

Mutation #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);
}

Mutation Testing finds useless code

public function deactivateInactiveUsers(): void
{
    $users = $this->userRepository->findInactiveUsers();

    foreach ($users as $user) {
        $user->deactivate();

-       $this->entityManager->persist($user);
    }

    $this->entitymanager->flush();
}

Problems

  • Speed (N * t)
    • N - the number of Mutants
    • t - tests execution time​
  • Equivalent Mutants
- $a = $b * (-1);
+ $a = $b / (-1);
- $a = $b * $c;
+ $a = $b / $c;

How to speed MT up?

  • Execute a subset of tests for mutated line
  • Run the fastest tests first
  • Run mutations in parallel
  • Avoid useless mutations
  • Run MT only for changed files
- $result = [$a, $b] + [$c, $d];
+ $result = [$a, $b] - [$c, $d];

Run for changed files only

infection --git-diff-filter=AM --git-diff-base=master

Run killing tests first

                        Test #
Mutation #
1 2 3 Result
                1 ✅ 0.132s
0.387s

0.399s
Killed
                2 Escaped
                3 Killed
                4

Run killing tests first

                        Test #
Mutation #
1 2 3 Result
                1
0.399s

0.387s
✅ 0.132s Killed
                2 Escaped
                3 Killed
                4

Run killing tests first

                        Test #
Mutation #
1 2 3 Result
                1
0.399s
Killed
                2 Escaped
                3 Killed
                4

Run killing tests first

- Time: 1m 49s. Memory: 0.07GB (first run)
+ Time: 38s. Memory: 0.07GB (second run)

How to use MT on daily bases?

  • Write a new feature, cover by tests
  • run Infection
$ infection --git-diff-filter=AM --threads=4 --show-mutations
  • Run on CI server
$ 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();

How MT helps with code review?

Supported Test Frameworks:

  • PHPUnit
  • Pest
  • Codeception
  • phpspec

 

Supported Coverage Drivers:

  • Xdebug
  • phpdbg        
  • pcov

 

Supported PHP: 7.1+

Infection Playground

Infection Playground

Conclusion

  • More reliable tests
  • Less bugs
  • Helps with Code Review
  • Finds dead and useless code
  • Condition coverage instead of line coverage

Links

Questions?

Mutation Testing - Infection 2021

By born_free

Mutation Testing - Infection 2021

Mutation Testing in PHP. Infection - Mutation testing framework

  • 212