Przejdź do głównej zawartości

Testowanie mutacyjne — wyjaśnienie

Spec: ISO/IEC/IEEE 29119-4 Spec: PHPUnit Evidence: Test-backed

Pokrycie wierszy mówi tylko, że dany wiersz wykonał się podczas działania zestawu testów. Nie mówi natomiast, czy jakikolwiek test zakończyłby się niepowodzeniem, gdyby ten wiersz był błędny. Testowanie mutacyjne wypełnia tę lukę: celowo psuje kod i sprawdza, czy testy to wychwycą. Ta strona wyjaśnia, co oznacza wynik mutacji oraz jak NextPDF wykorzystuje go jako narzędzie diagnostyczne, a nie trofeum.

Pokrycie to jedna z najbardziej zaufanych metryk testowania, a zarazem jedna z najbardziej mylących. Test, który wywołuje metodę i niczego nie sprawdza, wykonuje każdy jej wiersz: pełne pokrycie, zerowa wykrywalność. Literatura normatywna jasno stwierdza, że uporządkowanie kryteriów pokrycia nie mówi nic o ich zdolności do ujawniania błędów. Tę zdolność norma określa jako skuteczność testów (ISO/IEC/IEEE 29119-4, §C.2.4). Procent pokrycia i gwarancja wykrywania błędów to dwie różne tezy.

W przypadku silnika PDF nie jest to rozważanie czysto teoretyczne. Sprawdzenie zakresu bajtów podpisu, przesunięcie tablicy odsyłaczy, gałąź kodowania — testy mogą w pełni „pokryć” każdy z tych elementów i ani razu nie sprawdzić wartości, która ma znaczenie. Zielony zestaw oparty na słabych testach jest gorszy niż uczciwie przyznana luka, ponieważ aktywnie zniechęca do zajrzenia głębiej.

  • Testowanie mutacyjne wprowadza do kodu źródłowego tysiące drobnych, celowych zmian (mutantów) — zamienia < na <=, + na - albo zwracaną wartość przy return — i ponownie uruchamia testy dla każdej z nich.
  • Jeśli test zakończy się niepowodzeniem dla mutanta, mutant zostaje zabity: jakiś test faktycznie sprawdził to zachowanie. Jeśli wszystkie testy nadal przechodzą, mutant przetrwał: zachowanie zostało wykonane, ale nigdy nie zostało sprawdzone.
  • W przybliżeniu Wskaźnik wyniku mutacji (MSI, Mutation Score Indicator) to liczba zabitych mutantów podzielona przez łączną liczbę nierównoważnych mutantów. Mierzy, czy testy wykrywają zmiany, a nie czy wykonują kod.
  • Niektóre mutanty są równoważne — nie mogą zmienić obserwowalnego zachowania, więc żaden test nie jest w stanie ich zabić. Traktowanie ich jako niepowodzeń jest nieuczciwe. NextPDF dowodzi ich równoważności i odnotowuje je w rejestrze, zamiast nieformalnie je pomijać.
  • NextPDF wykorzystuje MSI do wykrywania i wzmacniania słabych testów. To bramka diagnostyczna w ciągłej integracji, a nie wskaźnik marketingowy.

Mutacje w silniku są uruchamiane za pomocą Infection. Narzędzie jest skonfigurowane na produkcyjnym drzewie źródeł, z włączonymi rodzinami mutatorów: arytmetycznymi, logicznymi, granic warunków, równości, wartości zwracanych oraz usuwania — czyli dokładnie tymi operatorami, które ujawniają logikę „wykonaną, lecz niesprawdzoną”. Przebieg jest mechaniczny:

  1. Start green The suite must pass before mutation begins.
  2. Mutate Apply one small, deliberate change to the source.
  3. Re-run Run the tests that cover the mutated line.
  4. Killed A test failed — the behaviour is genuinely asserted.
  5. Escaped All tests still pass — a weak spot to strengthen.
  6. Equivalent No test can kill it because behaviour is unchanged — proven and ledgered, not scored as a miss.
Pętla testowania mutacyjnego, którą uruchamia NextPDF: weź zielone testy, wygeneruj mutanta, ponownie uruchom testy go obejmujące i zaklasyfikuj mutanta jako zabity (test go wychwycił), przetrwały (luka typu „pokrycie bez asercji” do usunięcia) lub udowodniony jako równoważny (żaden test nie może go zabić; odnotowany w rejestrze, niezaliczany na niekorzyść wyniku).

Dwie decyzje projektowe sprawiają, że tej liczbie można ufać. Po pierwsze, wynik jest wpięty jako bramka. Ciągła integracja wymusza minimalny MSI (oraz minimalny covered-MSI) i uruchamia wariant ograniczony do różnic na zmienionych wierszach. Dzięki temu zmiana, która dodaje kod, ale nie dodaje rzeczywistych asercji, zostaje wychwycona podczas przeglądu, a nie odkryta później. Po drugie, NextPDF nie pomija po cichu niewygodnych mutantów. Mutanty, które są naprawdę równoważne semantycznie — na przykład !== wobec !=, gdy ścisłe typowanie gwarantuje, że oba operandy mają ten sam typ — są odnotowywane w rejestrze mutacji wraz z jawnym testem dowodzącym równoważności. Dzięki temu liczba przetrwałych mutantów odzwierciedla rzeczywiste luki, a nie zabiegi księgowe. To właśnie połączenie PHPStan Level 10 z strict_types oraz typowanymi właściwościami sprawia, że te dowody równoważności są poprawne.

Evidence: Test-backed Testowanie mutacyjne jest skonfigurowane w silniku, na produkcyjnych katalogach źródłowych, z włączonymi rodzinami mutatorów ujawniającymi zachowanie. Jest wymuszane jako bramka ciągłej integracji z minimalnym MSI i wariantem ograniczonym do różnic. To kontrola wykonywana w ramach kompilacji, a nie dodatek po fakcie.

Evidence: Test-backed Problem równoważnych mutantów jest rozwiązywany uczciwie. Mutanty równoważne semantycznie są klasyfikowane i poparte dedykowanymi testami dowodzącymi równoważności w rejestrze mutacji, przy czym poprawność każdego dowodu opiera się na PHPStan Level 10 oraz ścisłym typowaniu. Liczba przetrwałych mutantów odzwierciedla zatem rzeczywiste niewykryte zachowanie, a nie niemożliwy do zabicia szum, który sztucznie pogarsza wynik.

Evidence: Standard-backed Mutacja jest uznaną techniką, a nie wynalazek NextPDF. Spec: ISO/IEC/IEEE 29119-4, §B.2.4 opisuje stosowanie generycznych mutacji do elementów specyfikacji w celu wyprowadzenia konkretnych mutacji na potrzeby testowania. Technika ta jest w ogóle potrzebna, ponieważ ta sama norma stwierdza, że relacja zawierania między kryteriami pokrycia nie porządkuje ich pod względem zdolności do ujawniania błędów (ISO/IEC/IEEE 29119-4, §C.2.4).

Evidence: Standard-backed Samo pokrycie ma jasno określone, ale ograniczone znaczenie. Spec: PHPUnit rozróżnia pokrycie wierszy, gałęzi i ścieżek. Pokrycie wierszy odnotowuje jedynie, że wykonywalny wiersz wykonał się. Znajomość tej definicji sprawia, że jej niewystarczalność staje się oczywista.

Nie chodzi o samo polecenie — chodzi o to, co mówi przetrwały mutant:

<?php
declare(strict_types=1);
final class ByteRange
{
// Suppose the production guard is:
// if ($offset < 0) { throw new InvalidByteRange(); }
public function assertNonNegative(int $offset): void
{
if ($offset < 0) {
throw new InvalidByteRange('offset must be >= 0');
}
}
}
// A test that EXECUTES this line but does not assert the boundary:
// $byteRange->assertNonNegative(5); // no exception expected, none asserted
// gives 100% line coverage of assertNonNegative().
//
// Mutation flips `< 0` to `<= 0`. Behaviour now differs ONLY at $offset === 0.
// If no test passes 0 and asserts what happens, every test still passes:
// the mutant ESCAPED. Coverage said "tested"; mutation said "the boundary
// is unasserted". The fix is a test that pins offset === 0, not a higher
// target.
//
// composer mutation:diff → mutate only changed lines, enforce min MSI
// composer mutation:full → full-tree mutation gate

W tym przetrwałym mutancie widać całą wartość tej techniki. Wskazał rzeczywistą, konkretną, brakującą asercję, którą raport pokrycia uznawał za w pełni przetestowaną.

Najważniejsze nieporozumienie polega na tym, że wynik mutacji to ocena, którą należy maksymalizować. Bardzo wysoki MSI osiągnięty przez pisanie testów po to, by zabijać mutanty jest równie pusty jak wysokie pokrycie osiągnięte przez wywoływanie metod bez sprawdzania. Metryka została ograna i nie mierzy już wykrywalności. NextPDF wykorzystuje MSI do wykrywania słabych testów. Efektem jest lepsza asercja; ostentacyjne chwalenie się wynikiem nie jest celem.

Drugie nieporozumienie polega na tym, że każdy przetrwały mutant jest wadą testów. Niektóre mutanty są naprawdę równoważne i nie mogą zostać zabite przez żaden test, ponieważ nie zmieniają obserwowalnego zachowania. Traktowanie ich jako niepowodzeń daje nieuczciwy, sztucznie zaniżony wynik i uczy ludzi ignorowania raportu. Odpowiedzią NextPDF jest jawne udowodnienie równoważności i odnotowanie jej w rejestrze, a nie ciche tłumienie takich przypadków ani udawanie, że liczba jest gorsza, niż jest w rzeczywistości.

Testowanie mutacyjne mierzy, czy testy wykrywają wprowadzone zmiany. Nie dowodzi, że kod jest poprawny. Nie mierzy wydajności ani zgodności. Nie potrafi zabić prawdziwie równoważnego mutanta. Bieżący wynik mutacji, obowiązujący próg minimalnego MSI, liczba odnotowanych w rejestrze równoważnych mutantów oraz wszelkie wartości pokrycia to żywe sygnały jakości generowane z artefaktów ciągłej integracji i publikowane wraz z kompilacją. Celowo ich tu nie ma, ponieważ liczba wklejona w tekst dezaktualizuje się i staje się małym kłamstwem. Jedynym stabilnym faktem podanym na tej stronie jest PHPStan Level 10; to właściwość konfiguracji, na której opierają się dowody równoważności, a nie pomiar.

Dobór mutatorów, progi oraz zasady prowadzenia rejestru należą do konfiguracji mutacji silnika i mogą ewoluować. W razie jakiejkolwiek rozbieżności z tą stroną to ta konfiguracja jest rozstrzygająca. Nie formułuje się tu żadnych twierdzeń o skuteczności testów jakiejkolwiek innej biblioteki.

  • Piramida testów NextPDF — pięć warstw testów, które testowanie mutacyjne audytuje pod kątem rzeczywistego wykrywania błędów.
  • Ścisłe typy, wszędzie — jak PHPStan Level 10 i ścisłe typowanie sprawiają, że dowody równoważności mutantów są poprawne.
  • Testowanie plikami wzorcowymi — kolejna warstwa, której zdolność wykrywania pomaga walidować testowanie mutacyjne.
  • Mutant — pojedyncza, celowa drobna zmiana w kodzie źródłowym, służąca do sprawdzenia, czy zestaw testów zauważyłby tę zmianę.
  • Zabity mutant — mutant, który spowodował niepowodzenie co najmniej jednego testu; zachowanie zostało faktycznie sprawdzone.
  • Przetrwały mutant — mutant, przy którym wszystkie testy nadal przeszły. Zachowanie zostało wykonane, lecz nigdy nie zostało sprawdzone — słaby punkt do naprawy.
  • Równoważny mutant — mutant, który nie może zmienić obserwowalnego zachowania, więc żaden test nie jest w stanie go zabić. NextPDF dowodzi ich równoważności i odnotowuje je w rejestrze.
  • MSI (Mutation Score Indicator, wskaźnik wyniku mutacji) — w przybliżeniu liczba zabitych mutantów podzielona przez łączną liczbę nierównoważnych mutantów; miara wykrywalności, a nie wykonania.
  • Pokrycie wierszy — metryka, która odnotowuje jedynie, że wykonywalny wiersz wykonał się podczas działania zestawu; zdefiniowana przez PHPUnit i niewystarczająca sama w sobie.