Przejdź do treści
Utrzymanie i przekazanie

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.

Redakcjadeveloper front-end10 minkod + tabela
412liczba z tezy tekstu

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 break w 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.