Ostatnio zainteresowałem się automatycznym mierzeniem jakości testów jednostkowych (unit tests). Testy jednostkowe pozwalają na sprawdzenie czy mój kod nie działa niepoprawnie dla przypadków testowych. W żaden sposób jednak nie są w stanie udowodnić, że mój kod w 100% (dla wszystkich możliwych przypadków) funkcjonuje w sposób prawidłowy.
Jednakże jak stwierdzić, że testy, które napisałem są właściwe? Jak stwierdzić, że wyczerpująco sprawdzają zaimplementowaną funkcjonalność? Jak stwierdzić, że sprawdzają mój kod na tyle, ile jest to tylko możliwe? I jak zrobić to w pełni automatycznie? Z pomocą przychodzą metody automatycznego sprawdzania jakości/poprawności testów.
Do tego, aby automatycznie sprawdzić jakość testów jednostkowych, bez wątpienia potrzeba:
- kodu testowanego - kodu, który zawiera pewną funkcjonalność, którą będziemy testować
- działających, wykonujących się poprawnie testów jednostkowych dla kodu testowanego.
Spotkałem się z dwiema możliwościami automatycznego sprawdzenia jakości kodu opartymi na:
- sprawdzaniu pokrycia kodu testami,
- testowaniu mutacyjnym.
Postaram się teraz w kilku słowach trochę opisać te dwa pomysły na mierzenie jakości testów jednostkowych.
Pokrycie kodu (code coverage)
Instrukcja, zawarta w kodzie, uważana jest za pokrytą, gdy zostanie wywołana przez test przynajmniej jeden raz. Stosunek ilości instrukcji pokrytych do ogólnej liczby instrukcji określa nam procent pokrycia kodu testami. Najlepiej oczywiście gdy wynosi 100%, wtedy mamy pewność, że każda instrukcja została wywołana podczas uruchamiania testów. Często jednak w praktyce za zadowalającą uznaje się wartość w okolicach 80%.
Istnieją jednak pewne czarne strony tej metody. To, że każda instrukcja zostanie uruchomiona w trakcie testów, wcale nie oznacza, że wszystkie możliwe warianty zostały sprawdzone i że kod działa poprawnie. Poniżej krótki przykład ilustrujący taki przypadek.
Weźmy dla przykładu prostą metodę dzielącą dwie liczby i zwracającą wynik dzielenia:
public class SimpleMath
{
public static decimal? Dziel( decimal dzielna,
decimal dzielnik )
{
return dzielna / dzielnik;
}
}
I test sprawdzający działanie tej metody:
[TestFixture]
public class TestSimpleMath
{
[Test]
public void TestDzielenie()
{
decimal? wynik = SimpleMath.Dziel( 4m, 2m );
Assert.AreEqual( 2m, wynik );
}
}
W tym przypadku pokrycie kodu testami wynosi 100%.
Mimo to bardzo łatwo możemy stwierdzić, że dla wywołania funkcji z parametrami 4 i 0 (zero):
[Test]
public void TestDzieleniePrzezZero()
{
decimal? wynik = SimpleMath.Dziel( 4m, 0m );
Assert.IsNull( wynik );
}
otrzymamy wyjątek System.DivideByZeroException.
Tym samym udowodniłem, że nawet mając pokrycie kodu testami w 100%, nie oznacza to, że kod działa prawidłowo i nie zawiera błędów.
Poprawna metoda Dziel() przechodząca testy dla tego przykładu wygląda następująco:
public class SimpleMath
{
public static decimal? Dziel( decimal dzielna,
decimal dzielnik )
{
if ( dzielnik == 0 )
return null;
return dzielna / dzielnik;
}
}
Testowanie mutacyjne (mutation testing)
Jak pokazałem wcześniej, badanie skuteczności testów za pomocą pokrycia kodu testowanego, nie daje pewności co do poprawności jego działania. Możemy jednak przeprowadzić inne sprawdzenie jakości testów oparte na testowaniu mutacyjnym.
Przebieg procesu testowania mutacyjnego przedstawiony jest na poniższym schemacie:
Cały proces polega na wielokrotnym mutowaniu kodu testowanego, uruchamianiu testów jednostkowych i sprawdzaniu czy po dokonanej mutacji nadal wykonują się poprawnie.
Według mnie jest to nic więcej jak wykorzystanie zmodyfikowanych algorytmów genetycznych. Choć pomysł wygląda na prosty i łatwy, to największą trudnością jest proces mutowania, a największym minusem bardzo długi czas wykonywania testów.
W kilku następnych postach postaram się przybliżyć, "czym to się je" w .NET.