Zmiana jednego znaku w warunku nie zepsuła żadnego z 412 testów, a pokrycie nadal pokazuje 87 proc.
Pokrycie linii nie mówi, czy testy cokolwiek wyłapią. Jak zmierzyć ich czułość mutacjami, które operatory przeżywają najczęściej i jaki próg dać do CI.
Narzędzie od pokrycia pokazuje 87 proc. linii i 412 zielonych testów. Wchodzisz do modułu wyceny, zmieniasz w jednym warunku > na >= i uruchamiasz suite ponownie: nadal zielony. Zmieniasz zwracaną wartość funkcji rabatu na null: też zielony. Oba zepsucia przeszłyby przegląd, review i wdrożenie, bo jedyny automat, który miał ich nie przepuścić, nie widzi różnicy.
To nie jest sytuacja wyjątkowa w projektach, w których testy dopisał model na prośbę „dodaj testy do tego pliku”. Model optymalizuje to, co umie sprawdzić: czy test się kompiluje i czy przechodzi. Pokrycie rośnie, liczba testów rośnie, zdolność wyłapania błędu niekoniecznie. Jest na to osobna miara i da się ją uruchomić w jeden wieczór na pojedynczym module.
Co mierzy pokrycie linii, a czego nie mierzy wcale
Pokrycie odpowiada na jedno pytanie: która linia wykonała się w trakcie przebiegu testów. Nie pyta, czy po wykonaniu tej linii cokolwiek zostało sprawdzone. Test, który wywoła funkcję i nie postawi żadnej asercji poza not.toThrow(), podnosi pokrycie dokładnie tak samo jak test porównujący wynik z oczekiwaną wartością.
Stąd bierze się rozjazd, który w projekcie oddanym klientowi kosztuje najwięcej: zestawienie pokrycia jest zielone, a suite nie pełni funkcji siatki bezpieczeństwa. Zmiana w kodzie, która psuje zachowanie, nie wywraca niczego, więc nikt jej nie zatrzymuje przed wydaniem.
Zabity, przeżyły, nieobjęty: skąd się bierze mutation score
Testowanie mutacyjne odwraca pytanie. Narzędzie wprowadza do kodu drobne, pojedyncze zmiany — mutanty — i dla każdej uruchamia te testy, które jej dotyczą. Jeśli po zmianie choć jeden test padnie, mutant jest zabity: masz dowód, że ta zmiana nie przejdzie niezauważona. Jeśli wszystkie testy przechodzą, mutant przeżył, a to znaczy, że w tym miejscu brakuje asercji.
| Stan mutanta | Co się stało | Jak liczy się do wskaźnika |
|---|---|---|
| Killed (zabity) | Co najmniej jeden test padł przy aktywnym mutancie | Wykryty |
| Survived (przeżyły) | Wszystkie testy przeszły, choć kod działa inaczej | Niewykryty — tu brakuje testu |
| No coverage | Żaden test nie dotyka tego miejsca | Niewykryty |
| Timeout | Przebieg nie zakończył się, np. pętla bez wyjścia | Wykryty |
Wskaźnik liczy się prosto. W dokumentacji Strykera mutation score to iloraz mutantów wykrytych i wszystkich prawidłowych, razy sto, gdzie wykryte to zabite plus timeouty, a prawidłowe to wykryte plus niewykryte. Jest też wariant liczony tylko po kodzie objętym testami — wyższy, bo pomija mutanty w miejscach, do których żaden test nie dochodzi. Przy odbiorze projektu pytaj o pierwszy: drugi da się podnieść, zawężając zakres testów.
100 proc. pokrycia przy 4 proc. mutation score — liczby z badań
Rozjazd między jedną miarą a drugą jest opisany ilościowo. Autorzy pracy o generowaniu testów prowadzonym mutacjami odnotowują zestawy testów osiągające 100 proc. pokrycia przy 4 proc. mutation score i nazywają pokrycie słabym wskaźnikiem zdolności wykrywania błędów; eksperyment objął 204 obiekty z dwóch benchmarków.
Osobna praca replikacyjna sprawdziła, jak obie miary korelują ze wykrywaniem prawdziwych błędów. Na 8 268 zestawach testów i 101 123 przypadkach wygenerowanych przez 11 modeli dla 318 metod z błędami z Defects4J korelacje pokrycia ze skutecznością wyszły słabe (okolice 0,37–0,48), a mutation score wypadł wyżej, ale wciąż umiarkowanie (okolice 0,48–0,51). Wniosek autorów jest ostrożniejszy, niż brzmi marketing narzędzi: te miary nadają się do porównywania zestawów testów na kodzie uznanym za poprawny, a nie do obiecywania, że błąd zostanie znaleziony.
Warto znać też skalę filtrowania, przez które przechodzą testy z modelu, zanim ktoś je przyjmie. W opisie narzędzia uruchomionego produkcyjnie na kodzie Instagrama i Facebooka 75 proc. wygenerowanych przypadków zbudowało się poprawnie, 57 proc. przechodziło stabilnie, a 25 proc. podniosło pokrycie, przy czym to kolejne sita, nie niezależne wyniki. Do produkcji trafiło to, co przeszło wszystkie trzy — a i tak była to selekcja, nie hurtowe przyjęcie: 73 proc. rekomendacji zaakceptowali inżynierowie.
Trzy wzorce z generatora, które przechodzą i nic nie chronią
Przeżyły mutant prawie zawsze wskazuje na jeden z kilku wzorców. Poniżej trzy, które w kodzie testów pisanych przez model wracają najczęściej, każdy z mutacją, która je obnaża.
Asercja na mocku zamiast na wyniku
it('zapisuje zamówienie', async () => {
const repo = { save: vi.fn().mockResolvedValue({ id: 1 }) };
await zapiszZamowienie({ ilosc: 2, cena: 19.99 }, repo);
expect(repo.save).toHaveBeenCalled();
});
Test sprawdza, że metoda została wywołana. Nie sprawdza, z czym. Zmień w kodzie produkcyjnym przekazywaną ilość na zero albo wartość zamówienia na NaN — mutant przeżyje, bo save nadal dostaje wywołanie. Poprawka to jedna linia: expect(repo.save).toHaveBeenCalledWith({ ilosc: 2, wartosc: 39.98 }).
Asercja, która nie dotyka wyniku
it('waliduje koszyk', () => {
expect(() => walidujKoszyk(koszyk)).not.toThrow();
expect(walidujKoszyk(koszyk)).toBeDefined();
});
Taki test przechodzi dla każdej implementacji, która nie rzuca wyjątkiem. Podmień ciało walidatora na return { ok: true } — zielono. Test nabiera sensu dopiero wtedy, gdy wchodzi do niego koszyk niepoprawny i asercja dotyczy listy błędów, a nie samego faktu, że coś wróciło.
Brak wartości granicznej
const rabat = (kwota: number) => (kwota > 1000 ? 0.1 : 0);
it('daje rabat powyżej tysiąca', () => {
expect(rabat(2000)).toBe(0.1);
expect(rabat(100)).toBe(0);
});
Dwa przypadki, dwie gałęzie, pokrycie stu procent tej funkcji. Mutacja > na >= przeżywa, bo żaden test nie podaje dokładnie tysiąca. Brakujący przypadek to expect(rabat(1000)).toBe(0) — i to jest dokładnie ten rodzaj zmiany, o który spiera się potem klient przy fakturze.
Osobna pułapka: snapshoty przyjęte na ślepo. Mutanty zabijają, bo każda zmiana wyniku zmienia snapshot, więc wskaźnik rośnie. O poprawności nie mówią nic — jeśli snapshot powstał na zepsutym wyniku, utrwala błąd zamiast go łapać.
Pierwszy przebieg na jednym module: co ustawić i ile to trwa
Nie uruchamiaj mutacji na całym repozytorium. Każdy mutant to osobny przebieg tych testów, które go dotyczą, więc czas rośnie z iloczynu liczby mutantów i czasu suite’u, podzielonego przez równoległość. Zacznij od modułu, w którym liczą się pieniądze albo uprawnienia — ta sama logika obowiązuje przy progach pewności stawianych tylko dla pól krytycznych: ostrzej pilnuje się tego, czego błąd kosztuje, a nie wszystkiego po równo.
npm i -D @stryker-mutator/core @stryker-mutator/vitest-runner
npx stryker init
{
"testRunner": "vitest",
"mutate": ["src/domena/wycena/**/*.ts"],
"concurrency": "50%",
"incremental": true,
"thresholds": { "high": 80, "low": 60, "break": null },
"reporters": ["html", "clear-text", "progress"]
}
Cztery ustawienia z dokumentacji konfiguracji, które decydują o tym, czy przebieg skończy się w rozsądnym czasie. mutate domyślnie bierze wszystko z src i lib poza plikami testów — zawężenie do jednego katalogu jest najszybszą dźwignią, a zakres można podać nawet liniami, jak src/app.js:1-11. concurrency domyślnie używa wszystkich rdzeni przy czterech lub mniej, a przy większej liczbie jednego mniej; wartość procentowa pozwala zostawić maszynę używalną. incremental jest domyślnie wyłączone, a włączone zapisuje wyniki do reports/stryker-incremental.json i przyspiesza kolejne przebiegi. thresholds ma domyślnie high: 80, low: 60, break: null, przy czym dwie pierwsze wartości sterują wyłącznie kolorem w raporcie.
Co dalej zrobić z wynikiem, widać po tym, jaki operator przeżył. Lista operatorów jest w dokumentacji mutatorów, a poniżej pięć, które w kodzie generowanym wracają najczęściej.
| Mutacja, która przeżyła | Czego brakuje w testach | Co dopisać |
|---|---|---|
a > b na a <= b |
przypadku na granicy warunku | test dla wartości równej progowi |
a && b na a || b |
przypadku, w którym tylko jeden człon jest prawdziwy | dwa testy: prawda/fałsz na każdym członie |
if (a > b) na if (false) |
asercji na skutku tej gałęzi | sprawdzenie efektu, nie samego wywołania |
"PLN" na "" |
porównania dokładnej treści | asercja na pełnym stringu wyniku |
foo?.bar na foo.bar |
przypadku z brakującymi danymi | test z undefined w wejściu |
Dla innych stosów zasada jest ta sama, zmienia się narzędzie: PIT w Javie, Infection w PHP, mutmut w Pythonie.
Próg w CI i odbiór projektu: co wpisać do protokołu
Najczęstszy błąd przy wdrażaniu tej miary to ustawienie progu, który od razu wywraca build. Wynik pierwszego przebiegu na starym module bywa niski i przepisanie wszystkich testów nie zdarzy się w tym sprincie. Ustaw break kilka punktów pod aktualnym wynikiem — wtedy pipeline pada tylko wtedy, gdy ktoś pogorszy stan — i podnoś próg po każdym przebiegu, w którym wynik poszedł w górę. break jest domyślnie wyłączone, a ustawione poniżej wyniku kończy przebieg kodem 1.
Przy odbiorze projektu albo przejmowaniu go po kimś pytaj o konkrety, nie o deklarację „jest pokryte testami”. Zasada jest ta sama co w liście kontrolnej przed przekazaniem zadania: przekazanie bez spisanych kryteriów odbioru wraca do ciebie w najgorszym momencie.
- mutation score liczony osobno dla modułów płatności, uprawnień i integracji, nie jedna liczba dla całego repozytorium;
- raport HTML zapisany jako artefakt builda, żeby dało się zobaczyć listę przeżyłych mutantów, a nie tylko wskaźnik;
- plik konfiguracyjny z progiem
breakw repozytorium, a nie ustawiony ręcznie w CI; - lista miejsc z mutantami w stanie no coverage — to kod, którego żaden test nie dotyka;
- czas przebiegu, bo mutacje wchodzące do każdego commita w dużym repozytorium są nie do utrzymania.
Ta lista ma też wartość handlową. Suite, który nie przepuszcza zmiany znaku w warunku, jest argumentem przy sprzedawaniu opieki po wdrożeniu — a jeśli planujesz przestawić się z pojedynczych zleceń na stałą obsługę, przyda się poradnik GeekWork o przejściu z łapania zleceń na przewidywalny dochód. Utrzymanie da się wycenić wtedy, gdy wiadomo, czego pilnuje automat, a czego człowiek.
Czego mutacje nie powiedzą — i która liczba trafia do CI w tym tygodniu
Mutation score ma trzy ograniczenia, o których trzeba wiedzieć przed obiecaniem komukolwiek liczby w umowie. Pierwsze: liczy się tylko na suite, który przechodzi na obecnym kodzie — czerwonego zestawu testów nie zmutujesz, bo nie ma punktu odniesienia. Drugie: część mutantów jest semantycznie równoważna oryginałowi i nie da się ich zabić żadnym testem, więc wynik 100 nie jest celem, a pogoń za nim kończy się testami pisanymi pod narzędzie. Trzecie: nawet dobry wynik nie mówi, że funkcja robi to, czego chce klient — mutacje sprawdzają czułość testów na zmianę kodu, nie zgodność z wymaganiem. Korelacje z pracy replikacyjnej wyżej są tu właściwą miarą ostrożności: lepszy sygnał niż pokrycie, nie dowód.
Mimo tych ograniczeń to jedyna miara, która odpowiada na pytanie zadane na początku: czy ktokolwiek zauważy, że zmieniłeś > na >=. Wybierz jeden moduł, w którym błąd kosztuje pieniądze, uruchom na nim przebieg dzisiaj i zapisz dwie liczby: mutation score i czas. Jeśli wynik jest poniżej pokrycia o kilkadziesiąt punktów, masz gotową listę zadań na najbliższy sprint — nie w formie „dopiszmy testy”, a w formie konkretnych przeżyłych mutantów. Próg break wstaw dopiero w następnym kroku, kilka punktów pod tym, co wyszło.