Testy mutacyjne to proces, w którym celowo wprowadzane są błędy w kodzie (mutacje), aby sprawdzić, czy testy nadal będą pozytywne. W takim przypadku oznacza to, że zestaw testów może być niewystarczający do pokrycia potencjalnie wprowadzonych błędów. Testowanie mutacyjne pozwala na identyfikację potencjalnych luk w zestawie testów.
Mutation Coverage to metryka wskazująca na stosunek linii kodu poddanych modyfikacjom, które zostały wykryte przez testy, w stosunku do wszystkich linii kodu. Wartość na poziomie 85% (11/13) oznacza, że w 2 liniach kodu zostały wprowadzone modyfikacje, które nie zostały wykryte przez istniejący zestaw testów.
Code Coverage to metryka jakościowa, która określa, w jakim stopniu kod produkcyjny jest uruchamiany podczas wykonywania zestawu testów. Z założenia program o wyższym Code Coverage, wyrażanym w procentach, jest uruchamiany w większym zakresie, więc wnioskujemy, że skoro testy się powiodły, program zawiera mniej błędów. O tym, w jaki sposób wyznaczana jest metryka Code Coverage oraz jakie ma wady -pisałem tutaj.
Z tego artykułu dowiesz się:
- Czym są Testy Mutacyjne i Mutation Coverage.
- Jak sprawdzić, czy testy naprawdę testują wszystko? Czy nie brakuje jakiejś asercji?
- Jak przeanalizować raport z Testów Mutacyjncych.
Piguła wiedzy o najlepszych praktykach testowania w Java
Pobierz za darmo książkę 100 stron o technikach testowania w Java
Czy testy testują wystarczająco dużo?
Skoro mierzony jest Code Coverage, czyli program przechodzi po wszystkich liniach kodu, a testy są zielone, można zadać pytanie: Czy naprawdę testujemy wszystkie przypadki brzegowe? Czy istnieje taka zmiana w kodzie, która nie zostałaby wykryta przez testy?
Jako przykład przytoczę kod, który określa koszt dostawy zamówienia w sklepie internetowym. Wymagania:
- Do 50 km, zamówienie dostarczane jest za darmo.
- Do 100 km obowiązuje stała cena 10 zł.
- Powyżej 100 km obowiązuje stała cena + stawka 0,2 zł za każdy dodatkowy kilometr.
- Użytkownicy premium zawsze mają dostawę za darmo.
Wyżej wymienione parametry cenowe i odległości zastąpię parametrami w kodzie, ale tych wartości użyję w testach.
Przykładowa implementacja wyżej wymienionych wymagań:
package pl.softwareskill.example.codecov;
import lombok.RequiredArgsConstructor;
import static pl.softwareskill.example.codecov.UserType.Premium;
@RequiredArgsConstructor
public class ShippingCostCalculator {
private static final long METERS_IN_KILOMETER = 1000;
private final long freeDeliveryMaxDistance;
private final long fixedCostDistance;
private final double fixedCost;
private final double costPerKm;
public double calculate(final long distanceInMeters, final UserType userType) {
if (userType == Premium || distanceInMeters <= freeDeliveryMaxDistance) {
return 0;
}
if (distanceInMeters <= fixedCostDistance) {
return fixedCost;
}
return fixedCost + additionalCostPerKm(distanceInMeters);
}
private double additionalCostPerKm(long distanceInMeters) {
long additionalDistanceInKm = (distanceInMeters - fixedCostDistance) / METERS_IN_KILOMETER;
return additionalDistanceInKm * costPerKm;
}
}
* Implementacja cen na typ double, nie jest najlepszym pomysłem (np. ograniczenia arytmetyki zmiennoprzecinkowej w postaci binarnej), ale dla uproszczenia przykładu, użyłem tego typu.
Dopiszę następujące przypadki testowe, które przychodzą mi do głowy na teraz, myśląc, że to główne przypadki:
package pl.softwareskill.example.codecov;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ShippingCostCalculatorTest {
private final long FREE_MAX_DISTANCE = 50_000;
private final long FIXED_COST_DISTANCE = 100_000;
private final double COST_FIXED = 10;
private final double COST_PER_KM = 0.2;
private final long FAR_DISTANCE = FREE_MAX_DISTANCE + 1;
ShippingCostCalculator shippingCostCalculator = new ShippingCostCalculator(FREE_MAX_DISTANCE, FIXED_COST_DISTANCE, COST_FIXED, COST_PER_KM);
@Test
void freeShippingForPremiumRegardlessDistance() {
assertThat(shippingCostCalculator.calculate(FAR_DISTANCE, UserType.Premium))
.isZero();
}
@Test
void belowFixedCostDistance() {
long belowFixedCostDistance = FIXED_COST_DISTANCE - 10_000;
assertThat(shippingCostCalculator.calculate(belowFixedCostDistance, UserType.Regular))
.isEqualTo(COST_FIXED);
}
@Test
void pricePerKmIsAddedForBreachedFixedDistance() {
long breachedDistanceKm = 10;
long breachedDistance = breachedDistanceKm * 1_000;
long distance = FIXED_COST_DISTANCE + breachedDistance;
assertThat(shippingCostCalculator.calculate(distance, UserType.Regular))
.isEqualTo(COST_FIXED + (COST_PER_KM * breachedDistanceKm));
}
}
Po uruchomieniu testów JaCoCo (IntelliJ też) wskazuje na 100% line coverage. Oznacza to, że wszystkie warunki końcowe zostały osiągnięte:
- Jak sprawdzić, czy testy naprawdę testują wszystko?
- Czy nie brakuje jakiejś asercji?
Testy mutacyjne w Java
Testowanie mutacyjne polega na wprowadzeniu zmian w kodzie (mutacjach) oraz zaobserwowaniu, czy testy ją wykryją. Jeżeli nie – jest to potencjalny obszar do poprawy poprzez ulepszenie zestawu testów.
W Java dostępny jest framework Pitest przeprowadzający mutacje kodu. Projekt dostępny jest pod adresem: https://pitest.org/. Działa to, jako plugin do narzędzi budowania Maven/Gradle.
Zasada działania
Zasada działania testów mutacyjnych – to sprawdzenie które linie kodu są uruchamiane przez które testy. Następne wprowadzenie mutacji i ponowne uruchomienie testów pokrywających daną linię. Tym sposobem podczas modyfikacji jednej linii nie są uruchamiane wszystkie testy.
Wynik działania jest oznaczony:
- KILLED – oznacza, że testy wykryły wprowadzony błąd.
- SURVIVED – oznacza, że testy „przetrwały”, czyli nie zauważyły wprowadzonej zmiany.
Przykładowe mutacje
- Zamiana warunków, np. <= zmieniony na < lub: = na !=
- Podstawienie pod warunek stałej wartości zawsze true i zawsze false (sprawdzenie, czy warunek jest istotny)
- Zmiana operacji arytmetycznych na przeciwne, np. + na – lub: * na /
- Zwracanie z metod „pustej” wartości (0, null, pusty string)
- Podstawienie stałej
Pełna lista mutacji przeprowadzanych przez Pitest: https://pitest.org/quickstart/mutators/
Konfiguracja Pitest
Przykładowe repozytorium, z prezentowanym poniżej kodem oraz ze skonfigurowanym Pitest (mutation coverage), JaCoCo (code coverage), Github Contineous Integration oraz integracją z Codecov.io umieściłem w repozytorium:
Pitest działa jako plugin do narzędzi budowania Maven/Gradle. Przykładowa konfiguracja dla Maven:
<build>
<plugins>
<!-- mutation testing -->
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.5.1</version>
<executions>
<execution>
<id>pit-report</id>
<phase>test</phase>
<goals>
<goal>mutationCoverage</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>0.12</version>
</dependency>
</dependencies>
<configuration>
<targetClasses>
<param>pl.softwareskill.example.codecov.*</param>
</targetClasses>
<targetTests>
<param>pl.softwareskill.example.codecov.*</param>
</targetTests>
<mutators>
<mutator>STRONGER</mutator>
</mutators>
</configuration>
</plugin>
</plugins>
</build>
Wskazujemy dwa kluczowe parametry dla plugin`u:
targetClasses
– pod jakim pakietem znajduje się testowany kod,targetTests
– pod jakim pakietem znajduje się kod testujący,- (opcjonalnie)
mutators
– lista mutacji/grup użytych w procesie. Pełna lista: https://pitest.org/quickstart/mutators/
Analiza raportu Mutation Coverage
Sprawdźmy zatem wynik działania testów mutacyjnych. Nie jest źle. Mutation Coverage 85%, 11/13, czyli 2 linie kodu nie są wystarczająco dobrze pokryte przez testy.
Mogę przeanalizować konkretną implementację:
Usprawnienie testów
W linii 18 zostały wprowadzone 4 zmiany, ale jedna z nich nie zgłosiła błędów w teście. Zmieniono <= na <. Sprawdzam zestaw testów i faktycznie, brakuje całego testu sprawdzającego darmową dostawę dla użytkownika, który nie jest premium, ale nie przekroczył odległości darmowej dostawy:
@Test
void freeShippingBelowFreeDeliveryDistance() {
assertThat(shippingCostCalculator.calculate(FREE_MAX_DISTANCE, UserType.Regular))
.isZero();
}
Po uzupełnieniu – raport w tej części wygląda znacznie lepiej:
Podobna mutacja dotyczy linii 22: Zmieniono <= na <. Faktycznie, posiadam test belowFixedCostDistance sprawdzający, czy za dostawę jest stała opłata, gdy jej dystans wynosi poniżej 100 km. Natomiast nie mam testu dla dokładnie 100 km. Dopisuję brakujący test:
@Test
void atFixedCostDistance() {
long atFixedCostDistance = FIXED_COST_DISTANCE;
assertThat(shippingCostCalculator.calculate(atFixedCostDistance, UserType.Regular))
.isEqualTo(COST_FIXED);
}
Ponownie uruchamiam testy mutacyjne, ale błąd w raporcie nadal występuje. Co się stało?
Gdyby zmienić warunek <= na <, kod przechodzi do dalszych instrukcji i uruchamia fragment obliczający koszt dostawy. Logika mówi o tym, że opłata wynosi: 10 zł (do 100 km) + dodatkowa opłata 0,20 zł za każdy km. Zatem dla dokładnie 100 km nie ma dodatkowych kilometrów i koszt finalnie będzie wynosił tyle samo, ile w warunku wyznaczonym linijkę zwracającą stały koszt dostawy, do której mutacja dotarła przez zmodyfikowanie warunku.
Wnioski
Testy mutacyjne wprowadzają zestaw modyfikacji i uruchamiają testy ponownie. Niestety metoda nie jest idealna z dwóch powodów:
- Zestaw modyfikacji jest określony i nie pokrywa wszystkich potencjalnych błędów.
- Wprowadzone mutacje mogą zgłaszać nadmiarowe przypadki, kiedy wprowadzona modyfikacja naprawdę nie ma wpływu na logikę.
Przeglądając raport, należy przeprowadzić ręczną analizę przypadków, w których testy nie wykryły zmiany. Jednak testowanie mutacyjne pozwala na identyfikację potencjalnych luk w zestawie testów.
Jeśli przeczytałeś/aś ten artykuł i jeśli Ci się spodobał – będę bardzo wdzięczny, jeśli podzielisz się nim ze swoimi znajomymi i dołączysz do naszego fanpage na FaceBook – https://www.facebook.com/softwareskill
Wpis który czytasz to zaledwie fragment wiedzy zawartej w Programie szkoleniowym Java Developera od SoftwareSkill. Mamy do przekazania sporo usystematyzowanej wiedzy z zakresu kluczowych kompetencji i umiejętności Java Developera. Program składa się z kilku modułów w cotygodniowych dawkach wiedzy w formie video.
Piguła wiedzy o najlepszych praktykach testowania w Java
Pobierz za darmo książkę 100 stron o technikach testowania w Java
Gdybyś potrzebował jeszcze więcej:
Jesteś Java Developerem?
Przejdź na wyższy poziom wiedzy
„Droga do Seniora” 🔥💪
Jesteś Team Leaderem? Masz zespół?
Podnieś efektywność i wiedzę swojego zespołu 👌
Podsumowanie
Testy mutacyjne to proces, w którym celowo wprowadzane są błędy w kodzie (mutacje), aby sprawdzić, czy testy nadal będą pozytywne. W takim przypadku oznacza to, że zestaw testów jest niewystarczający do pokrycia potencjalnie wprowadzonych błędów. Testowanie mutacyjne pozwala na identyfikację potencjalnych luk w zestawie testów.
Mutation Coverage to metryka wskazująca na stosunek linii kodu poddanych modyfikacjom, które zostały wykryte przez testy w stosunku do wszystkich linii kodu. Wartość na poziomie 85% (11/13) oznacza, że w 2 liniach kodu zostały wprowadzone modyfikacje, które nie zostały wykryte przez istniejący zestaw testów.
Użyj frameworka Pitest.
Obraz: Papier plik wektorowy utworzone przez macrovector – pl.freepik.com