Testy mutacyjne – czyli testujemy testy

Pisanie testów ma nas upewnić, że wytwarzany przez nas kod działa poprawnie. Często wyznaczamy sobie współczynnik code coverage i przy wyniku blisko stuprocentowym możemy powiedzieć, że zaimplementowane rozwiązania są poprawne. Na pewno? Może jest jakieś narzędzie, które dam nam lepszy feedback?

Testy mutacyjne

Testowanie mutacyjne polega na modyfikacji małych części kodu i sprawdzaniu w jaki sposób reagują na te zmiany testy. Jeśli po modyfikacji testy nadal przechodzą to jest dla nas sygnał, że testy dla tego fragmentu są niewystarczające. Oczywiście wszystko zależy jakie są to zmiany, bo nie chcemy testować każdej drobnej modyfikacji typu wcięć czy nazw zmiennych, bo po takich zmianach testy jednak powinny przechodzić. Dlatego w testach mutacyjnych korzysta się z tzw. mutatorów, które mają za zadanie zmienić niektóre fragmenty kodu na inne, tak aby to miało faktycznie sens, ale o tym więcej w dalszej części artykułu. Takie testy sami też czasami robimy ręcznie, sprawdzając czy jak zmienimy coś w naszym kodzie to załamie to testy. Jeśli zrobiliśmy refaktoryzację „połowy systemu” i testy nadal przechodzą na zielono to od razu możemy stwierdzić, że mamy kiepskie testy. Chyba, że komuś się coś takiego zdarzyło i testy jednak miał dobre to gratuluję 😀

Infection framework

W PHP obecnie najpopularniejszym frameworkiem do testów mutacyjnych jest Infection. Wspiera obecnie PHPUnit i PhpSpec, a do działania wymaga PHP 7.1+ i Xdebuga lub phpdbg.

Pierwsze uruchomienie i konfiguracja

./vendor/bin/infection

Pierwsze uruchomienie wyświetla nam interaktywny konfigurator narzędzia, który kończy się przygotowaniem odpowiedniego pliku z ustawieniami infection.json.dist. W przykładzie, który niżej zaprezentuję wygląda to tak:

{
    "timeout": 10,
    "source": {
        "directories": [
            "src"
        ]
    },
    "logs": {
        "text": "infection.log",
        "perMutator": "per-mutator.md"
    },
    "mutators": {
        "@default": true
    }
}

Timeout to opcja, która powinna odpowiadać maksymalnemu czasowi w jakim pojedynczy test powinien zostać wykonany. W source ustawiamy katalogi, z których kod powinien zostać zmutowany, można dodać odpowiednie wykluczenia. W logs pod pozycją text ustawiamy gromadzenie statystyk, które najbardziej nas interesują, czyli po prostu naszych niedokładnych testów. Opcja perMutator pozwala zapisać, które z mutatorów zostały użyte. W opcji mutators ustawiamy jakie mutatory mają być używane, również tutaj można dodać odpowiednie wykluczenia. Więcej na ten temat oczywiście można znaleźć w dokumentacji.

Praktyczny przykład

final class Calculator
{
    public function add(int $a, int $b): int
    {
        return $a + $b;
    }
}

Załóżmy, że mamy taką klasę jak wyżej. Piszemy do tego odpowiedni test w 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]
        ];
    }
}

Test oczywiście powinien być napisany przed implementacją metody add(). Uruchomienie ./vendor/bin/phpunit zwraca:

PHPUnit 8.2.2 by Sebastian Bergmann and contributors.

....                                                                4 / 4 (100%)

Time: 39 ms, Memory: 4.00 MB

OK (4 tests, 4 assertions)

Teraz odpalamy ./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

Czyli nasze testy według infection są dokładne. W pliku per-mutator.md możemy sprawdzić jakie mutacje zostały zastosowane:

# 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 to po prostu zmiana znaku w metodzie na minus, która powinna załamać testy, a mutator PublicVisibility zmienia modyfikator dostępu metody, co również powinno załamać testy i tak też się dzieje w tym przypadku.

Dopiszmy teraz nieco bardziej skomplikowaną metodę.

/**
 * @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, []]
    ];
}

Tym razem po odpaleniu narzędzia infection widzimy:

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

Czyli coś z testami jest nie tak, sprawdzamy plik 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:
====================

Pierwszy problem, który nie został wyłapany to użycie funkcji array_values, użyta ona została w tym miejscu po to, aby zresetować klucze, bo array_filter zwraca wartości z takimi kluczami jak w pierwotnej tablicy. W tych testach jednak nie zawarliśmy takiego przypadku, który wymagałby użycia array_values, bo inaczej zostanie zwrócona tablica z takimi samymi wartościami, ale innymi kluczami.

Drugi problem natomiast dotyczy przypadków granicznych. Użyty został znak > przy porównaniu, a nie testujemy w żadnym teście wartości granicznych, więc zmiana znaku na >= nie powoduje załamania testów. Wystarczy dodać jeden test:

public function findGreaterThanProvider(): array
{
    return [
        [[1, 2, 3], -1, [1, 2, 3]],
        [[-2, -3, -4], 0, []],
        [[4, 5, 6], 4, [5, 6]]
    ];
}

Teraz infection nie wyświetla już żadnych zastrzeżeń:

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

Dodajmy jeszcze metodę subtract do klasy Calculator, tym razem jednak bez pisania testu w PHPUnit:

public function subtract(int $a, int $b): int
{
    return $a - $b;
}

Po odpaleniu infection dostajemy wynik:

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

Tym razem infection zwraca dwie niepokryte testami mutacje.

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

Wskaźniki

Narzędzie po każdym uruchomieniu zwraca trzy wskaźniki:

Metrics:
    Mutation Score Indicator (MSI): 47%
    Mutation Code Coverage: 67%
    Covered Code MSI: 70%

Mutation Score Indicator – procent mutacji wykrytych przez testy

Wyliczany jest w następujący sposób:

TotalDefeatedMutants = KilledCount + TimedOutCount + ErrorCount;

MSI = (TotalDefeatedMutants / TotalMutantsCount) * 100;

Mutation Code Coverage – procent kodu pokrytego przez mutacje

Wyliczany jest w następujący sposób:

TotalCoveredByTestsMutants = TotalMutantsCount - NotCoveredByTestsCount;

CoveredRate = (TotalCoveredByTestsMutants / TotalMutantsCount) * 100;

Covered Code Mutation Score Indicator – wyznacza skuteczność testów tylko dla kodu, który jest nimi pokryty

Wyliczany jest w następujący sposób:

TotalCoveredByTestsMutants = TotalMutantsCount - NotCoveredByTestsCount;
TotalDefeatedMutants = KilledCount + TimedOutCount + ErrorCount;

CoveredCodeMSI = (TotalDefeatedMutants / TotalCoveredByTestsMutants) * 100;

Używanie w większym projekcie

W przykładzie mieliśmy tylko jedną klasę, więc można było odpalać infection bez parametrów. Jednak w codziennej pracy nad normalnym projektem użyteczną opcją będzie parametr –filter, pozwalający określić plik, dla którego chcemy sprawdzić mutacje.

./vendor/bin/infection --filter=Calculator.php

False positives

Niektóre mutacje mogą nie zmieniać działania danego kodu i faktycznie infection zwróci nam mniejszy wskaźnik MSI niż 100%, ale nie zawsze będziemy w stanie coś z tym zrobić, więc taką sytuację należy zaakceptować. Przykładem może być kod jak poniżej:

public function calcNumber(int $a): int
{
    return $a / $this->getRatio();
}

private function getRatio(): int
{
    return 1;
}

Metoda getRatio oczywiście w tym wypadku jest bez sensu, w kodzie projektowym pewnie byłoby tutaj jakieś obliczanie, ale równie dobrze mogłoby wyjść z niego 1. Infection zwróci coś takiego:

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
     {

Wiadomo, że mnożenie i dzielenie przez 1 daje ten sam wynik. Więc taka mutacja nie powinna zepsuć testów, więc mimo, że infection zarzuca niedokładność testów to wszystko jest w porządku.

Optymalizacje dla dużych projektów

W przypadku dużych projektów przetestowanie wszystkich mutacji może być bardzo czasochłonne. Można więc zoptymalizować uruchamianie narzędzia podczas CI tylko dla zmienionych plików. Więcej na ten temat można znaleźć w dokumentacji: https://infection.github.io/guide/how-to.html

Dodatkowo możliwe jest uruchamianie testów dla zmutowanego kodu równolegle. Jednak taka możliwość zachodzi tylko wtedy, gdy każdy z testów jest niezależny, dobre testy takie właśnie powinny być. Włączanie tej opcji wymaga przekazania parametru –threads

./vendor/bin/infection --threads=4

Jak to działa?

Framework Infection korzysta z AST (Abstract Syntax Tree), czyli reprezentacji kodu za pomocą abstrakcyjnej struktury danych. Na ten temat pojawiło się już trochę w artykule o JIT. Wykorzystywany jest parser napisany przez jedną z osób rozwijających PHP (php-parser).

Działanie narzędzia w uproszczeniu wygląda w następujący sposób:

  1. Wygenerowanie AST z kodu
  2. Zaaplikowanie odpowiednich mutatorów (listę dostępnych mutatorów można znaleźć tutaj -> https://infection.github.io/guide/mutators.html)
  3. Utworzenie zmutowanego kodu na podstawie nowego AST
  4. Wykonanie testów dla zmutowanego kodu

Dla przykładu tak wygląda mutator zmieniający plus na minus:

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

Metoda mutate() tworzy nowy element, którym ma być zastąpiony plus. Klasa Node pochodzi właśnie z pakietu php-parser, służącego do operacji na AST i modyfikowaniu kodu PHP. Jednak taka zmiana nie może być wykonana w każdym miejscu, dlatego w metodzie mutatesNode() zawarte są dodatkowe warunki. Jeśli po lewej stronie plusa występuje tablica lub po prawej stronie występuje tablica to taka zmiana nie jest wykonywana. Taki warunek jest potrzebny dlatego, że kod:

$tab = [0] + [1];

jest jak najbardziej poprawny, ale poniższy już nie.

$tab = [0] - [1];

 

Podsumowanie

Testy mutacyjne są dobrym narzędziem uzupełniającym proces continuous integration dającym informacje o jakości testów. Zielony pasek w testach nie oznacza, że wszystko jest dobrze napisane, testowanie testów, czyli testy mutacyjne pozwalają podnieść trochę dokładność testów, co w efekcie powinno poprawić pewność, że dostarczamy działające rozwiązania. Oczywiście jak już zostało to wspomniane dążenie do osiągnięcia 100% w metrykach nie musi być praktykowane, bo nie zawsze jest możliwe. Należy po prostu analizować logi i odpowiednio dostosowywać testy.

Udostępnij: