Kurs TDD cz. 5: Nasz drugi test jednostkowy

W poprzedniej części kursu 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 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ć na dwa sposoby.

Sposób pierwszy to atrybut [ExpectedException]. Jako parametr atrybutu możemy wrzucić typ oczekiwanego wyjątku:

[Test]
[ExpectedException(typeof(DivideByZeroException))]
public void Divide_DivisionByZero_ThrowsException()
{
    var calc = new Calculator();
    calc.Divide(2, 0);
}

Drugim sposobem jest wywołanie Assert.Throws. Tutaj również podanie typu jest opcjonalne. Jako parametr przekazujemy delegat kodu, który chcemy wykonać.
EDIT: Jak słusznie zauważył Tomek (patrz komentarz do artykułu), jest to sposób zalecany bardziej niż ExpectedException, gdyż odwołujemy się do konkretnego kawałka kodu, który ma wyrzucić błąd. W przypadku atrybutu ExpectedException wyjątek może być rzucony w innym miejscu niż tego oczekujemy.
Kod dla metody Throws wygląda tak:

[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:

7 thoughts on “Kurs TDD cz. 5: Nasz drugi test jednostkowy

  1. Czekam na kolejne “odcinki”. Tak przy okazji wg mnie i nie tylko :) nie wolno (tzn. nie powinno się) używać atrybutu ExpectedException. W prezentowanym tutaj przykładzie nic wielkiego się nie stanie, jednakże w sytuacji, gdy mamy bardziej skomplikowany kod bądź łapiemy bardziej ogólny wyjątek (tzn samą klasę Exception) atrybut ten wskaże, że test się powiódł gdy Exception zostanie rzucony np. w konstruktorze klasy. Chcąc zobrazować o co mi chodzi, mogę przedstawić poniższy kod.

    [Test]
    [ExpectedException(typeof(DivideByZeroException))]
    public void Divide_DivisionByZero_ThrowsException()
    {
    var calc = new Calculator();
    calc.Divide(2, 0);
    }

    gdyby implementacja Calculator wyglądała tak, test by się powiódł

    public class Calculator
    {
    public Calculator()
    {
    throw new DiveByZeroException();
    }
    }

    Tak jak wspomniałem, w tym przypadku wygląda to “głupio”, ale przy bardziej rozbudowanych scenariuszach tak nie jest. Dlatego testowanie za pomocą lambdy gwarantuje, że wyjątek został rzucony w tej a nie innej lini.

  2. Pingback: Kurs TDD cz. 8: Testy parametryzowane | DARIUSZ WOZNIAK.NET
  3. Pingback: Kurs TDD – update źródeł | DariuszWoźniak .NET
  4. “W tak prostym przykładzie nie trzeba nic refaktoryzować! Fin!” – Nie trzeba już nic poprawić. Refaktoryzacja dotyczy kodu już działającego – jak wszystkie testy przeszły.

  5. Chciałbym zapytać o wyjaśnienie – przeprowadziłem testy przed implementacją i oczywiście się nie powiodły. Natomiast po dodaniu implementacji metody Divide() na początku – mimo, że wyglądało to tak, jakby zostały odpalone (używam kombinacji ctrl+u, ctrl+r) – testy dalej były czerwone. Dopiero gdy kliknąłem w dany test w kodzie i jeszcze raz odpaliłem test, to ten konkretny test (Divide_ReturnsProperValue()) się powiódł. Potem postawiłem kursor w teście Divide_DivisionByZero_ThrowsExeption() i po odpaleniu testów powiódł się i analogicznie w przypadku Divide_OnCalculatedEventIsCalled().
    Używam Resharpera i Visual Studio 2017 RC, nie mogę powtórzyć tej sytuacji by zrobić screeny czy dokładniej przejrzeć logi, ale sytuacja była na tyle interesująca, że postanowiłem zapytać, czy to normalne?

    Przed opublikowaniem komentarza przeanalizowałem jeszcze raz problem i wygląda na to, że skrót ctrl+u, ctrl+r w Resharperze domyślnie odpala tylko jeden konkretny test, a nie całą sesję. To w sumie dziwne, bo kliknięcie w menu kontekstowym (http://i.imgur.com/NpEVFrm.png) odpala całą sesję, a przecież tam podpowiadany jest dokładnie ten skrót…

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s