lukaszstaniszewski.pl

blog programistyczny

lukaszstaniszewski.pl

blog programistyczny

Testy mutacyjne

Każdego dnia pisane są tysiące klas tylko czy aby na pewno dobrych bez bugów? A może zapominasz zadać sobie pytania kończąc test jednostkowy czy przeszedłeś wszystkie przypadki? Zapewne jak każdy powiesz że to wszystko zależy. Niestety jako ludzie popełniamy błędy, dlatego z pomocą przychodzą nam zero jedynkowe maszyny (one też popełniają błędy ale rzadziej). 🙂 W ramach wpisu dowiesz się jak testy mutacyjne oraz biblioteka infection wspomogą Cię w codziennej pracy i pozwolą uniknąć prostych błędów.

 

Cały kod możesz znaleźć [TUTAJ] oraz [TUTAJ].

Czym są testy mutacyjne

Testy mutacyjne służą do testowanie własnych testów i można by na tym skończyć ale dodam coś więcej. 🙂

Testowanie to polega na małych modyfikacjach kodu (mutant) i sprawdzania czy zostały one złamane. Jeżeli zmutowana metoda powoduje niepowodzenie testów jest dodawana jako zabity mutant (przyda Ci się to w dalszej części wpisu przy wzorach). Mutanty są oparte o zdefiniowane operatory. Dla przykładu infection ma je zdefiniowane w następujący sposób [TUTAJ]. Każdy operator tak naprawdę definiuje typowe błędy programistyczne jak np. brak pomniejszenia liter.

Konfiguracja

Nie zawsze wszystko testujemy tak samo, tak naprawdę nigdy nie powinniśmy mieć 100% pokrycia kodu testami. Niestety infection to wszystko ignoruje i będzie sprawdzać wszystkie klasy jakie mamy w wybranych katalogach. W tym przypadku na pomoc przychodzi nam konfiguracja biblioteki dzięki której możesz wykluczyć konkretne klasy, na bazie regex itd.

{
  "source": {
    "directories": [
      "src"
    ],
    "excludes": [
      "{.*/Repository/.*}",
      "{.*/Exception/.*}",
      "{.*/Entity/.*}",
      "{.*/DTO/.*}",
      "{.*/Command/.*}",
      "{.*/Query/.*}",
      "{.*/Event/.*}",
      "{.*/ReadModel/.*}",
      "{.*/Factory/.*}",
      "{.*/Controller/.*}",
      "/\\.interface\\.php/",
      "*/Kernel.php"
    ]
  },
  "logs": {
    "text": "infection.log",
    "summary": "summary.log",
    "json": "infection-log.json",
    "perMutator": "per-mutator.md"
  },
  "mutators": {
    "@default": true,
    "@function_signature": false
  },
  "minMsi": 80,
  "minCoveredMsi": 80,
  "testFramework":"phpunit"
}

Dodatkowo w konfiguracji można zdefiniować m.in. nazwy plików logów, framework do testów lub minimalne wartości powodzenia MSI.

Przykład testów mutacyjnych

Jako przykład posłuży nam klasa która będzie weryfikować czy wartość priorytetu jest w danym zakresie.

<?php

declare(strict_types=1);

namespace App\Service;

final class PriorityService
{
    public function shouldBeHighPriority(int $priority): bool
    {
        return $priority > 0 && $priority < 3;
    }
}

Prosty test który nie sprawdza wszystkich przypadków prezentuje się następująco.

<?php

declare(strict_types=1);

namespace App\Tests\Service;

use App\Service\PriorityService;
use PHPUnit\Framework\TestCase;

class PriorityServiceTest extends TestCase
{
    private PriorityService $service;

    protected function setUp(): void
    {
        $this->service = new PriorityService();
    }

    /**
     * @test
     */
    public function shouldBeReturnHighPriority(): void
    {
        self::assertTrue(
            $this->service->shouldBeHighPriority(2)
        );
    }

    /**
     * @dataProvider getAnotherPriority
     * @test
     */
    public function shouldBeReturnAnotherPriority(int $priority): void
    {
        self::assertFalse(
            $this->service->shouldBeHighPriority($priority)
        );
    }

    public function getAnotherPriority(): array
    {
        return [
            [-100],
            [5],
        ];
    }
}

Po uruchomieniu infection zwróci następujące informacje

6 mutations were generated:
4 mutants were killed
0 mutants were configured to be ignored
0 mutants were not covered by tests
2 covered mutants were not detected
0 errors were encountered
0 syntax errors were encountered
0 time outs were encountered
0 mutants required more time than configured

Metrics:
Mutation Score Indicator (MSI): 66%
Mutation Code Coverage: 100%
Covered Code MSI: 66%

Natomiast w logach znajdziemy poniższe informacje

Escaped mutants:
================

1) /post-examples/src/Service/PriorityService.php:11    [M] LessThan

--- Original
+++ New
@@ @@
 {
     public function shouldBeHighPriority(int $priority) : bool
     {
-        return $priority > 0 && $priority < 3;
+        return $priority > 0 && $priority <= 3;
     }
 }


2) /post-examples/src/Service/PriorityService.php:11    [M] GreaterThan

--- Original
+++ New
@@ @@
 {
     public function shouldBeHighPriority(int $priority) : bool
     {
-        return $priority > 0 && $priority < 3;
+        return $priority >= 0 && $priority < 3;
     }
 }


Timed Out mutants:
==================

Skipped mutants:
================

Not Covered mutants:
====================

1) /post-examples/src/Service/ValidatorService.php:15    [M] GreaterThan

--- Original
+++ New
@@ @@
     }
     public function hasErrors() : bool
     {
-        return count($this->errors) > 0;
+        return count($this->errors) >= 0;
     }
 }


2) /post-examples/src/Service/ValidatorService.php:15    [M] GreaterThanNegotiation

--- Original
+++ New
@@ @@
     }
     public function hasErrors() : bool
     {
-        return count($this->errors) > 0;
+        return count($this->errors) <= 0;
     }
 }

Gdy spojrzysz na test i logi widać brak sprawdzenia kilku priorytetów które należy dodać do wcześniej napisanego testu.

<?php

declare(strict_types=1);

namespace App\Tests\Service;

use App\Service\PriorityService;
use PHPUnit\Framework\TestCase;

class PriorityServiceTest extends TestCase
{
    private PriorityService $service;

    protected function setUp(): void
    {
        $this->service = new PriorityService();
    }

    /**
     * @test
     * @dataProvider getHighPriority
     */
    public function shouldBeReturnHighPriority(int $priority): void
    {
        self::assertTrue(
            $this->service->shouldBeHighPriority($priority)
        );
    }

    public function getHighPriority(): array
    {
        return array_map(
            static fn (int $priority) => [$priority],
            range(1, 2)
        );
    }

    /**
     * @dataProvider getAnotherPriority
     * @test
     */
    public function shouldBeReturnAnotherPriority(int $priority): void
    {
        self::assertFalse(
            $this->service->shouldBeHighPriority($priority)
        );
    }

    public function getAnotherPriority(): array
    {
        return array_map(
            static fn (int $priority) => [$priority],
            [0, -1, 3, 4]
        );
    }
}

Po poprawieniu testów infection zwróci 100% dla wszystkich metryk.

PS w przypadku repozytorium dodane zostały klasy takie jak kontroler, dodatkowa klasa bez testów itp. które mają pokazywać dodatkowe przypadki.

Metryki

Pierwszą z trzech metryk prezentowanych przez bibliotekę Infection jest MSI (ang. Mutation Score Indicator). W żołnierskich słowach przedstawia ile procent mutacji jest pokrytych w całym projekcie (oczywiście biorąc pod uwagę wykluczenia w konfiguracji). Jest ona wyliczana na podstawie wzoru (KilledCount + TimedOutCount + ErrorCount) / TotalMutantsCount .

Następna metryką jest MCK (ang. Mutation Code Coverage) która ukazuję procent pokrycia kodu mutacjami. Warto tutaj wspomnieć że MCK nie bierze pod uwagę klas które nie posiadają testów. Taka logika będzie skutkować zbliżonym wynikiem do normalnego code coverage (różnica może wynikać z zastosowanych adnotacji PS do potwierdzenia przy przykładach). Wzór dla MCK jest bardzo prosty i prezentuję się tak (TotalMutantsCount - NotCoveredByTestsCount) / TotalMutantsCount.

Ostatnim wskaźnikiem jest CCM (ang. Covered Code MSI). Pokazuje ona pokrycie mutacji w klasach które posiadają testy. Wzór który służy do wyliczania tej metryki jest następujący (TotalMutantsCount - NotCoveredByTestsCount) / (KilledCount + TimedOutCount + ErrorCount).

Podsumowanie

Jak widzisz testy mutacyjne nie są takie straszne wręcz odwrotnie. Na początku w moim odczuciu metryki są trochę nie zrozumiałe ale jak przerobisz kilka przykładów oraz eksperymentów stają się one bardzo proste i przydatne. 🙂

Warto jednak pamiętać również o poprawianiu jakości swojej wiedzy z baz danych dlatego zapraszam Cię do wpisu z porównaniu różnych typów primary key dla MySQL [TUTAJ].

Źródła:

Testy mutacyjne
Przewiń na górę