testy jednostkowe

Testy jednostkowe

Jeśli czytałeś poprzedni artykuł, to nawet nie mając styczności z testami jednostkowymi, powinieneś rozumieć już czym są i do jakich celów się je wykorzystuje. Kolejnym naturalnym etapem jest więc nauka pisania samych testów. Czego Ci potrzeba? O tym w dzisiejszym poście. Zaczynamy.

Framework

To pierwszy punkt na liście. Bez niego nie ruszysz. Udostępnia narzędzia do tworzenia klas i metod z testami jednostkowymi. Jako programista .NET mogę wypowiedzieć się na temat narzędzi tylko na tę platformę. Do najpopularniejszych należą biblioteki NUnitxUnit oraz dostarczony przez Microsoft MSUnit. Wszystkie z nich dostępne są w wersji open source – możesz korzystać do woli i bez żadnych ograniczeń. Istnieją też inne rozwiązania takie jak: EMTF, MbUnit, czy Roadster, ale większość z nich nie jest już obecnie rozwijana.

Będąc w temacie frameworków warto zwrócić uwagę na fakt, że każdy cechuje nieco inne podejście do różnych kwestii związanych z testowaniem. Wszystkie szczegóły znajdziesz w dokumentacji biblioteki, na którą się zdecydujesz – odnośniki do nich umieściłem wyżej. Ja w dalszych przykładach będę korzystał z NUnita.

Gdzie tworzyć testy?

W poprzednim artykule wspominałem, że zazwyczaj umieszczane są one w osobnym projekcie. Inną możliwością jest tworzenie testów w projekcie zawierającym właściwy kod. ZDECYDOWANIE jednak polecam pierwsze podejście. Widzę w nim wiele korzyści:

Separacja właściwego kodu od testów

Bądźmy szczerzy. Jeśli wdrożenie się udało i projekt NIE będzie rozwijany dalej z testów nie ma ŻADNEJ korzyści. W takiej sytuacji można by je nawet usunąć, bo nie niosą ze sobą wartości biznesowej. Unit testy to narzędzie, które wykorzystuje się podczas tworzenia oprogramowania. Dlatego uważam, że dobrym pomysłem jest oddzielenie logiki od testów. Zapewnia to porządek w strukturach folderów, czy nazewnictwie plików.

Organizacja zależności(ang. dependencies) aplikacji

W przypadku rozdzielenia testów i logiki, separujemy też zależności, które dołączamy do projektów. Frameworki używane do testowania nie będą przecież potrzebne do uruchomienia właściwej aplikacji, tak samo jak paczki NuGet (o których poczytasz tutaj) odpowiedzialne za np. walidację w testach.

Warto dodać też, że w przypadku kilku projektów z logiką, dobrze byłoby tez stworzyć ich odpowiedniki do testów, aby jak najwierniej odwzorować strukturę folderów i zachować porządek. Ułatwia to później znalezienie konkretnych plików.

Nazewnictwo testów

Wiesz gdzie je utworzyć, mamy narzędzia do tego potrzebne. Czas więc zakasać rękawy, zabrać się do pracy i zacząć pisać testy. Pierwszym problemem jaki możesz napotkać jest nazewnictwo. W świecie testów jednostkowych przyjęło się kilka koncepcji nazywania klas i metod. Jedno jest pewne – te dwie nazwy, w połączeniu muszą jednoznacznie określać co i w jakich warunkach jest sprawdzane. Każdy czytający, widząc sygnaturę metody powinien wiedzieć czego dotyczy dany test. Poniżej opisałem przykładowe koncepcje:

1) Method_When_Then
Stosując przykład z poprzedniego wpisu, klasa testująca PaymentHandler, powinna zostać nazwana PaymentHandlerTests. Jeśli chodzi o metodę testującą, to w niej nazwa powinna zawierać trzy elementy:

  • method – metodę komponentu, który testujemy
  • when – warunki w jakich wykonywany jest test
  • then- oczekiwany rezultat testujemy

Przykład: ProcessPayment_WhenPaymentCannotBeProcessed_ThrowsException

2) Opis testowanej funkcjonalności w metodzie
przykład: ThrowsExceptionWhenPaymentCannotBeProcessed
Koncepcja, która pomija nazwę testowanej metody. W mojej opinii sprawdzą się w przypadku klas posiadających 1 publiczną metodę, lub gdy poszczególne metody testowane są w osobnych klasach. W innych sytuacjach może dezorientować czytającego.

3) Should_Result_When_Conditions
przykład: Should_ThrowException_WhenPaymentCannotBeProcessed

4) When_Conditions_Expect_Result
przykład: When_PaymentCannotBeProcessed_Expect_ThrowsException

Jak widzisz możliwości jest wiele. Przy doborze odpowiedniej warto zastanowić się nad użytecznością i czytelnością. Jeszcze innym pomysłem podzielił się Maciej Aniserowicz w swoim artykule o testach. Proponuje on każde słowo oddzielać podkreśleniami, co gwarantuje lepszą czytelność. Sam oceń, czy taka propozycja jest dla Ciebie.

Arrange-Act-Assert!

Wiesz już całkiem sporo o testach. Zanim jednak przejdziesz do tworzenia, musisz wiedzieć o tym, że powinno pisać się je zgodnie z wzorem AAA, którego rozwinięcie znajdziesz w tytule akapitu. Zakłada on, że każdy test jednostkowy powinien składać się z trzech części:

Arrange

Tutaj umieszczany jest cały kod odpowiada za przygotowanie kontekstu w jakim wykonywany jest test. Np. tworzy się parametry przekazywane do testowanej metody. Część sekcji arrange można przenieść także do metod wykonywanych przed testami. Przykładowo NUnit pozwala na wykorzystanie takiej, po użyciu atrybutu [SetUp] lub [OneTimeSetUp]. Tak przygotowane metody będą wywołane odpowiednio: przed każdym testem lub przed uruchomieniem wszystkich. Najczęstszą praktyką jest tworzenie tutaj elementów wspólnych dla wszystkich testów tak, żeby zredukować powtarzalność kodu.

Act

Zdecydowanie najkrótsza część testu, zawierająca jedynie wywołanie testowanej metody wraz z potrzebnymi parametrami.

Assert

Ostatnia część, niezbędna w każdym teście. Jej zadaniem jest weryfikacja rezultatów testu. Tutaj powinna być sprawdzona wartość zwrócona z testowanej metody, obiekty, które zostały utworzone lub metody wywołane wewnątrz. Jedynym przypadkiem, kiedy rezultat jest weryfikowany w innym miejscu, to sytuacja, w której kod wyrzuca wyjątek. Z racji specyfiki ich działania, aby nie przerwały procesu testowania, muszą być przechwycone już w części Act i odpowiednio obsłużone.

Przykład testów

Dobrze, przyszedł wreszcie czas, kiedy w końcu zabierzemy się za pisanie testów jednostkowych, które posłużą nam na przykład. Jako klasę do testowania wykorzystamy coś prostego, co łatwo weryfikować, a przypadki nie są skomplikowane. W miarę postępu zaawansowania serii, poziom trudności będzie się zwiększał, jednak na początek wystarczy nam klasa, będąca prostym, czterodziałaniowym kalkulatorem. Implementuje interfejs posiadający 4 metody, których sygnatury mówią same za siebie:

public interface ICalculator 
{ 
    int Add(int firstComponent, int secondComponent); 
    int Substract(int minuend, int subtrahend); 
    int Multiply(int multiplicand, int multiplier); 
    double Divide(int dividend, int divider); 
}

Sama klasa, też raczej nie wymaga komentarza:

public class Calculator : ICalculator
{
    public int Add(int firstComponent, int secondComponent)
    {
        return firstComponent + secondComponent;
    }

    public double Divide(int dividend, int divider)
    {
        if (divider == 0)
            throw new DivideByZeroException();

        return (double)dividend / (double)divider;
    }

    public int Multiply(int multiplicand, int multiplier)
    {
        return multiplicand * multiplier;
    }

    public int Substract(int minuend, int subtrahend)
    {
        return minuend - subtrahend;
    }
}

Teraz przejdźmy do meritum, czyli testów kalkulatora. Zaczniemy od omówienia przypadków, które warto sprawdzić:

Dodawanie:

  • obie liczby są dodatnie– wynik powinien być dodatni
  • liczby są równe – 0
  • obie liczby są ujemne – wynik ujemny

Podobne przypadki możemy wyróżnić dla odejmowania i mnożenia. Dodatkowy wystąpi przy dzieleniu. Drugi argument w tej metodzie- dzielnik nie może być zerem. Taki warunek również warto przetestować. Mamy omówione podstawowe scenariusze testów, stwórzmy więc klasę, która będzie je przechowywała:

 
[TestFixture] 
public class CalculatorTests 
{ 
    private ICalculator _calculator; 
    [SetUp] 
    public void SetUp() 
    { 
        _calculator = new Calculator(); 
    } 
} 

Jak widzisz klasa zawiera sprawdzany komponent oraz metodę odpowiedzialną za inicjalizację go. Atrybut [SetUp] powoduje tworzenie nowej instancji kalkulatora przed każdym kolejnym testem.

Okej! Mam pustą klasę, która jeszcze nic nie sprawdza. Napiszę więc pierwszy test, zacznę od dodawania:

[Test]
public void Add_WhenTwoPositiveNumbersProvided_ReturnPositiveResult()
{
    //arrange
    var first = 4;
    var second = 5;
    var expectedResult = first + second;

    //act
    var result = _calculator.Add(first, second);

    //assert
    Assert.AreEqual(expectedResult, result);
    Assert.True(result > 0);
}

W fazie Arrange stworzyłem dwie zmienne, które będą argumentami przekazywanymi do weryfikowanej metody. W tym przypadku nie potrzebuję niczego więcej.

Act, jak wspomniałem wyżej, to zawsze tylko wywołanie logiki. Zwracana wartość zapisuję w kolejnej zmiennej.

Część Assert jest odpowiedzialna za sprawdzenie rezultatów, co też robię – korzystając z metody znajdującej się w bibliotece NUnit.

Czy kod testów można poprawić?

Podobne testy możesz przygotować dla pozostałych przypadków dodawania, które opisałem wyżej. Zauważ jednak, że gdybyś stworzył kilka takich testów, jedyne co by je odróżniało to wartości dodawanych liczb, tworzonych w fazie Arrange. Żeby uniknąć powtarzalności kodu twórcy biblioteki dołączyli funkcjonalność tzw. test case-ów. Pozwala ona na definiowanie wartości w atrybutach, które zostaną przekazane do testu w formie parametrów metody. Dzięki temu jedna metoda może zweryfikować kilka przypadków. Tyle teorii, zobacz jak wygląda to w praktyce.

Tak wyglądają testy bez test case-ów:

        [Test]
        public void Add_WhenTwoPositiveNumbersProvided_ReturnPositiveResult()
        {
            //arrange
            var first = 4;
            var second = 5;
            var expectedResult = first + second;

            //act
            var result = _calculator.Add(first, second);

            //assert
            Assert.AreEqual(expectedResult, result);
            Assert.True(result > 0);
        }

        [Test]
        public void Add_WhenTwoNegativeNumbersProvided_ReturnNegativeResult()
        {
            //arrange
            var first = -4;
            var second = -5;
            var expectedResult = first + second;

            //act
            var result = _calculator.Add(first, second);

            //assert
            Assert.AreEqual(expectedResult, result);
            Assert.True(result < 0);
        }

        [Test]
        public void Add_WhenTwoZerosProvided_ReturnZeroResult()
        {
            //arrange
            var first = 0;
            var second = 0;
            var expectedResult = first + second;

            //act
            var result = _calculator.Add(first, second);

            //assert
            Assert.AreEqual(expectedResult, result);
            Assert.True(result == 0);
        }

a tak z:

        [TestCase(4, 5)]
        [TestCase(-4, -5)]
        [TestCase(0, 0)]
        public void Add_WhenTwoNumbersProvided_ReturnCorrectResult(int first, int second)
        {
            //arrange
            var expectedResult = first + second;

            //act
            var result = _calculator.Add(first, second);

            //assert
            Assert.AreEqual(expectedResult, result);
        }

Jak widać, tracimy tutaj możliwość porównywania wartości z zerem, bo każdy wynik będzie inny. Natomiast sam wynik jesteśmy w stanie obliczyć i to już wystarcza do weryfikacji testów.
Implementacja testów dla odejmowania i mnożenia będzie podobna. Nie chcę sztucznie przedłużać artykułu, dlatego przygotowałem je dla Ciebie w osobnym repozytorium na moim koncie Bitbucket. Możesz je przejrzeć pod tym linkiem.

Testowanie wyjątków

Ostatnią rzeczą jaką chciałbym CI pokazać jest testowanie wyjątków. Jeśli przyjrzałeś się kodowi kalkulatora, to wiesz, że w przypadku przekazania dzielnika = 0, powinien wyrzucić wyjątek: DivideByZeroException. To ważna funkcjonalność, dlatego warto ją przetestować:

        [Test]
        public void Divide_WhenDividerIsZero_ThenThrowsDivideByZeroException()
        {
            //arrange
            var dividend = 6;
            var divider = 0;

            //act and assert
            Assert.Throws<DivideByZeroException>(() => _calculator.Divide(dividend, divider));
        }

Jak widzisz faza Arrange jest analogiczna do wcześniejszych przykładów. Act i Assert są połączone ze sobą. Powód opisywałem wyżej – aby test wykonał się poprawnie wyjątek musi zostać obsłużony. W tym celu korzystamy z metody NUnit weryfikującej taką sytuację. Przyjmuje ona funkcję anonimową jako parametr. W nawiasach trójkątnych podaję typ wyjątku, który będzie wyrzucony (używam generycznej wersji metody z biblioteki NUnit). Dzięki takiej operacji test zweryfikuje, czy kod zachował się poprawnie, czyli w momencie podania 0 jako dzielnika wyrzuci wyjątek takiego typu. W przeciwnym wypadku test nie przejdzie.

Podsumowanie

Ufff to by było na tyle. Jeżeli jesteś ze mną nadal, to mam nadzieję, że nie żałujesz, a Twoja wiedza o świecie programowania się powiększyła. Omówiliśmy dzisiaj wiele kwestii: co pomoże Ci w pisaniu testów, gdzie je tworzyć i jak nazywać. Napisaliśmy też sporo przykładów. Przypominam, że całość znajduje się na moim Bitbucketcie. Gorąco zachęcam do pobrania kodu i własnych prób testowania. Jeżeli uznałeś to wszystko za przydatną wiedzę, to podziel się nią z innymi w mediach społecznościowych – przyciski znajdziesz poniżej. W komentarzu natomiast napisz, czy wszystko było dla Ciebie zrozumiałe lub czy napotkałeś jakieś problemy.

Please follow and like us:

Dodaj komentarz

This site uses Akismet to reduce spam. Learn how your comment data is processed.