Doskonałym uzupełnieniem wpisów o testach parametryzowanych i kombinatorycznych jest omówienie tzw. “teorii”. Teoria jest specjalnym rodzajem testu, w którym weryfikujemy dane twierdzenie przy pomocy założeń (ang. assumptions). Dla porównania:
Rozbijmy teorię teorii na czynniki pierwsze i wtedy wszystko stanie się jasne i proste…
Dane wejściowe do teorii dostarczane są za pomocą:
[Datapoint]
:[Datapoint]
public int Positive = 1;
[Datapoints]
:[Datapoints]
public int[] Positive = { 1, 2 };
Dane te są przekazywane do parametrów metody testowej w sposób kombinatoryczny. Technicznie możliwe jest też przekazanie danych wejściowych w ten sam sposób, co w przypadku testów parametryzowanych, ale wtedy pisanie teorii nie ma sensu – wystarczy przecież zwykły test.
Teoria jest weryfikowana dla wszystkich danych wejściowych spełniających zadane przez nas kryteria. Do zdefiniowania założeń wykorzystujemy metodę Assume.That
, która działa podobnie jak Assert.That
, ale jeśli podany warunek nie zostanie spełniony, to rezultatem testu będzie stan “nierozstrzygnięty” (Inconclusive). Stan ten nie oznacza ani powodzenia testu, ani jego niepowodzenia. Taki stan powinniśmy (oraz nasze buildy) traktować obojętnie.
Ponadto, założenia (Assume.That
) cechują się dodatkowymi własnościami:
Nasza klasa Calculator jest idealnym przykładem możliwości wykorzystania teorii w praktyce. Jako przykład może posłużyć twierdzenie, że jeśli dzielna jest dodatnia, a dzielnik ujemny, to wynik dzielenia musi być ujemny.
public class Theory
{
[Datapoint] public int Negative = -1;
[Datapoint] public int Positive = 1;
[Theory]
public void WhenDividendIsPositiveAndDivisorIsNegative_TheQuotientIsNegative(int dividend,
int divisor)
{
Assume.That(dividend > 0);
Assume.That(divisor < 0);
var calculator = new Calculator();
float quotient = calculator.Divide(dividend, divisor);
Assert.That(quotient < 0);
}
}
Przyjrzyjmy się bliżej powyższemu przykładowi. Zestaw danych wejściowych składa się z dwóch intów: {-1, 1}. Obydwie wartości trafiają do parametrów metody testowej – dividend i divisor. Kombinatorycznie mamy więc 4 przypadki testowe:
(-1,-1)
(-1,1)
(1,-1)
(1,1)
Pierwsze założenie Assume.That(dividend > 0)
mówi o tym, że dzielna ma być dodatnia. Jeśli ten warunek nie zostanie spełniony, to test ma status nierozstrzygniętego.
W przeciwnym wypadku, sprawdzane jest drugie założenie Assume.That(divisor < 0)
, które mówi o tym że dzielnik ma być ujemny. Tak samo jak wyżej – jeśli warunek będzie nieprawdą, test będzie nierozstrzygnięty.
W przeciwnym wypadku, test podąży ścieżką zwykłego testu, a jego stan będzie zależny od asercji. W wyniku, otrzymamy następujące rezultaty:
Widzimy, że testy nierozstrzygnięte nie wpływają na stan teorii. Działoby się to tylko wtedy (o czym mówiliśmy wcześniej) jeśli wszystkie założenia w danej metodzie byłyby niespełnione. Proste? I to bardzo!!
Po pierwsze, nasz zapis kodu przypomina bardziej próbę udowodnienia twierdzenia aniżeli zestaw metod nie związanych ze sobą w żaden sposób. Zapis przypadków testowych jest w takim przypadku łatwiejszy do czytania i zarządzania.
Po drugie, posiadamy odseparowaną część dla danych wejściowych, które można dodatkowo sensownie nazwać. Dane te są filtrowane przez nasze założenia.
Teorie można stosować z powodzeniem nie tylko dla twierdzeń matematycznych, ale też w algorytmach, strukturach danych, czy też logice biznesowej.
Należy pamiętać o tym, że przypadki testowe w teoriach generują się kombinatorycznie, dzięki czemu ilość naszych testów, a co za tym idzie – czas ich wykonania – może drastycznie wzrosnąć. Ponadto, teorie powinno się wykorzystywać tylko tam, gdzie mamy do czynienia z twierdzeniem, które chcemy udowodnić. W przypadku testów, które oparte są o przykładowe dane (example-driven tests), lepiej jest użyć tych “zwykłych” testów.
Część I: Testy jednostkowe – wstęp