O tym, że testy jednostkowe pisać trzeba i warto już chyba nikogo przekonywać nie trzeba (nieprzekonanych zachęcam do podzielenia się swoimi wątpliwościami). Jednak samo posiadanie testów to oczywiście nie wszystko. Oprócz wspomnianym już na trzeciejkawie zagadnieniu pielęgnacji testów jednostkowych istnieje jeszcze przynajmniej kilka ważnych aspektów tego zagadnienia. Dzisiaj chciałbym zająć się kontrolą jakości testów jednostkowych…
Ha! Właśnie – jakość testów… Brzmi dość abstrakcyjnie, prawda? Jak coś co sprawdza jakość naszego kodu może zostać sprawdzone? Na szczęście dla nas może. Za chwilę postaram się przybliżyć nieco znane metody mierzenia jakości testów. Żeby urozmaicić nieco lekturę posłużymy się konkretnym przykładem a żeby nie tworzyć abstrakcyjnych konstrukcji, jako baza do eksperymentów posłuży nam znana biblioteka spod znaku Apache – Commons Lang. Projekt posiada dość pokaźną liczbę dostępnych raportów wygenerowanych przy użyciu różnych narzędzi, gdzie możemy między innymi zobaczyć statystyki dotyczące testów. Widzimy, że projekt ma ponad 2000 napisanych testów i około 90% pokrycie kodu testami – będzie na czym eksperymentować!
Jeżeli mówimy o sprawdzaniu jakości testów, to powinniśmy być w stanie tą jakość w jakiś sposób zmierzyć. Tradycyjnie już za miarę jakości testów jednostkowych przyjmuje się współczynnik pokrycia kodu aplikacji testami. Jak postaram się zaraz pokazać nie jest to jedyna miara, jakiej możemy używać a na pewno nie wystarczająca… Mierzenie pokrycia kodu testami daje nam konkretną informację o poziomie „przetestowania” kodu naszej aplikacji. Ale czy zawsze jest to informacja dająca pewność? U podstawy tej metryki stoi przekonanie, że linie kodu aplikacji wykonane przez testy, zostały rzeczywiście przetestowane. Oczywiście nie musi to być prawdą. Poniżej zamieszczam fragment kodu, którym „przetestowałem” klasę StringUtils:
[crayon lang=”java” nums=”1″ start=”1″]
package org.apache.commons.lang3;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Random;
import org.junit.Test;
public class StringUtilsTestAUTO {
@Test
public void testThat() {
Method[] methods = StringUtils.class.getMethods();
StringUtils utils = new StringUtils();
for (int j = 0; j < 5; j++) {
for (Method method : methods) {
Class[] paramClasses = method.getParameterTypes();
Object[] parameters = new Object[paramClasses.length];
for (int i = 0; i < parameters.length; i++) {
parameters[i] = generateRandomObject(paramClasses[i]);
}
try {
method.invoke(utils, parameters);
} catch (Exception ex) {
}
}
}
}
private Object generateRandomObject(Class<?> clazz) {
Random rand = new Random();
if (clazz.isAssignableFrom(String.class)
|| clazz.isAssignableFrom(CharSequence.class)) {
return rand.nextBoolean() ? null : RandomStringUtils.random(
rand.nextInt(100), rand.nextBoolean(), rand.nextBoolean());
} else if („int”.equals(clazz.getName())) {
return rand.nextBoolean() ? null : rand.nextInt(100);
} else if („long”.equals(clazz.getName())) {
return rand.nextBoolean() ? null : rand.nextLong();
} else if („char”.equals(clazz.getName())) {
return rand.nextBoolean() ? null : RandomStringUtils.random(1,
rand.nextBoolean(), rand.nextBoolean()).charAt(0);
} else if (clazz.isAssignableFrom(String[].class)) {
return rand.nextBoolean() ? null : new String[] { RandomStringUtils
.random(rand.nextInt(100), rand.nextBoolean(),
rand.nextBoolean()) };
} else if (clazz.isAssignableFrom(Iterator.class)) {
return rand.nextBoolean() ? null : Arrays.asList(
new String[] { RandomStringUtils.random(rand.nextInt(100),
rand.nextBoolean(), rand.nextBoolean()) })
.iterator();
}
return null;
}
}
[/crayon]
Jak widać test jest prosty i nie wymagający wiele od kodu – wywołujemy wszystkie metody klasy StringUtils z użyciem losowych parametrów i nie przejmujemy się w ogóle wynikami… Pokrycie testami wynosi średnio ok 64% – nieźle jak na parędziesiąt linii kodu. Dla porównania pokrycie testami tej samej klasy, przez testy stworzone przez zespół Apache, wynosi 97% i zawiera się w ponad 4200 linii. Oczywiście takie „sztuczne” testy nie dają nam żadnej wiedzy na temat jakości naszego kodu!
Można by się w tym momencie zacząć zastanawiać kto przy zdrowych zmysłach pisze testy, które niczego nie testują (i nie mam tu na myśli tak ekstremalnych przypadków jak w przykładzie). Powodów może być wiele – zaczynając od braku umiejętności, kiedy wydaje nam się, że coś testujemy, a kończąc na celowym „skracaniu” sobie drogi, kiedy nie mamy już pomysłu co przetestować, a do upragnionych 80% pokrycia jeszcze daleko… Zakładając, że w naszej organizacji dokonujemy regularnych przeglądów kodu (także kodu testów jednostkowych) powinniśmy wykryć takie „celowe” błędy. Kod z przykładu na pewno zostałby zakwestionowany przez dobrze skonfigurowany Checkstyle (z powodu przechwytywania wszystkich wyjątków), brak Assert’ów także powinien zwrócić naszą uwagę…
Co jednak zrobić z błędami w testach, które nie są tak oczywiste? Prawdopodobnie większość z Was zna pojęcie „posiewania błędów”. Jest to metodologia, teoretycznie pozwalająca na oszacowanie liczby błędów w systemie informatycznym, poprzez wprowadzenie do niego celowo znanej liczby błędów. Na podstawie ilości błędów odkrytych w czasie testów systemowych możemy następnie oszacować (ale już nie zlokalizować) całkowitą liczbę błędów w naszej aplikacji. Podobne podejście można także znaleźć w świecie testów jednostkowych. Mowa tutaj o testowaniu mutacyjnym.
Testowanie mutacyjne, podobnie jak posiewanie błędów opiera się na idei modyfikacji (wprowadzaniu błędów) do kodu aplikacji w celu odkrycia błędów, z tą jednak różnicą, że może ono zostać całkowicie zautomatyzowane. Zakładamy, że wprowadzenie zmian w kodzie spowoduje, że testy które sprawdzają ten fragment kodu powinny zgłosić błąd – oznacza to bowiem, że sprawdzają poprawność zmienionego fragmentu. Oczywiście rodzi to wiele pytań.
Po pierwsze – jak automatycznie „popsuć” kod? Możemy modyfikować warunki logiczne, operatory matematyczne, wartości stałych – zachęcam do zapoznania się z listą mutacji obsługiwaną przez frameworki testów mutacyjnych – PIT lub Jumble. Niestety automatyczne wprowadzanie zmian, wiąże się z wprowadzaniem tzw. mutacji równoważnych. Są to modyfikacje kodu, które nie powodują zmian w zachowaniu programu a co za tym idzie, niemożliwe do wykrycia. Przykład takiej modyfikacji poniżej:
[crayon lang=”java”]
for (int i = 0; i < 10; i++) {
if (i == 5) {
return true;
}
}
[/crayon]
Jeżeli zamienimy warunek logiczny == na >=
[crayon lang=”java”]
for (int i = 0; i < 10; i++) { if (i >= 5) {
return true;
}
}
[/crayon]
Działanie naszego programu nie zmieni się, ale narzędzia do testów mutacyjnych zgłoszą potencjalny błąd. Wykrywanie takich błędów jest bardzo trudne i stanowi jeden z poważniejszych problemów przy wykorzystaniu testów mutacyjnych w praktyce.
Po drugie – ile to kosztuje? Samo uruchomienie testów mutacyjnych nie kosztuje praktycznie nic. Mutacje generują się automatycznie, a konfiguracja projektu używającego Mavena nie powinna nam zająć więcej niż minutę – krótki tutorial na stronie PIT’a może nam pomóc. Niestety jest jedno ale. Jak można sobie wyobrazić liczba możliwych mutacji rośnie bardzo szybko wraz ze złożonością aplikacji. Wiąże się to z wielokrotnym wykonaniem testów dla każdej mutacji. Dla przykładowej klasy StringUtils, PIT wygenerował 1378 mutacji a czas wykonania testów wzrósł do 150 sekund z początkowych 300 milisekund. Daje to pojęcie z jakim wzrostem złożoności testów mamy do czynienia.
Po trzecie – czy to w ogóle działa? Uruchomiłem PIT’a i muszę przyznać, że wyniki są zgodne z oczekiwaniami. „Prawdziwe” testy wykryły 91% mutacji, co według mnie jest bardzo dobrym wynikiem.
Detected 1257 of 1378 mutations in around 168 secs
org.apache.commons.lang3.StringUtils.html
Line coverage 98% Mutation coverage 91%
Tests
org.apache.commons.lang3.StringUtilsTest org.apache.commons.lang3.StringEscapeUtilsTest org.apache.commons.lang3.StringUtilsStartsEndsWithTest org.apache.commons.lang3.StringUtilsSubstringTest org.apache.commons.lang3.StringUtilsIsTest org.apache.commons.lang3.StringUtilsEqualsIndexOfTest org.apache.commons.lang3.StringUtilsTrimEmptyTest
Mutated classes
org.apache.commons.lang3.StringUtils
Testów „sztucznych” nie trzeba chyba komentować…
Detected 4 of 1348 mutations in around 107 secs
org.apache.commons.lang3.StringUtils.html
Line coverage 62% Mutation coverage 0%
Tests
org.apache.commons.lang3.StringUtilsTestAUTO
Mutated classes
org.apache.commons.lang3.StringUtils
Powyższe proste porównanie prawdziwych testów jednostkowych z ich „symulacją”, stworzoną tylko po to, aby osiągnąć jakieś pokrycie kodu testami pokazuje wartość testów mutacyjnych. Zarówno w prostych jak i bardziej złożonych problemach pomagają one odróżnić testy „dobre” od „byle jakich”.
Otwartym pytaniem pozostaje jedynie jaką wartość pokrycia (odkrycia?) mutacji można uznać za zadowalającą? Podobnie jak w przypadku mierzenia pokrycia kodu testami wiadomo tylko dwie rzeczy. Po pierwsze im więcej tym lepiej (oczywiście z zachowaniem zdrowego rozsądku – ślepe dążenie do 100% nie zawsze niesie ze sobą realne korzyści). Po drugie nigdy nie dojdziemy do 100% (i to nie dla tego, że się nie da) – każdy musi znaleźć własny złoty środek (według mnie rozsądna jest zasada, że pokrycie kodu testami nie powinno się nigdy zmniejszać).
Podsumowując – jeśli korzystasz już z potężnego narzędzia jakim są testy jednostkowe – to zachęcam do spróbowania testów mutacyjnych i zweryfikowania jaka jest realna wartość istniejących testów. Jeśli uda się Wam odkryć coś ciekawego zapraszam do podzielenia się swoimi wynikami.