Lepiej zapobiegać, czyli podstawy testów jednostkowych

Lepiej zapobiegać, czyli podstawy testów jednostkowych

Z tego tekstu dowiesz się jak i po co stosować testy jednostkowe. Tu są same podstawy. Bardziej zaawansowany tekst jest dostępny tutaj.

Co to takiego?

Testowanie jednostkowe (ang. unit testing) polega na automatycznym testowaniu Twojego kodu. Automatyczne – czyli robi to za Ciebie framework. Jest kilka popularnych frameworków testujących dla .NETu. Wszystkie one współpracują z VisualStudio.

Na czym to polega?

Polega to na sprawdzeniu Twojej metody mniej więcej na takie sposoby:

  • czy kod się nie wywala lub wywala się wtedy, gdy powinien
  • czy kod zachowa się dobrze z różnymi parametrami (parametry brzegowe i „zwyczajne”)
  • czy kod zwraca poprawne dane
  • jak kod zachowa się w specyficznych warunkach

Test jednostkowy polega z grubsza na napisaniu specjalnego kodu, który testuje inny kod. O tym za chwilę.

Testowanie jednostkowe pomaga

Testy jednostkowe pomagają… bardzo pomagają uniknąć błędów w kodzie produkcyjnym. Generalnie bardzo potrafią ograniczyć występowanie błędów. Testy jednostkowe są najbardziej użyteczne w momencie, gdy refaktorujemy kod lub robimy zmiany w już działającym.

Zapewne nie raz przekonałeś się, że poprawki zrobione w jednej części kodu tworzą błędy zupełnie gdzieś indziej, prawda? Jest to swego rodzaju zmora. Ale cóż… jako ludzie nie jesteśmy w stanie wszystkiego przewidzieć. I tutaj bardzo pomocne okazują się być testy jednostkowe.

Testy dokumentują kod

Testy jednostkowe „dokumentują” też w pewien sposób kod. To nie znaczy, że nie powinno prowadzić się prawdziwej dokumentacji 😉 W jaki sposób test dokumentuje? Po prostu widzisz fragment poprawnie napisanego kodu – np. użycie jakiejś klasy – w odpowiedni sposób – w taki jaki została zaprojektowana. Dzięki temu wiesz, jak taką konkretną klasę utworzyć i jak jej używać. Przeglądając zatem kody testów możesz się sporo dowiedzieć o całym systemie.

Jak testować?

Pokażę Ci to na przykładzie frameworka nUnit. Dlaczego ten? Po prostu z własnego przyzwyczajenia. Są inne, MSTest, xUnit (te dwa są na dzień dobry w VisualStudio) i sporo innych. Generalnie zasada jest ta sama. Różnice są jeśli chodzi o kod testujący, inicjujący itp.

Przygotowanie

Zainstaluj w Visual Studio następujące rozszerzenia: NUnit 3 Test Adapter i NUnit VS Templates. NUnit TestAdapter to jest rozszerzenie, które współpracuje z TestExplorerem w Visual Studio (takie specjalne okno z widocznymi testami i ich wynikami). NUnit VS Templates natomiast to szablony, dzięki którym możesz tworzyć od razu nowe projekty testujące.

Potrzebujemy teraz jakiegoś programu do testowania. Więc zróbmy coś małego i prostego. Utwórz sobie projekt Class Library (.NET Standard) – dlatego taki, ponieważ chcę uniknąć na tym poziomie aplikacji okienkowych lub konsolowych. Skupimy się tylko na testach i samej logice.

Stwórz prostą klasę Calculator:

public class Calculator
{
  public int Add(int x, int y)
  {
    return x + y;
  }
  
  public int Mul(int x, int y)
  {
    return x * y;
  }
}

Sytuacja wygląda tak – mamy publiczną klasę i dwie publiczne metody. To są idealni kandydaci, żeby zastosować na nich testy jednostkowe.

Żeby mieć porządek w solucji, utwórz teraz w niej katalog Tests. W tym katalogu utwórz nowy projekt: nUnit Test Project (.NET Core) -> nie znajdziesz go, jeśli nie zainstalowałeś NUnit VS Templates.

Interesuje nas ten konkretnie projekt, ponieważ będziemy testować bibliotekę .NETStandard. Gdybyśmy testowali aplikację na Androida, wybralibyśmy NUnit 3 Test Project (Android). Analogicznie z innymi szablonami, które widzisz w oknie dodawania nowego projektu.

Możesz nazwać swój projekt, np: NUnit.Nazwa-Projektu-Ktory-Testujesz. Oczywiście możesz nazwać go dowolnie, ale takie nazewnictwo wydaje się być rozsądne, bo:

  • pokazuje Ci jakim frameworkiem testujesz
  • pokazuje Ci jaki projekt testujesz

Osobiście bardziej używam standardowego nazewnictwa w stylu: NazwaFirmy.NazwaAplikacji.ProjektTests

(gdzie Projekt, to nazwa projektu, który testuję)

Pierwszy test

OK, zróbmy zatem pierwsze testy jednostkowe. Ja zazwyczaj robię tak (i Tobie też polecam), że jedną klasę testuję w jednym pliku. Zatem w projekcie testowym utwórz sobie plik C# o nazwie CalculatorTests.cs

Od razu do pliku dodaj dyrektywę using NUnit.Framework
I teraz tak – całe testowanie w nUnit opiera się głównie na atrybutach. Żeby Twoja klasa była uznana za klasę z testami, musisz ją oznaczyć atrybutem TestFixture:

[TestFixture]
public class CalculatorTests
{

}

Teraz będziemy w niej dodawać metody testujące. Każdą swoją metodę powinieneś przetestować na różne sposoby (jeśli to jest sensowne). A więc do każdej metody napiszesz pewnie kilka metod testujących. I tutaj dwie uwagi.

  1. Metoda testująca powinna testować tylko jedną, konkretną rzecz – tak samo jak metoda w klasie powinna robić tylko jedną, konkretną rzecz
  2. W programowaniu obowiązuje żelazna reguła DRY (Don’t Repeat Yourself). Czyli nie piszemy analogicznego kodu kilka razy. Jeśli chodzi o testowanie, to zasada DRY nie jest już taka żelazna. Pamiętaj, że im więcej logiki masz w testach, tym więcej błędów możesz w tych testach popełnić (w samym kodzie testującym). Zatem testy staramy się pisać jak najprościej, nawet jeśli to wymaga duplikowania kodu. Rzecz jasna, możesz tworzyć jakieś metody pomocnicze i nikt Ci tego nie zabroni. Sam też tak robię. Ale podejdź do tego ostrożnie i zdroworozsądkowo.

Metoda testująca

OK, napiszmy więc metodę, która sprawdzi, czy Add w ogóle działa i się nie wywala. Jeśli chodzi o nazewnictwo metod testujących, jest tutaj też pewna reguła. Być może nie jest to żelazna zasada, ale na pewno sensowna i wspomagająca. Więc staraj się jej trzymać.

Nazwa metody testującej powinna składać się z 3 części:

  • nazwa metody, którą testujemy
  • warunki, które testujemy
  • spodziewany efekt

Tak jak mówiłem, to nie jest jakaś żelazna zasada, ale pomaga. Więc staraj się stosować to lub podobne wzory. Zatem napiszmy teraz metodę testującą:

public void Add_ValidArguments_DoesNotThrow()
{

}

Co to oznacza:

  • testujemy metodę Add
  • testujemy ją w normalnych warunkach – a więc przy użyciu poprawnych argumentów (ValidArguments)
  • spodziewamy się, że metoda nie rzuci żadnym wyjątkiem (DoesNotThrow). Możemy też spodziewać się, że metoda zwróci poprawny wynik, czyli trzeci człon moglibyśmy nazwać np. „ReturnsValidValue„. Napiszmy teraz ciało tej metody:
public void Add_ValidArguments_DoesNotThrow()
{
	Calculator calc = new Calculator();
	int result = calc.Add(2, 2);
}

W tym momencie tworzymy obiekt Calculator i wywołujemy metodę Add. Czyli dokładnie to, co zrobimy gdzieś w programie, wykorzystując obiekt Calculator.

Teraz upewnijmy się, że otrzymany wynik jest taki, jakiego się spodziewamy:

public void Add_ValidArguments_DoesNotThrow()
{
	Calculator calc = new Calculator();
	int result = calc.Add(2, 2);
	Assert.AreEqual(4, result);
}

Sprawdzenie poprawności testu

Jak widzisz jest tutaj metoda AreEqual z klasy Assert. Klasa Assert pochodzi z frameworka nUnit. Inne frameworki testujące też mogą mieć klasę o nazwie Assert z innymi metodami albo zupełnie inne sposoby na sprawdzenie wyniku. Jak widzisz, klasa Assert ma sporo możliwości różnych sprawdzeń. Większość z nich jest „samoopisująca się”, ale opis ich wszystkich znajdziesz w dokumentacji nUnit.

Metoda AreEqual sprawdza, czy zmienna result ma spodziewaną wartość. W tym przypadku 4. Jeśli nie miałaby takiej wartości, test zakończyłby się niepowodzeniem.

To teraz zróbmy drugie sprawdzenie, upewnijmy się, że metoda Add nie rzuca wyjątku:

public void Add_ValidArguments_DoesNotThrow()
{
	Calculator calc = new Calculator();
	int result = 0;
	Assert.DoesNotThrow(() => result = calc.Add(2, 2));
	Assert.AreEqual(4, result);
}

Teraz, jeśli metoda Add rzuci wyjątek lub rezultat będzie inny niż spodziewany, test zakończy się niepowodzeniem.

Jest jeszcze jedna rzecz, którą trzeba zrobić, żeby test rozpocząć. Jak pisałem wcześniej – możesz mieć w klasie testującej różne metody pomocnicze. A więc nie każda metoda musi być metodą testującą. Aby oznaczyć metodę jako testującą posłuż się atrybutem Test:

[Test]
public void Add_ValidArguments_DoesNotThrow()
{
	Calculator calc = new Calculator();
	int result = 0;
	Assert.DoesNotThrow(() => result = calc.Add(2, 2));
	Assert.AreEqual(4, result);
}

Uruchomienie testu

Uruchomienie okna Test Explorer

Super! Teraz otwórz sobie okienko TestExplorer (menu View -> Test Explorer). To jest okienko, w którym zobaczysz wyniki testów. To okno jest z VisualStudio, a rozszerzenie nUnit 3 Test Adapter, które zainstalowałeś wcześniej, pozwala na połączenie nUnit z mechanizmem testowania dostępnym w Visual Studio.

Ok, teraz zbuduj swoją solucję. Jeśli się nie buduje, prawdopodobnie otrzymałeś błąd, że .NetFramework 4.6 nie jest kompatybilny z .NetStandard. Po prostu kliknij prawym klawiszem myszy na projekt testujący i wybierz Properties. Tam przejdź na zakładkę Application i z comboboxa TargetFramework ustaw właściwość .Net Framework 4.7.2. Teraz powinieneś już móc zbudować wszystko.

Okienko TestExplorer
Okienko TestExplorer

Zobacz, jak wygląda okienko Test Explorer. Powinieneś tu zobaczyć wszystkie testy, jakie masz w solucji. Możesz uruchomić poszczególne testy, możesz uruchomić całą grupę. Jeśli klikniesz prawym klawiszem myszy na jakiś element (grupę testów albo poszczególny test) zobaczysz elementy Run i Debug. To Cię w tym momencie interesuje najbardziej. Polecenie RUN po prostu uruchomi testy. Polecenie DEBUG uruchomi testy w trybie debugowania. Tzn. że dopiero przy DEBUG będą istotne breakpointy, które postawisz w kodzie testującym.

Uruchom teraz test i zobacz wynik. Test możesz uruchomić na trzy sposoby:

  • Klikając zielony PLAY na górze okienka TestExplorer -> w ten sposób możesz uruchomić od razu WSZYSTKIE testy
  • Klikając prawym klawiszem myszy na konkretnym teście (w okienku TestExplorer) i wybierając opcję RUN.
  • Klikając prawym klawiszem myszy na konkretnym teście (w okienku TestExplorer) i wybierając opcję DEBUG – w ten sposób będziesz mógł debugować swoje testy. Tylko przy tej opcji breakpointy w kodzie testowym będą aktywne.

Super! Wszystko się udało. To teraz w ramach ćwiczeń zrób coś, żeby test nie powiódł się i zobacz, jak to wygląda.

Testy jednostkowe dla wielu przypadków

Ok, wróćmy do poprawnych kodów i zróbmy jakiś lepszy test. W tym momencie testujemy tylko jeden przypadek – 2 + 2. Lepszy test przetestuje kilka przypadków. nUnit tutaj ułatwia sprawę. Wystarczy, że atrybut Test zamienisz na TestCase i zmienisz lekko metodę:

[TestCase(2, 2, 4)]
[TestCase(3, 3, 6)]
[TestCase(0, 2, 2)]
[TestCase(100, 150, 250)]
public void Add_ValidArguments_DoesNotThrow(int x, int y, int expected)
{
	Calculator calc = new Calculator();
	int result = 0;
	Assert.DoesNotThrow(() => result = calc.Add(x, y));
	Assert.AreEqual(expected, result);
}

Atrybut TestCase po prostu przyjmuje pewne zmienne. Zmienne te są później przekazywane do metody, dlatego musisz mieć tutaj tyle parametrów, ile masz w atrybucie. Myślę, że całość sama się tłumaczy.
W taki sposób pisząc tylko jeden test, stworzyłeś 4 różne przypadki testowe (co zobaczysz w okienku Test Explorer).

Wiele przypadków w okienku TestExplorer

Wypadałoby też dodać testy, które sprawdzą jak metoda zachowuje się z ujemnymi argumentami i co ważniejsze – z argumentami BRZEGOWYMI: int.MinValue, int.MaxValue. Tutaj wszystko zależy od konkretnego programu. No bo co się stanie, jeśli do maksymalnej wartości dodasz 1? Licznik się przekręci i otrzymasz minimalną wartość… Lub program się wywali… W zależności, co chcesz osiągnąć. To tylko przykład.

Co testować?

Mógłbyś teraz się pokusić o to, żeby testować każdą swoją metodę. Oczywiście nie ma to sensu w praktyce. Jeśli 100% Twojego kodu jest obłożonych testami, to coś jest nie tak. Co powinieneś testować? Kody, w których jest jakaś logika. Ale tylko swoje! To jest ważne, bo sporo osób łapie się na tym, że testują kody innych frameworków. To jest błąd. Z założenia, frameworki są już przetestowane. To oznacza, że jeśli klasę Calculator miałbyś z innego frameworka, to wtedy nie powinieneś jej testować. Jeśli masz taki przypadek:

public IList<string> GetWords(string str)
{
	return str.Split(' ');
}

To nie testuj takiej metody. Inaczej wyszłoby na to, że testujesz metodę Split z klasy string. To nie ma sensu.
Ale już coś takiego:

public IList<string> GetWords(string str)
{
	var arr = return str.Split(' ');
	return arr.ToList();
}

MOŻE mieć sens (ale nie musi). Masz tutaj trochę dodatkowej logiki. A co, jeśli przekażesz pustego stringa? Zmienna arr będzie nullem. Więc metoda się wywali. I w tym przypadku testujesz już swój kod, a nie kod frameworka. Zwracaj na to uwagę.

To tyle jeśli chodzi o podstawy testów jednostkowych. Więcej w drugiej części artykułu, do której szalenie Cię zachęcam 🙂

Jeśli masz pytania lub znalazłeś błąd w tekście, napisz w komentarzu.

Podziel się artykułem na: