Kurs TDD cz. 5 — Nasz drugi test jednostkowy

W poprzedniej części kursu “Kurs TDD część 4: Nasz pierwszy test jednostkowy”) omówiłem w jaki sposób ustawić środowisko Visual Studio aby móc pisać i uruchamiać testy. W tej części omówię jak wykonać kilka prostych technik, tj. jak:

  • zgrupować testy za pomocą atrybutu [TestCase],
  • testować wyjątki,
  • testować zdarzenia.

Na tapetę idzie przykład dzielenia; chcemy napisać funkcjonalność i testy mając na uwadze, że:

  • metoda Divide należy do klasy Calculator,
  • metoda Divide przyjmuje dwa parametry wejściowe — obydwa typu int; zwracanym typem jest float,
  • po skończonym obliczeniu wywoływane jest zdarzenie CalculatedEvent,
  • w przypadku, gdy dzielnik jest równy 0, wyrzucamy wyjątek typu DivideByZeroException.

I tyle! Początkowy szkielet klasy Calculator wyglądać będzie tak:

class Calculator
{
    public float Divide(int dividend, int divisor)
    {
        throw new NotImplementedException();
    }
 
    public event EventHandler CalculatedEvent;
 
    protected virtual void OnCalculated()
    {
        var handler = CalculatedEvent;
        if (handler != null) handler(this, EventArgs.Empty);
    }
}

Test przypadków niebrzegowych i brzegowych

Pamiętając, że na wejściu mamy dwa inty, a na wyjściu float możemy rozpisać tablicę przypadków, dla których chcemy sprawdzić poprawność naszego wyniku. Mogą to być następujące testy:

  • dzielenie liczb dodatnich, zwracany typ jest liczbą całkowitą, np. 4 ÷ 2 = 2,
  • dzielenie liczby dodatniej przez ujemną i odwrotnie, zwracany typ jest liczbą całkowitą, np. -4 ÷ 2 = -2 i 4 ÷ (-2) = -2,
  • dzielenie zera przez dowolną liczbę, np. 0 ÷ 3 = 0,
  • zwracany typ jest ułamkiem skończonym, np. 5 ÷ 2 = 2,5,
  • zwracany typ jest ułamkiem nieskończonym lub zaokrąglonym, np. 1 ÷ 3 = 0,333333343f.[1]

Aby uniknąć pisania sześciu metod (można, ale da się to zrobić lepiej) czy też pisania wszystkich asercji w jednym teście (co, jak już wiemy, jest złym wzorcem) możemy skorzystać z NUnitowego atrybutu [TestCase]. Jako parametry atrybutu podajemy dane wejściowe oraz (opcjonalnie) wartość oczekiwaną, natomiast definicja poszczególnych elementów zawarta jest w parametrach testu jednostkowego. Nasz test będzie wyglądać tak:

[TestCase(4, 2, 2.0f)]
[TestCase(-4, 2, -2.0f)]
[TestCase(4, -2, -2.0f)]
[TestCase(0, 3, 0.0f)]
[TestCase(5, 2, 2.5f)]
[TestCase(1, 3, 0.333333343f)]
public void Divide_ReturnsProperValue(int dividend, int divisor, float expectedQuotient)
{
    var calc = new Calculator();
    var quotient = calc.Divide(dividend, divisor);
    Assert.AreEqual(expectedQuotient, quotient);
}

Dzięki atrybutowi [TestCase] mamy 6 testów w jednej metodzie. W okienku rezultatów testu, wszystkie pojawiają się jako podrzędne do głównego testu. Wygląda to tak:

testcase

Bardzo ważna kwestia jaka tutaj się pojawiła to przypadek dzielenia 1 ÷ 3. Dzięki arytmetyce liczb zmiennoprzecinkowych uzyskamy wynik 0.333333343f. Wyjaśnienie skąd się wział taki wynik znajduje się w literaturze zamieszczonej w przypisach. Najważniejsza jest jednak świadomość tego faktu i uwzględnienie go w testach.

Testowanie wyrzucenia wyjątku

W przypadku dzielenia musimy obsłużyć przypadek dzielenia przez zero. Założyliśmy, że w takim przypadku wyrzucamy błąd typu System.DivideByZeroException. W NUnicie testowanie wyjątków możemy wykonać przez wywołanie [Assert.Throws]. Tutaj również podanie typu jest opcjonalne. Jako parametr przekazujemy delegat kodu, który chcemy wykonać.

[Test]
public void Divide_DivisionByZero_ThrowsException()
{
    var calc = new Calculator();
    Assert.Throws<DivideByZeroException>(() => calc.Divide(2, 0));
}

Wyrażenie lambda możemy zastąpić anonimową metodą:

Assert.Throws(delegate { calc.Divide(2, 0); });

Testowanie zdarzenia

Założyliśmy, że po wykonaniu obliczeń, wołamy zdarzenie CalculatedEvent. Sam NUnit nie wspiera natywnie testowania zdarzeń, jednak możemy zastosować prosty trik—po wywołaniu zdarzenia zmieniamy wartość flagi. Asercji dokonujemy na podstawie wartości tej flagi. Jeśli zdarzenie zostało wywołane, test przechodzi pozytywnie:

[Test]
public void Divide_OnCalculatedEventIsCalled()
{
    var calc = new Calculator();
 
    bool wasEventCalled = false;
    calc.CalculatedEvent += (sender, args) => wasEventCalled = true;
 
    calc.Divide(1, 2);
 
    Assert.IsTrue(wasEventCalled);
}

Wyrażenie lambda możemy zastąpić anonimową metodą:

calc.CalculatedEvent += delegate { wasEventCalled = true; };

Implementacja

Po napisaniu testów do naszego kodu, możemy przystąpić do napisania implementacji metody dzielenia. Zachęcam do napisania implementacji we własnym zakresie!

Ostateczna postać klasy wygląda tak:

class Calculator
{
    public float Divide(int dividend, int divisor)
    {
        if (divisor == 0) throw new DivideByZeroException();
 
        float result = (float)dividend / divisor;
        OnCalculated();
        return result;
    }
 
    public event EventHandler CalculatedEvent;
 
    protected virtual void OnCalculated()
    {
        var handler = CalculatedEvent;
        if (handler != null) handler(this, EventArgs.Empty);
    }
}

Wszystkie testy są zielone. W tak prostym przykładzie nie trzeba nic refaktoryzować! Fin!

Podsumowanie

W tej części kursu poznaliśmy:

  • Przydatność atrybutu [TestCase], który niewielkim kosztem generuje przypadek testowy.
  • Sposoby testowania wyjątków za pomocą NUnit.
  • Sposób testowania zdarzeń.

Ponadto dowiedliśmy że float, ze względu na arytmetykę liczb zmiennoprzecinkowych, nie jest odpowiednim typem jako typ zwracany przy dzieleniu. Lepszym okazałby się decimal. Wybrałem jednak float, aby pokazać naturę testów. Oczekujemy nie do końca prawidłowej (z punktu widzenia matematycznego) wartości (przypadek dzielenia 1 ÷ 3) i dzięki temu pojawienie się oczekiwanego wyniku nie powinno nas zaskoczyć. Dzięki TDD wykrylibyśmy taki błąd przy zmianie typu zwracanego: np. z decimal na float. Dobranie typu parametrów wejściowych jako int też jest celowe, choć w praktyce bardzo niebezpieczne. Na szczególną uwagę zasługuje linijka:

float result = (float)dividend / divisor;

Jaki wynik otrzymalibyśmy bez rzutowania zmiennej dividend na float? Zachęcam do eksperymentowania.

Przypisy

[1] Dlaczego 1 ÷ 3 = 0,333333343f? Czytaj więcej na ten temat:

Opublikowano 16 lipca 2013

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