Testy mutacyjne w TypeScript

Testy mutacyjne w TypeScript

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 w TypeScript - wynik działania polecenia `npm test` dla biblioteki jest

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:

Wynik polecenia npx stryker run. 10 mutants survived, score 82,14%

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:

Przykład testu mutacyjnego w TypeScript

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:

Testy mutacyjne w TypeScript - wynik działania stryker-js - pokazana zmiana w kodzie (mutant)

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:

Wynik polecenia npx stryker run - 8 mutants survived, score 85,71%

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:

Kolejny przykład mutanta EqualityOperator

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):

Wynik polecenia npx stryker run - 6 mutants survived, score 89,29%

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:

Testy mutacyjne w TypeScript. Lista mutantów typu EqualityOperator (stryker-js)

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:

Wynik polecenia npx stryker run - 0 mutants survived, score 100%. 100% pokrycia - to dają nam testy mutacyjne w TypeScript

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 😉

Programista, cyfrowy nomada od 2019 r. Autor bloga programistawpodrozy.pl
Subscribe
Powiadom o
guest
0 komentarzy
Inline Feedbacks
View all comments