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
- 204