Testy jednostkowe na wyższym poziomie

Testy jednostkowe na wyższym poziomie

Wstęp

W poprzednim artykule o testach jednostkowych poruszyłem tylko podstawy. Jeśli ich nie ogarniasz, koniecznie przeczytaj najpierw tamten artykuł.

W tym artykule opiszę kilka bardziej zaawansowanych metod, które stosuje się właściwie na co dzień. Jednak nie bój się. Słowo „zaawansowane” w tym kontekście nie oznacza niczego trudnego…

Kod testowalny vs nietestowalny

Każdy system można napisać w taki sposób, że nie da się do niego zrobić testów lub zrobienie ich będzie zupełnie nieopłacalne. Taki projekt nazywamy nietestowalnym. Można system projektować też tak, żeby testy były całkowicie normalnym zjawiskiem. I do tego dążymy.

Jak zwykle kod powie więcej niż 1000 słów…

class UserData
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

class UserDataProvider
{
    public UserData ReadData(int userId)
    {
        string fileName = $@"C:\dane\{userId}.txt";
        if (!File.Exists(fileName))
            return null;

        UserData userData = new UserData();
        userData.FirstName = data[0];
        userData.LastName = data[1];
        return userData;
    }
}

Metoda ReadData sprawdza, czy plik o konkretnej nazwie istnieje (1), jeśli tak odczytuje go z dysku (2) i tworzy obiekt klasy UserData (3; metoda ma aż 3 odpowiedzialności)

Jak teraz przetestujesz jednostkowo metodę ReadData? Nie da się, bo jest silnie związana z klasą File, a problem klasy File polega na tym, że odnosi się do konkretnych zasobów, których po prostu podczas jednostkowego testowania nie będzie. Co więcej, jeśli chciałbyś zapisać dane użytkownika, klasa File zapisze plik na dysku – to jest tzw. „efekt uboczny”. Testy jednostkowe nie mogą mieć żadnych efektów ubocznych. Jest to bardzo niepożądane.

Dlatego też, żeby uczynić klasę UserDataProvider testowalną, musimy zaprojektować jakąś abstrakcję – zastosować DependencyInjection. Jeśli nie wiesz co to, przeczytaj artykuł, w który opisuję ten mechanizm.

Stosuj abstrakcje

Zamiast posługiwać się bezpośrednio klasą File, utworzymy interfejs, który zostanie wstrzyknięty do UserDataProvider. Jeśli nie rozumiesz pojęcia wstrzyknięcie, koniecznie przeczytaj ten artykuł.

interface IDataRepository
{
    string[] GetData(int id);
}

public class FileRepository : IDataRepository
{
    public string[] GetData(int id)
    {
        try
        {
            string fileName = $@"C:\dane\{id}.json";
            return File.ReadAllLines(fileName);
        }catch(FileNotFoundException)
        {
            return null;
        }        
    }
}

Mając taką abstrakcję, możemy już zmienić klasę UserDataProvider i uczynić ją testowalną:

class UserDataProvider
{
    IDataRepository repo;
    public UserDataProvider(IDataRepository repo)
    {
        this.repo = repo;
    }
    public UserData ReadData(int userId)
    {
        string[] data = repo.GetData(userId);
        
        UserData userData = new UserData();
        userData.FirstName = data[0];
        userData.LastName = data[1];
        return userData;
    }
}

Zobacz, co się przy okazji stało. Metoda ReadData robi już tylko jedną rzecz, a nie kilka jak to było na początku.

Ale jak teraz testować tę klasę? Musimy stworzyć JAKIŚ obiekt implementujący interfejs IDataRepository…

Co to jest Fake Object?

Fake Object to nic innego jak obiekt oszukany. Ma się zachować dokładnie tak, jak tego chcemy w danej sytuacji. Napiszmy więc sobie taki FakeObject, który implementuje IDataRepository:

class FakeRepository : IDataRepository
{
    public string[] DataToReturn { get; set; } = null;
    public string[] GetData(int id)
    {
        return DataToReturn;
    }
}

Po prostu metoda GetData zwróci takie dane, jakie przekażemy wcześniej do właściwości DataToReturn. Teraz przyszedł czas na napisanie pierwszego testu z Fake’iem. Przygotuj zatem nowy projekt testowy (jeśli nie wiesz jak, to przeczytaj artykuł o podstawach testów jednostkowych).

Testy z użyciem Fake

Ja w swoim przykładzie będę stosował bibliotekę nUnit.

Tak jak mówiłem, testujemy klasę UserDataProvider i metodę ReadData. Przypomnę kod:

class UserDataProvider
{
    IDataRepository repo;
    public UserDataProvider(IDataRepository repo)
    {
        this.repo = repo;
    }
    public UserData ReadData(int userId)
    {
        string[] data = repo.GetData(userId);
        
        UserData userData = new UserData();
        userData.FirstName = data[0];
        userData.LastName = data[1];
        return userData;
    }
}

Jakie chcemy przetestować przypadki?

  • nie ma użytkownika o takim id
  • odczytane dane są niepoprawne
  • odczytane dane są prawidłowe

Test – brak użytkownika

Napiszmy więc pierwszy test:

[Test]
public void ReadData_NoSuchUser_ReturnsNull()
{
    FakeRepository repo = new FakeRepository();
    repo.DataToReturn = null;

    UserDataProvider udp = new UserDataProvider(repo);
    UserData result = null;
    Assert.DoesNotThrow(() => result = udp.ReadData(0));
    Assert.IsNull(result);
}

Najpierw został utworzony obiekt fake’owy. Chcemy, żeby zwracał null – zakładamy, że tak będzie, gdy użytkownika nie będzie w systemie.

Następnie utworzyliśmy prawdziwy obiekt – UserDataProvider, korzystający z oszukanego FakeRepository.

I sprawdzamy, czy metoda się nie wywala (nie chcemy tego) i czy nie zwróciła żadnego użytkownika.

Po uruchomieniu testu okazuje się, że aplikacja się wykrzacza – jest rzucony wyjątek NullReferenceException. No oczywiście, że tak bo okazuje się, że w metodzie ReadData nigdzie nie sprawdzamy, co zostało zwrócone z repozytorium. Poprawmy to:

public class UserDataProvider
{
    IDataRepository repo;
    public UserDataProvider(IDataRepository repo)
    {
        this.repo = repo;
    }
    public UserData ReadData(int userId)
    {
        string[] data = repo.GetData(userId);
        if(data == null)
            return null;

        UserData userData = new UserData();
        userData.FirstName = data[0];
        userData.LastName = data[1];
        return userData;
    }
}

Super, teraz działa. Sprawdźmy zatem drugi przypadek.

Test – poprawne dane

[Test]
public void ReadData_UserExists_ReturnsUser()
{
    FakeRepository repo = new FakeRepository();
    repo.DataToReturn = new string[]
    {
        "Adam",
        "Jachocki"
    };

    UserDataProvider udp = new UserDataProvider(repo);
    UserData user = null;
    
    Assert.DoesNotThrow(() => user = udp.ReadData(0));
    Assert.IsNotNull(user);
    Assert.AreEqual("Adam", user.FirstName);
    Assert.AreEqual("Jachocki", user.LastName);
}

Najpierw skonfigurowaliśmy obiekt fake’owy tak, żeby zwrócił tablicę z dwoma elementami – dokładnie w takiej formie dostaniemy dane z pliku tekstowego.

Na koniec sprawdziliśmy kilka rzeczy:

  • czy program się nie wysypał
  • czy user jest prawidłowym obiektem
  • czy user posiada odpowiednie wartości

Tym razem test zadziałał. No to został ostatni przypadek…

Test – nieprawidłowe dane

[Test]
public void ReadData_InvalidData_ThrowsException()
{
    FakeRepository repo = new FakeRepository();
    repo.DataToReturn = new string[]
    {
        "Adam",
    };

    UserDataProvider udp = new UserDataProvider(repo);
    UserData user = null;

    Assert.Throws<InvalidDataException>(() => user = udp.ReadData(0));
}

Przede wszystkim chcemy, żeby program się wygrzmocił, jeśli dane będą w niepoprawnym formacie (np. repo zwróci tablicę jednoelementową zamiast dwuelementową). To zdecydowanie jest sytuacja wyjątkowa, w której zastosowanie wyjątków ma jak najbardziej sens. Program ma się wywalić, więc nie stosujemy już innych sprawdzeń.

Po uruchomieniu tego testu dostajemy brzydki błąd na twarz z komunikatem:

Expected: <System.IO.InvalidDataException>
  But was:  <System.IndexOutOfRangeException

Oznacza to, że owszem został rzucony wyjątek, ale IndexOutOfRangeException zamiast tego, który chcemy – InvalidDataException. No racja. Jeśli spojrzysz na klasę UserDataProvider, zobaczysz że nigdzie nie rzucamy takiego wyjątku. Natomiast IndexOutOfRange jest rzucany przez system, ponieważ odwołujemy się do nieistniejącego elementu w tablicy. Naprawmy to:

public UserData ReadData(int userId)
{
    string[] data = repo.GetData(userId);

    if (data == null)
        return null;

    if (data.Length < 2)
        throw new InvalidDataException("Dane w niepoprawnym formacie!");

    UserData userData = new UserData();
    userData.FirstName = data[0];
    userData.LastName = data[1];
    return userData;
}

Testy poszły, ale ja teraz mam duże zastrzeżenia do tego kodu. Metoda ReadData nie dość, że tworzy użytkownika, to jeszcze sprawdza poprawność danych. Czyli znów ma dwie odpowiedzialności. Powinniśmy teraz trochę ten kod wyczyścić i walidację danych zrobić w osobnej metodzie:

Trochę czyszczenia

public UserData ReadData(int userId)
{
    string[] data = repo.GetData(userId);
    if (!ValidateData(data))
        return null;       

    UserData userData = new UserData();
    userData.FirstName = data[0];
    userData.LastName = data[1];
    return userData;
}

bool ValidateData(string[] data)
{
    if (data == null)
        return false;

    if(data.Length < 2)
        throw new InvalidDataException("Dane w niepoprawnym formacie!");

    return true;

}

Kod stał się bardziej czytelny i nadal działa. SUPER! Zwróć uwagę na dwie rzeczy:

  • to co właśnie zrobiliśmy (czyszczenie kodu, rozdzielanie go) nazywa się refactoring. Podczas refactoringu czasami dochodzi do błędów. Gdyby nie testy jednostkowe, moglibyśmy ich nie wychwycić, a przynajmniej nie tak szybko. Jest taka zasada, która mówi – nie refaktoruj kodu, do którego nie masz testów.
  • podczas poprawiania kodu może okazać się, że musisz pewne rzeczy przemyśleć lub przeprojektować

Wiesz już czym jest Fake Object i jak go używać w testach. Ale jest jeszcze jedno… Fajne…

Czym jest Mock?

Mock to imitacja (dosłowne tłumaczenie) jakiegoś obiektu. To jest alternatywa dla FakeObject. W niektórych językach programowania może być trudne lub niemożliwe stworzenie mocka. Na szczęście my jesteśmy w świecie .NET, gdzie z odpowiednią biblioteką jest to oczywiste i proste jak beknięcie po piwie.

Różnica między Mock a Fake

Główną różnicą jest to, że jeśli tworzysz FakeObject, musisz zaimplementować wszystkie metody z interfejsu. Gdy tworzysz Mock – implementujesz tylko to co chcesz i tak jak chcesz. I to ad hoc!

Jednak Mock nie jest złotym środkiem. Czasami lepiej się sprawdzi Mock, a w niektórych przypadkach lepiej będzie napisać FakeObject.

Biblioteka Moq

Pobierz sobie do projektu testowego bibliotekę Moq z NuGet: https://www.nuget.org/packages/Moq/

Następnie do uses dodaj:

using Moq;

Teraz się pobawimy. Zmieńmy testy w taki sposób, żeby nie używać Fake, tylko Mock (będziemy „mokować”). Najpierw pierwszy przypadek:

[Test]
public void ReadData_NoSuchUser_ReturnsNull()
{
    var mockRepository = new Mock<IDataRepository>();
    mockRepository.Setup(m => m.GetData(It.IsAny<int>())).Returns<string[]>(null);

    UserDataProvider udp = new UserDataProvider(mockRepository.Object);
    UserData result = null;
    Assert.DoesNotThrow(() => result = udp.ReadData(0));
    Assert.IsNull(result);
}

Co tu się stało?

  1. Utworzyliśmy obiekt Mock, mówiąc mu jaki interfejs ma imitować
  2. Za pomocą metody Setup możemy skonfigurować Mocka w taki sposób, żeby powiedzieć mu:
    • jakie argumenty przyjmuje metoda (może to być konkretny argument albo tak jak tutaj – jakikolwiek int: It.IsAny<int>()
    • jaką wartość ma zwracać metoda – w związku z tym, że zwracamy null, musimy podać typ zwracanej wartości
  3. W jednym Setupie konfigurujemy jedną metodę. Nic nie stoi na przeszkodzie, żeby skonfigurować ich więcej.

Nie pisząc żadnej nowej klasy otrzymaliśmy coś, co potrafi imitować działanie obiektu.

Klasa Mock ma właściwość Object, która jest żądanego typu (w naszym przypadku IDataRepository), dlatego też to tę właściwość wstrzykujemy do konstruktora.

A jaki jest kod? Nie ma to znaczenia. To jest zwykła imitacja – najbardziej Cię interesuje, co metoda zwraca (czasami, jaki argument przyjmuje). Co więcej, możesz skonfigurować tak, żeby mock zwracał różne wartości dla różnych parametrów, np:

mockRepository.Setup(m => m.GetData(0)).Returns<string[]>(null);
mockRepository.Setup(m => m.GetData(1)).Returns(new string[] { "Adam", "Jachocki"});
mockRepository.Setup(m => m.GetData(2)).Returns(new string[] { "Jan", "Kowalski" });

W ramach ćwiczeń zachęcam Cię do przerobienia pozostałych testów z Fake’ów na Mocki.

Dokumentacja

Trochę mnie korci, żeby napisać coś więcej o bibliotece Moq, ale to nie jest o tym artykuł. Jeśli będzie jakaś prośba, na pewno to zrobię. Póki co odsyłam do:

Biblioteka Moq potrafi zrobić właściwie chyba wszystko, co sobie wymyślisz. Dlatego polecam poczytać o niej i potestować.


To właściwie wszystko jeśli chodzi o testy jednostkowe. Jeśli czegoś nie rozumiesz, coś pominąłem lub znalazłeś błąd, podziel się w komentarzu. Jeśli uważasz artykuł za przydatny, podziel się nim z innymi 🙂

Podziel się artykułem na:
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: