Jak zacząć testować aplikację .NET z wykorzystaniem NUnit?
Dokładne testowanie aplikacji jest ważne. To nie ulega wątpliwości. Gdy tworzymy program i go wydajemy lub oddajemy dla klienta, to aplikacja powinna działać jak najlepiej. A jak sprawdzić, czy tak jest, jeśli nie jest testowana? Podejścia do testowania są różne. Niektóre firmy zatrudniają testerów manualnych, którzy przeklikują się przez aplikację. Inni piszą test automatyczne, w różnych formach. Jeszcze inni sprawdzają aplikację ręcznie tylko podczas pisania danej funkcjonalności. Które podejście jest właściwe? Tak naprawdę – wszystkie. Po prostu zależy to od danej sytuacji. Tworząc poważny system, powinniśmy go dokładnie testować. Jednak pisząc jedynie małą aplikację pomocniczą, której użyjemy tylko kilka razy i potem wyrzucimy, być może będzie to strata czasu.
Dlaczego powinniśmy testować?
Odpowiedź jest prosta – żeby zapewnić wysoką jakość aplikacji. Ale testować można też ręcznie. A dlaczego powinniśmy pisać testy automatyczne (na których się tutaj skupiamy)? Korzyści jest kilka.
Dobrze napisane testy powinny przyśpieszyć prace nad aplikacją. Pozwalają one szybciej (niż manualne testy) potwierdzić, że nowa funkcjonalność działa, a także że zmiany w istniejącym kodzie nie popsuły działających funkcjonalności.
Testy zmuszają nas także do pisania kodu w bardziej przejrzysty sposób. Aplikacja musi być stworzona bardziej modułowo, gdzie każda część zajmuje się jedną rzeczą – tak, żebyśmy dali radę to przetestować. Wielu programistów, którzy nie mieli do czynienia z testami, lubi wrzucać wiele różnych, niezależnych od siebie funkcji do jednej klasy czy metody. Przez to kod jest później ciężki do czytania, debugowania i trudno wprowadzać w nim zmiany. Testy automatyczne pozwalają nam więc na stworzenie kodu lepszej jakości. Kodu, z którym się wygodniej pracuje i jest mniej podatny na błędy.
Wprowadzenie nowego programisty do projektu również będzie łatwiejsze, gdy mamy do dyspozycji testy. Będzie mógł on dowiedzieć się z nich, jak powinny działać dane fragmenty kodu, bez zajmowania czasu innym osobom.
Jakie mamy rodzaje testów automatycznych?
Testy automatyczne możemy podzielić na kilka kategorii ze względu na to, jaką część systemu testujemy:
- Testy jednostkowe – jest to rodzaj testów, w których sprawdzamy działanie tylko pojedynczego elementu aplikacji, który nie posiada żadnych zależności, np. metoda w klasie, która nie wykorzystuje niczego z zewnątrz.
- Testy integracyjne – jest to poziom wyżej niż testy jednostkowe. Sprawdzamy tutaj elementy systemu, które od siebie zależą i czy powiązania między nimi działają prawidłowo.
- Testy UI – są to testy interfejsu użytkownika. Jest to zupełnie oddzielna kategoria, ponieważ na warstwie widoku ciężko jest tworzyć testy w taki sam sposób jak w kategoriach powyżej. Tutaj zazwyczaj do dyspozycji mamy pewien framework, który pozwala nam na interakcję z włączoną aplikacją i podczas testu wykonuje zakodowane wcześniej akcje, podobnie jakby robił to zwykły użytkownik, a więc np. klika na przycisk, wpisuje tekst, itp.
Te 3 kategorie na początek w zupełności wystarczą, chociaż dałoby się wydzielić ich jeszcze kilka. Tutaj będziemy się zajmować testami jednostkowymi.
Jakie mamy podejścia do pisania testów?
Testy można pisać właściwie na 2 sposoby:
- Przed pisaniem właściwego kodu. Najpierw piszemy test, który testuje pewien element systemu. Ten element jeszcze nie istnieje, jego kod nie został napisany, więc test nawet się nie skompiluje. Taki sposób pisania na początku może być ciężki, ponieważ musimy zupełnie zmienić sposób myślenia o tym, w jaki sposób tworzymy aplikacje. Powinniśmy pomyśleć o tym jaka metoda jest nam potrzebna, co będzie robić, jakie argumenty przyjmować, co zwracać, jak się nazywać i w jakiej klasie będzie się znajdować. Często jest tak, że takie rzeczy wychodzą podczas tworzenia i okazuje się, że metoda robi 2 różne rzeczy. Nie będziemy przyglądać się temu bliżej w tym poście – zaczniemy od drugiego sposobu, czyli od podstaw.
- Po napisaniu właściwego kodu. Tutaj mamy już stworzoną metodę, którą chcemy przetestować. Wystarczy zastanowić się jakie testy chcemy do niej dopisać, żeby sprawdzić jej poprawne działanie. To podejście ma ten plus, że możemy dopisywać takie testy do istniejącego systemu (o czym się raczej rzadko słyszy). Problem tylko w tym, że jeśli system nie był pisany z myślą o testowaniu, to dodanie do niego testów bez refaktoringu może być ciężkie.
Łatwiej jest zacząć od sposobu drugiego i od niego też rozpoczniemy.
Popularne frameworki do testowania w .NET
Do pisania testów przyda się jakiś framework. W świecie .Neta mamy 3 popularne opcje:
- NUnit
- xUnit
- MSTest
MSTestu nigdy za bardzo nie używałem, więc na jego temat się nie wypowiem. Zazwyczaj zwracam się ku NUnit, ale próbowałem także pracować z xUnit. Ogólnie rzecz biorąc, są one bardzo podobne na podstawowym poziomie, stosują jedynie inne nazewnictwo i lekko inną składnię. Mi bardzo odpowiadają te rzeczy w NUnit, dlatego też jego używam i o nim dzisiaj będę pisał.
Tworzenie projektu z testami
Pora przejść do praktyki. Co musimy zrobić, żeby zacząć korzystać z NUnit:
- Stwórzmy sobie 2 projekty typu Class Library. Pierwszy to będzie biblioteka z metoda dla naszej aplikacji, a drugi to projekt z testami. Nazwijmy je np. MyCoreLibrary i MyCoreLibrary.Tests.
- Do MyCoreLibrary.Tests doinstalujmy paczkę NuGet o nazwie NUnit.
- Możemy zaczynać!
Pierwszy test
W projekcie MyCoreLibrary stwórzmy sobie statyczną klasę MathUtils, a w niej metodę do obliczania liczby z ciągu Fibonacciego:
public static int CalculateFibonacciNumber(int numberIndex) { if (numberIndex == 1 || numberIndex == 2) { return 1; } return CalculateFibonacciNumber(numberIndex - 1) + CalculateFibonacciNumber(numberIndex - 2); }
Teraz napiszmy pierwszy test dla tej metody. W projekcie MyCoreLibrary.Tests wykonajmy następujące rzeczy:
- Dodajmy klasę MathUtilsTests.
- Oznaczmy ją atrybutem TestFixture (z namespace NUnit.Framework).
- Dodajmy w klasie metodę o dowolnej nazwie. Ja nazwałem ją CalculateFibonacciNumberShouldReturnCorrectValue. Lubię, gdy metoda testowa opisuje to, co robi.
- Oznaczmy wyżej stworzoną metodę atrybutem Test (z namespace NUnit.Framework).
- Dodajmy referencję do projektu MyCoreLibrary. Będziemy w końcu testować metody tam zdefiniowane, musimy mieć więc do nich dostęp.
Teraz przejdźmy do ciała metody testowej. Jest pewna ogólna zasada odnośnie struktury testów, nazywana jest 3A: Arrange, Act, Assert. Chodzi tu o to, żeby najpierw przygotować wszystko do testu (arrange), potem wywołać testowaną metodę (act), a na koniec sprawdzić, czy stan (np. zwracana wartość) jest taki, jak oczekiwaliśmy (assert). U nas wyglądałoby to w następujący sposób:
public void CalculateFibonacciNumberShouldReturnCorrectValue() { // Arrage var numberIndex = 6; var expected = 8; // Act var actual = MathUtils.CalculateFibonacciNumber(numberIndex); // Assert Assert.That(actual, Is.EqualTo(expected)); }
W Arrange dużo się nie dzieje – inicjalizujemy tylko parametr i oczekiwany wynik. W Act wywołujemy funkcję z parametrem i zapisujemy wynik. A w Assert sprawdzamy jego poprawność. Używamy do tego metod z NUnit, które mają bardzo przyjemną składnię. Zaczynamy od Assert.That (bardzo często będziemy zaczynać właśnie od tego), następnie jako pierwszy parametr, podajemy wynik z testowanej metody, a jako drugi rodzaj ograniczenia (Is.EqualTo) z oczekiwaną wartością.
Odpalamy test – NUnit Runner
Mamy napisany test, ale co mamy z nim teraz zrobić? Żeby go uruchomić potrzebujemy runnera. Do NUnita mamy już gotowych kilka runnerów, np. gui runner, czy console runner. Są to oddzielne aplikacje, które skanują naszą dllkę z testami, znajdują wszystkie testy, odpalają je i pokazują wyniki. Jednak najwygodniej jest użyć runnera zintegrowanego z Visual Studio. Wtedy będziemy mogli odpalać testy prosto z Visuala, bez zewnętrznych aplikacji.
Runner ten jest dostępny jako rozszerzenie tutaj. Możemy też zainstalować go prosto z Visual Studio wybierając Tools -> Extensions and Updates -> zakładka Online -> wyszukać i zainstalować NUnit 3 Test Adapter.
Po zainstalowaniu zbudujmy projekt z testami i z górnego menu wybierzmy Test -> Windows -> Test Explorer. W nowo otworzonym oknie powinniśmy zobaczyć nasz test na liście (nazwa metody). Możemy kliknąć na niego prawym przyciskiem myszy i wybrać Run Selected Tests. Test jest prawidłowy, więc powinien dostać zielone kółeczko po lewej stronie.
Parametry
Załóżmy teraz, że chcielibyśmy przetestować naszą metodę przekazując inne wartości jako argument. Powinniśmy zmienić go ręcznie? A może napisać drugi test? Oczywiście nie 🙂 W NUnit możemy użyć atrybutu TestCase, żeby przekazać do testu różne parametry.
[TestCase(0, 0)] [TestCase(1, 1)] [TestCase(2, 1)] [TestCase(3, 2)] [TestCase(6, 8)] [TestCase(19, 4181)] public void CalculateFibonacciNumberShouldReturnCorrectValue(int numberIndex, int expected) { // Arrage // Teraz mamy to w parametrach // Act var actual = MathUtils.CalculateFibonacciNumber(numberIndex); // Assert Assert.That(actual, Is.EqualTo(expected)); }
W TestCase mamy 2 wartości. Są one przekazywane do parametrów przy wywołaniu metody testowej w takiej kolejności, jak je podaliśmy.
Jeśli teraz zbudujemy projekt, to w Test Explorerze zobaczymy 6 przypadków na liście. Gdy je uruchomimy (najlepiej z debugowaniem), to zobaczymy, że w przypadku z 0 jako numberIndex dostajemy StackOverflowException. Super, znaleźliśmy błąd w metodzie, testy do czegoś się przydały!
Gdy zajrzymy do kodu metody to zauważymy, że przypadku z 0 nie obsługujemy. Naprawmy to. Obecnie dla indeksów 1 i 2 zwracamy 1. Dodatkowo dla zera powinniśmy zwracać zero. Warto również zauważyć, że tak naprawdę oddzielny przypadek dla indeksu 2 nie jest potrzebny. Przy tym indeksie suma dwóch poprzednich liczb ciągu da nam 1. Możemy więc ten przypadek usunąć, dla uproszczenia metody.
public static int CalculateFibonacciNumber(int numberIndex) { if (numberIndex == 0 || numberIndex == 1) { return numberIndex; } return CalculateFibonacciNumber(numberIndex - 1) + CalculateFibonacciNumber(numberIndex - 2); }
Przewidzieć nieprzewidziane
Przy testach jednostkowych powinniśmy zawsze badać różne scenariusze. Musimy się zastanowić, jak dana metoda może być wykorzystana, jak może się zachowywać dla różnych wartości wejściowych. Powinniśmy przede wszystkim zawsze testować wartości brzegowe. W powyższym teście sprawdzamy, co się stanie dla parametru 0. Jest to pierwszy element ciągu Fibonacciego. Ale co się stanie, jeśli przekażemy tam liczbę ujemną? W końcu możemy to zrobić, nikt nam tego nie zabrania. Odpalmy taki test z debugowaniem:
[Test] public void CalculateFibonacciNumberWithNegativeNumber() { // Arrage var numberIndex = -1; // Act var actual = MathUtils.CalculateFibonacciNumber(numberIndex); // Assert // Na razie nic... }
Zobaczymy, że dostajemy StackOverflowException. To było do przewidzenia, ale teraz wiemy to na pewno. Możemy to naprawić na 2 sposoby. Albo zamienić akceptowane argumenty na typ uint. Albo dla ujemnych argumentów rzucać własny wyjątek typu ArgumentException. Spróbujemy z tym drugim rozwiązaniem, ponieważ uczymy się testowania i da się tutaj napisać nowy test. Zaktualizujemy więc metodę o rzucanie wyjątku:
public static int CalculateFibonacciNumber(int numberIndex) { if(numberIndex < 0) { throw new ArgumentException("Index cannot be negative."); } if (numberIndex == 0 || numberIndex == 1) { return numberIndex; } return CalculateFibonacciNumber(numberIndex - 1) + CalculateFibonacciNumber(numberIndex - 2); }
Dopiszemy też nowy test. Nazwa, tak jak w poprzednim jest długa, ale opisowa.
[Test] public void CalculateFibonacciNumberShouldTrowArgumentExceptionForNegativeArgument() { // Arrage var numberIndex = -1; // Act TestDelegate actual = () => MathUtils.CalculateFibonacciNumber(numberIndex); // Assert Assert.That(actual, Throws.ArgumentException); }
Używamy tutaj wyrażenia lambda przy wywołaniu metody i zapisujemy je do zmiennej typu TestDelegate. Następnie przy bloku Assert sprawdzamy, czy metoda rzuca wyjątek. Taka składnia z wyrażeniem lambda jest tutaj konieczna. Gdybyśmy po prostu wywołali metodę, to od razu dostalibyśmy wyjątek. Zamiast tego przekazujemy lambdę do Assert.That, które wywołuje ją wewnętrznie i nasłuchuje na wyjątek, który nas interesuje.
Dodatkowo, moglibyśmy sprawdzać np. Message z wyjątku, jeśli zachodziłaby taka konieczność. Jednak w tym przypadku nie ma takiej potrzeby.
Porównywanie list
Kolejną rzeczą, na którą warto spojrzeć to porównywanie list. Są to podstawowe elementy, które na pewno często będziemy wykorzystywać. Stwórzmy sobie nową metodę, która będzie nam zwracać listę. Co taka metoda może robić? Uznałem, że do przykładu dobrze nada się metoda zwracająca ciąg Fibonacciego od początku do podanego przez nas indeksu.
public static List<int> GetFibonacciSequence(int maxNumberIndexInclusive) { List<int> result = new List<int>(); for(int i = 0; i <= maxNumberIndexInclusive; ++i) { result.Add(CalculateFibonacciNumber(i)); } return result; }
Napiszmy teraz test sprawdzający, czy metoda zwraca poprawny wynik dla indeksu 3:
[Test] public void GetFibonacciSequenceShouldReturnCorrectSequence() { // Arrage var expected = new List<int> { 0, 1, 1, 2 }; // Act var actual = MathUtils.GetFibonacciSequence(3); // Assert CollectionAssert.AreEqual(expected, actual); }
Nowością jest tutaj rodzaj assercji. Używamy metody AreEqual z klasy CollectionAssert. W ten sposób sprawdzimy, czy listy zawierają te same obiekty, w tej samej kolejności. Dla typu prostego jak int działa to dobrze. Jednak przy typach referencyjnych zostaną porównane referencje i zazwyczaj będzie to zachowanie niepożądane. Aby to poprawić, musielibyśmy napisać własny comparer.
Warto również wiedzieć, że mamy dostępną metodę AreEquivalent, która sprawdza, czy w listach znajdują się te same obiekty, ale nie zwraca uwagi na ich kolejność.
Tak jak poprzednio, użyjmy atrybutu TestCase, żeby przetestować różne dane wejściowe. Ale, ale! Jak napiszemy to:
[TestCase(3, new List<int> { 0, 1, 1, 2 })]
To dostaniemy błąd:
An attribute argument must be a constant expression, typeof expression or array creation expression of an attribute parameter type
W tym przypadku moglibyśmy zamienić listę na tablicę, ale ze względów demonstracyjnych zostańmy przy liście. Użyjemy tutaj nowej składki.
TestCaseSource
Za pomocą atrybutu TestCaseSource możemy przekazać statyczne pole, właściwość, czy metodę, która dostarczy dane do naszego testu. Spójrzmy:
[TestCaseSource(nameof(_getFibonacciSequenceShouldReturnCorrectSequenceData))] public void GetFibonacciSequenceShouldReturnCorrectSequence(int indexNumber, List<int> expected) { // Arrage // Act var actual = MathUtils.GetFibonacciSequence(indexNumber); // Assert CollectionAssert.AreEqual(expected, actual); } static object[] _getFibonacciSequenceShouldReturnCorrectSequenceData = { new object[] { 0, new List<int> { 0 } }, new object[] { 1, new List<int> { 0, 1 } }, new object[] { 2, new List<int> { 0, 1, 1 } }, new object[] { 3, new List<int> { 0, 1, 1, 2 } } };
Do atrybutu przekazujemy nazwę pola z danymi (jako string). Samo pole to tablica obiektów. Każdy wpis w tej tablicy to kolejna tablica obiektów, gdzie podajemy jeden zestaw argumentów. Tutaj mamy więc 4 zestawy argumentów. Po zbudowaniu powinniśmy zobaczyć w oknie TestExplorer 4 testy o tej samej nazwie z różnymi parametrami.
Zauważyliście pewnie, że nazwa wyświetla się w taki sposób GetFibonacciSequenceShouldReturnCorrectSequence(0,System.Collections.Generic.List’1[System.Int32]). Te System.Collections.Generic.List’1[System.Int32] nie wygląda najlepiej. Dałoby się to naprawić, gdybyśmy mogli nadpisać metodę ToString() w klasie List<>, ponieważ to co zwraca ta metoda, jest wyświetlane w TestExplorerze.
Podsumowanie
Na tym etapie się zatrzymamy. Myślę, że tyle informacji wystarczy do rozpoczęcia testowania własnych aplikacji za pomocą NUnit.
Dowiedzieliśmy się, dlaczego powinniśmy testować aplikacje i poznaliśmy podstawowe rodzaje testów automatycznych. Zapoznaliśmy się bliżej z testowaniem już istniejącego kodu z wykorzystaniem NUnit i runnera zintegrowanego z Visual Studio. Znamy już także podstawowe funkcjonalności NUnit, które w zupełności wystarczą do wielu przypadków testowych. Warto również zapamiętać, że łatwiej jest utrzymywać małe testy i nie należy mieszać kilku różnych funkcjonalności, czy ścieżek w jednym teście.
Kolejnym dobrym krokiem w zgłębianiu testów byłoby zapewne zapoznanie się z mockowaniem, tak żeby w bardziej złożonych systemach testować tylko jedną rzecz na raz.
Fragmenty kodu podane we wpisie są dostępne jako pełny projekt na githubie: https://github.com/Ermlab/how-to-start-testing-with-nunit
‘Jednak pisząc jedynie małą aplikację pomocniczą, której użyjemy tylko kilka razy i potem wyrzucimy, być może będzie to strata czasu.’
A później okazuje się, że ta mała aplikacja pomocnicza zostaje na stałe i jest używana przez kolejnych 15 lat 😉
Haha, racja 😉 Warto się zorientować, czy jest duże prawdopodobieństwo, że tak się stanie. Nie zawsze to możliwe, ale czasami da się to określić w przybliżeniu.