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: