Kurs TDD cz. 10 — Teorie

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:

  • W zwykłym teście dostarczamy zbiór danych wejściowych metodzie testowanej, a następnie weryfikujemy zbiór danych wyjściowych ze zbiorem danych wyjściowych oczekiwanych.
  • Teoria ma na celu weryfikację ogólnego twierdzenia dla danych wejściowych spełniających żądane kryteria.

Rozbijmy teorię teorii na czynniki pierwsze i wtedy wszystko stanie się jasne i proste…

Dane wejściowe

Dane wejściowe do teorii dostarczane są za pomocą:

  • zmiennych oznaczonych atrybutem [Datapoint]:
[Datapoint]
public int Positive = 1;
  • lub tablic oznaczonych atrybutem [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.

Założenia

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:

  • Jeśli wszystkie założenia w ramach jednej teorii zostaną niespełnione, to test się nie powiedzie, niezależnie od dalszych jego asercji.
  • Założenia mogą być umieszczone w dowolnym miejscu metody (nie tylko na jej początku).
  • Poza założeniami, teoria zachowuje się jak zwykły test, tj. asercje determinują stan testu.

Przykład

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:

fkJHZ[1]

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!!

Jakie są zalety takiego rozwiązania?

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.

Zobacz też

Opublikowano 25 lutego 2015

Blog o programowaniu
Dariusz Woźniak · GitHub · LinkedIn · Twitter · Goodreads