Załóżmy, iż mamy pewną aplikacje, o którą bardzo się troszczymy i robimy wszystko, żeby zapewnić jak najlepszą jakość kodu tejże aplikacji. Pokrycie kodu testami jednostkowymi jest jednym ze sposobów, którego możemy użyć, aby 'zabezpieczyć’ nasz kod przed błędami. Dwoimy się i troimy, aż w końcu udaję nam się pokryć nasz kod w 100% i teraz mamy pewność, iż żaden bug nie wkradł się do naszej aplikacji! Ale czy na pewno?
Stwórzmy aplikacje, o którą zadbamy!
Zacznijmy od 'zewnętrznego’ serwisu, który będziemy wstrzykiwać do naszej klasy.
public interface ExternalService { int methodOne(); int methodTwo(); }Teraz stwórzmy główną klasę naszej aplikacji:
public class WellTestedClass { private final ExternalService externalService; public WellTestedClass(final ExternalService externalService) { this.externalService = externalService; } public int wellTestedMethod() { final int n1 = this.externalService.methodOne(); final int n2 = this.externalService.methodTwo(); if (n1 > n2) { return n1 - n2; } return n1 + n2; } }Jak widzimy, nie ma tutaj żadnego 'Rocket Science’ Pobieramy z zewnętrznego serwisu dwie liczby, a następnie je porównujemy. o ile pierwsza z nich jest większa to odejmujemy od niej drugą liczbę i otrzymaną liczbę zwracamy jako wynik. W przeciwnym przypadku jako rezultat zwracamy sumę dwóch pobranych liczb.
Pora przetestować naszą aplikacje!
Stwórzmy najpierw pomocniczą klasę, która będzie implementować interfejs naszego zewnętrznego serwisu.
class ExternalServiceStub implements ExternalService { private final int n1; private final int n2; ExternalServiceStub(final int n1, final int n2) { this.n1 = n1; this.n2 = n2; } @Override public int methodOne() { return n1; } @Override public int methodTwo() { return n2; } }I teraz pierwsza metoda testowa:
@Test public void wellTestedMethod1() throws Exception { // given final WellTestedClass wellTestedClass = new WellTestedClass(new ExternalServiceStub(4, 3)); // when final int result = wellTestedClass.wellTestedMethod(); // then assertThat(result).isEqualTo(4 - 3); }W powyższej metodzie testowej sprawdziliśmy pierwszy przypadek, czyli sytuacje gdzie pierwsza pobrana liczba z zewnętrznego serwisu jest większa od kolejnej.
Teraz pora na drugi przypadek:
@Test public void wellTestedMethod2() throws Exception { // given final WellTestedClass wellTestedClass = new WellTestedClass(new ExternalServiceStub(3, 4)); // when final int result = wellTestedClass.wellTestedMethod(); // then assertThat(result).isEqualTo(3 + 4); }Tutaj pokryliśmy drugi przypadek, czyli sytuacje gdy druga liczba jest większa od pierwszej.
Wygląda na to, iż pokryliśmy wszystkie 'branche’ i nasz kod jest w pełni przetestowany, ale skąd mamy wiedzieć jak dobre są nasze testy jednostkowe?
Testy mutacyjne
Tutaj w pomocą przychodzą nam testy mutacyjne. Czym zatem są owe testy? Jest to technika polegająca na wprowadzaniu małych i losowych zmian w kodzie naszej aplikacji. Zmiany te powinny zostać wykryte przez nasze testy jednostkowe. Jeżeli, któraś ze zmian nie została wykryta oznacza to, iż nasze testy mogą nie być tak dobre jak nam się wydawało
Jakie zmiany?
Poniżej znajduję się lista z przykładowymi zmianami, które mogą zostać wprowadzone w naszym kodzie.
- Zmiana granicy w warunkach, np. > zostanie zmienione na >=, >= na >, itd.
- Negacja warunków, np. == zostanie zmienione na !=, <= na >, itd.
- Usunięcie warunków i zastąpienie ich stałą wartością, np. a > b zostanie zmienione na true
- Zmiana operacji matematycznych, np. dodawanie zostanie zamienione na odejmowanie, a mnożenie na dzielenie
- Zmiana wartości zmiennych na wartości defaultowe lub stałe, np. int zostanie ustawiony na 0 lub inną losową wartość
- Zwrócenie null zamiast obiektu
- Pominięcie wywołania metody typu void
Właśnie zapoznaliśmy się z przykładowymi modyfikacja, które mogą zostać wprowadzone do naszej aplikacji podczas testów mutacyjnych. Nasze testy jednostkowe powinny być napisane w taki sposób, aby zmiany te spowodowały to, iż nasze testy nie przejdą.
Testy mutacyjne w praktyce
Wróćmy teraz do naszego kodu, który napisaliśmy na początku i spróbujmy przeprowadzić testy mutacyjne. Z pomocą przyjdzie nam biblioteka PIT!
Konfiguracja
Konfiguracja i uruchomienie PIT są banalnie proste! Pierwsze co musimy zrobić to dodać plugin do naszego poma:
<plugin> <groupId>org.pitest</groupId> <artifactId>pitest-maven</artifactId> <version>1.2.0</version> </plugin>Domyślnie wszystkie klasy z naszej aplikacji zostaną poddane testom mutacyjnym. o ile chcemy to zmienić to możemy skonfigurować pakiety klas/testów, które będą wzięte pod uwagę.
<plugin> <groupId>org.pitest</groupId> <artifactId>pitest-maven</artifactId> <version>1.2.0</version> <configuration> <targetClasses> <param>pl.lantkowiak.*</param> </targetClasses> <targetTests> <param>pl.lantkowiak.*</param> </targetTests> </configuration> </plugin>Uruchomienie
Aby przeprowadzić testy mutacyjne wystarczy wywołać następujące polecenie:
mvn org.pitest:pitest-maven:mutationCoverageGdy operacja zakończy się sukcesem zostanie wygenerowany raport z wynikami. Znajduje się on pod następującą ścieżką: target/pit-reports/yyyyMMddHHmm.
Zmutujmy naszą aplikacje!
Pora wrócić do naszej aplikacji i wykonać na niej testy mutacyjne
Po zakończeniu testów otrzymamy wygenerowany raport.
Możemy z niego wyczytać, iż nasz kod jest w pełni pokryty przez nasze testy jednostkowe (Line Coverage). Możemy również zobaczyć trochę czerwonego koloru przy pokryciu mutacyjnych testów, a jak możemy się domyślać czerwony kolor nie oznacza nic dobrego
Po wklikaniu się trochę głębiej będziemy mogli zobaczyć poniższy ekran.
Możemy na nim zobaczyć, która linia naszego programu nie jest wystarczająco dobrze przetestowana, a poniżej listę mutacji, które zostały przeprowadzone w poszczególnych liniach kodu. Na zielono są zaznaczone mutacje, które zostały wykryte przez testy, natomiast na czerwono mutacje, które przeżyły i nasze testy ich nie wychwyciły.
W naszym przypadku nie została wychwycona zmiana warunku w if’ie z > na >=. Czyli w tym przypadku został wykryty warunek brzegowy, który nie został sprawdzony w testach.
Poprawy w takim razie nasz drugi test tak, aby pokrył warunek brzegowy.
@Test public void wellTestedMethod2() throws Exception { // given final WellTestedClass wellTestedClass = new WellTestedClass(new ExternalServiceStub(3, 3)); // when final int result = wellTestedClass.wellTestedMethod(); // then assertThat(result).isEqualTo(3 + 3); }Po tej modyfikacji żadne mutacje nam nie straszne i nasze testy mutacyjne przejdą na zielono
Podsumowanie
Dzisiaj zapoznaliśmy się z podstawami testów mutacyjnych. Testy te mogą nam pomóc w sprawdzeniu jak dobre są nasze testy jednostkowe. Sama koncepcja testów mutacyjnych nie jest niczym nowym, ale dopiero stosunkowo od niedawna jest używana w praktyce, ponieważ testy mutacyjne są dosyć kosztowne i wymagają sporej czasu procesora, żeby przeprowadzić wszystkie kombinacje mutacji i dopiero od niedawna nasze komputery są na tyle szybkie, żeby robić to w rozsądnym czasie