Dzisiaj przyjrzymy się testom mutacyjnym w TypeScript (lub JavaScript, używa się tutaj tych samych narzędzi). Punktem wyjścia będzie przykładowy kod pokryty testami w 100% (według konwencjonalnych wskaźników). Zobaczymy czemu 100% code coverage nie oznacza całkowitej pewności przed refactoringiem. W powiększeniu tej „strefy komfortu” pomogą nam testy mutacyjne w TypeScript.
Jeśli nie wiesz, czym są testy mutacyjne, zapraszam najpierw do tego artykułu. No to lecimy 😉
Punkt wyjścia: 100% code coverage
Na potrzeby tego artykułu przygotowałem bardzo prostą przykładową aplikację (a w zasadzie kawałek logiki biznesowej) w TypeScript, wygenerowaną za pomocą create-react-app. Kod źródłowy znajdziesz tutaj.
Naszym punktem wyjścia jest kod produkcyjny (legacy code, pełen ifków i tajemniczej logiki biznesowej) pokryty w 100% testami jednostkowymi. Często znajdujemy się w takiej właśnie sytuacji w naszych projektach. Taki stan wydaje się zapewniać nam dość duże bezpieczeństwo przed refactoringiem czy zmianami tej części kodu produkcyjnego.
Naszym kodem produkcyjnym jest klasa DiscountApplier:
W projekcie mamy klasę testową discountApplier.test.ts, która zapewnia 100% code coverage klasy DiscountApplier (testy można uruchomić poleceniem npm test
):

Testy napisane są z użyciem frameworka jest
i – jak widzisz powyżej – zapewniają 100% statements, branch, functions oraz line coverage. Czy to oznacza, że możemy się już rzucać w wir refactoringu i mieć pewność, że nic nie popsujemy? 🤔
Dorzucamy testy mutacyjne
Spróbujmy dodać do naszego projektu testy mutacyjne w TypeScript. Do tego celu użyjemy narzędzia Stryker Mutator, a konkretnie stryker-js.
Najpierw wykonujemy komendę npm install @stryker-mutator/core
w celu zainstalowania głównej paczki w naszej apce. Następnie, w głównym katalogu naszej apki, tworzymy plik stryker.conf.json
. Jest to plik konfiguracyjny Strykera, w którym można ustawić wiele przydatnych rzeczy. Na potrzeby naszego przykładu, użyjemy jedynie opcji mutate, która pozwoli nam ograniczyć mutowanie jedynie do pliku discountApplier.ts
:
W tym momencie jesteśmy w stanie uruchomić mutowanie poprzez wykonanie polecenia npx stryker run
. Końcowy wynik działania tej komendy w naszym przypadku wygląda następująco:

Wartość nazywaną w tym raporcie % score
, będę nazywać mutant coverage. Pokazuje ona pokrycie naszego kodu testami mutacyjnymi. Jak widzisz, wynosi ona 82,14%. Nie mamy więc pełnego pokrycia kodu testami – mimo 100% code coverage!
Interesująca jest też wartość 10 w kolumnie survived
. Z poprzedniego artykułu wiesz już, że są to mutanty, które przeżyły mutacje i właśnie nad nimi będziemy zaraz pracować.
Zaczynamy więc od następującej sytuacji:
Liczba testów: 6, mutant coverage: 82,14%, mutants survived: 10
PS: w przypadku przykładowego projektu użytego w tym artykule, Stryker zadziałał właściwie z automatu. W Twoim przypadku może nie być tak prosto. W razie problemów zerknij do dokumentacji albo zapytaj na Slacku (ludzie są tam mega pomocni, serio).
Rozpracowujemy pierwszego mutanta
Stryker generuje raport HTML (ścieżka widoczna na screenie powyżej), ale szczegóły mutantów, które przeżyły, bardzo przejrzyście widać w konsoli. Zajmijmy się więc pierwszym z nich:

Co to oznacza? Na czerwono widać oryginalny fragment kodu, a na zielono mutację, która została wprowadzona (warunek w if
został zamieniony na wartość false
). Okazuje się, że po wprowadzeniu takiej zmiany, żaden z naszych testów jednostkowych się nie wywalił. Możesz spróbować wprowadzić taką zmianę ręcznie i uruchomić testy – faktycznie wszystkie przechodzą. Jest to oczywiście zła sytuacja – chcielibyśmy, żeby testy wyłapywały jak najwięcej (idealnie: wszystkie) tego typu przypadków.
Zerkając jeszcze raz w wyniki mutacji, możemy zauważyć podobnego mutanta:

Wygląda więc na to, że nasz kod w tym bloku if
jest w ogóle niepotrzebny… Albo przynajmniej niewystarczająco otestowany.
Przyglądając się logice funkcji getDiscountPercentageValue
w klasie DiscountApplier
widzimy, że jeśli kupiony jest tylko jeden produkt, wartość zniżki powinna wynosić 0. W przeciwnym wypadku uruchamiana jest cała dalsza logika związana z wyliczaniem zniżki w zależności od sumy wartości lub ilości kupowanych produktów:
Wychodzi więc na to, że nasze testy, które podają do tej funkcji 1 produkt, nigdy nie spełniają żadnego z tych pozostałych warunków (np. ilość tego jednego produktu nigdy nie jest większa niż 10). Gdybyśmy mieli taki test, w przypadku wspomnianych dwóch mutacji testy by się wywaliły, ponieważ wartość zwróconej zniżki nie wynosiłaby 0.
Dopiszmy więc taki test:
Po uruchomieniu npm test
widzimy, że test przechodzi. Odpalmy więc jeszcze raz mutacje za pomocą npx stryker run
:

Super, udało nam się zabić dwa mutanty przez dopisanie jednego testu! Aktualna sytuacja prezentuje się następująco:
Liczba testów: 7 (+1), mutant coverage: 85,71%, mutants survived: 8 (-2)
Rozwalamy warunki brzegowe
Przyjrzyjmy się kolejnemu mutantowi:

Wygląda na to, że wszystkie nasze pozostałe mutanty są podobne. Sprawdzają one warunki brzegowe. W tym przypadku widać, że nie mamy testu sprawdzającego sytuację, gdy jest więcej niż jeden produkt, a suma ich ilości (quantity
) wynosi dokładnie 50. Dopiszmy więc taki test:
Test przechodzi, zobaczmy więc stan mutantów (npx stryker run
):

Jest coraz lepiej, znów ubiliśmy 2 mutanty jednym testem 😎 Myślę, że łapiesz już, o co chodzi w testach mutacyjnych i zwiększaniu mutant coverage 😉
Liczba testów: 8 (+2), mutant coverage: 89,29%, mutants survived: 6 (-4)
Pozostałe 6 mutantów, które przeżyły, wygląda bardzo podobnie:

Wszystkie są mutacjami typu EqualityOperator
. Dopiszmy więc kolejne testy sprawdzające nieprzetestowane warunki brzegowe znalezione przez Strykera:
100% mutant coverage
Na koniec tej iteracji sytuacja mutantowa wygląda następująco:

Zabiliśmy wszystkie mutanty i doszliśmy do 100% mutant coverage. Zauważ, jak obecnie wygląda nasza sytuacja:
Liczba testów: 12 (+6), mutant coverage: 100%, mutants survived: 0 (-10)
Zaczynaliśmy od 6 testów, a na koniec „za darmo” podwoiliśmy ich liczbę. Dodatkowo, poza 100% code (statements, branch, functions oraz line) coverge, mamy 100% mutant coverage. Taka sytuacja daje nam bardzo duży komfort w refactoringu i wprowadzaniu koniecznych zmian do kodu produkcyjnego.
Moim zdaniem testy mutacyjne w TypeScript powinny być standardem. Wysiłek jest bardzo mały, a korzyści – jak widzisz – ogromne.
A jakie jest Twoje zdanie na temat testowania mutacyjnego? Korzystasz? Jeśli nie, to czy zachęciłem Cię do obadania tematu? Daj znać w komentarzu 😉