Testy jednostkowe - temat rzeka. W każdej technologii, z którą miałem do czynienia, jednym z ważniejszych tematów były testy jednostkowe. Powstała już cała masa artykułów opisujących dobre praktyki czy różnego typu metodyki takie jak TDD lub BDD. Wspominając swoje początki, gdy odpalając kod nie wiedziałem czy się odpali i dlaczego nie, zrozumiałem, jak słaby nacisk w procesie edukacji kładzie się na testowanie oprogramowania. Pragnąc nieść kaganek oświaty chciałbym dołożyć swoją cegiełkę i przedstawić wam podstawy testów jednostkowych za pomocą biblioteki xUnit. Zapraszam do lektury!
Stworzenie projektu
Jak to zwykle bywa na moim blogu projekt tworzę za pomocą .NET CLI. Wielokrotnie zadawałem sobie pytania: czy pójść na siłownie? może lepiej korzystać z innego sposobu tworzenia projektu? W obu przypadkach stwierdziłem, że to nie czas i miejsce. Uważam, że .NET CLI to najszybsza metoda na stworzenie projektu (nawet mając Visual Studio For Mac). Przechodząc do działania: za pomocą konsoli tworzę projekt, a dopiero po wygenerowaniu solucji przechodzę do kodowania (czy to wspomnianego wcześniej Visual Studio For Mac czy też Visual Studio Code).
Najpierw kilka komend, aby stworzyć strukturę projektu:
$ mkdir xunit-tests
$ cd xunit-tests
$ mkdir -p ./{src,tests}
Teraz możemy przejść do tworzenie projektów .NET Core:
$ cd src && dotnet new classlib -n Application.Lib -o Application.Lib
$ cd ..
$ cd tests && dotnet new xunit -n Application.Tests -o Application.Tests
Nasza główna aplikacja będzie prostą biblioteką.
Na koniec procesu kreacji projektu tworzymy plik solucji i dodajemy do niego zależności:
$ dotnet new sln
$ dotnet sln add reference src/Application.Lib/Application.Lib.csproj
$ dotnet sln add reference tests/Application.Tests/Application.Tests.csproj
Majac już wygenerowany cały projekt możemy przejść do dowolnego IDE (Zintegrowane Środowisko Programistyczne, ja korzystam z Visual Studio Code). Do zarządzania zależnościami projektu nadal będę używać .NET CLI.
Czas na suchy żart prowadzącego. Lubicie kurczaki? Więc czas na nuget-a! zapada cisza, nawet świerszcz nie planuje zagrać swojej smutnej melodii...
Znaczy ten, przyszedł czas na dodanie potrzebnych paczek nuget-a, referencji projektowych i możemy zacząć pisać testy jednostkowe.
Konfigurowanie paczek i referencji
Aby w głównym projekcie móc korzystać z klas zdefiniowanych musimy dodać referencję do naszego projektu testowego:
$ dotnet add reference ../../src/Application.Lib/Application.Lib.csproj
Wygenerowany projekt testów powinien zawierać przykładowy plik testów o nazwie "UnitTest1.cs", który wygląda tak:
using System;
using Xunit;
namespace App.Test
{
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
}
Jak widać znajduje się tutaj definicja klasy UnitTest1, a oprócz tego pierwszy test, który jest jak basista w zespole - nic nie robi :) . Dodatkowo jest opatrzony atrybutem "Fact" (o którym napiszę trochę więcej w dalszej części posta).
Korzystając z tego, że mamy już wygenerowany jakiś test, możemy go odpalić, aby przetestować czy nasz test runner działa poprawnie. W katalogu, gdzie wygenerowaliśmy projekt testowy, odpalamy komendę "dotnet test":
$ cd tests/Application.Tests
$ dotnet test
Na ekranie naszego terminala powinno się pojawić coś podobnego do powyższego gif-a. Najważniejszy jest tutaj zielony napis informujący nas o pomyślnym zakończeniu testów.
Czas pisać testy jednostkowe!
Mając gotowe środowisko do pracy, w końcu możemy przejść do najciekawszej rzeczy: pisania testów. Zacznijmy od jakiś prostych przykładów, aby zobrazować na czym to polega. Niech nasz główny projekt zawiera klasę samochód, która będzie miała kilka podstawowych propertisów:
- Marka
- Model
- Przebieg
Implementacja takiej klasy jest dość prosta:
public class Car
{
public string Make { get; private set; }
public string Model { get; private set; }
public int Milage { get; private set; }
public Car(string make, string model, int milage)
{
Make = make;
Model = model;
Milage = milage;
}
}
W ten sposób otrzymaliśmy POCO(Plain old CLR objects), prostą klasę, który odzwierciedla nam jakąś encję w domenie.
Ale zaraz... gdzie tu te testy?
No właśnie, stworzyliśmy pełną implementacje klasy, ponieważ potrzebujemy jakąś logikę, którą będziemy testować. Dla przykładu dodajmy prostą metodę, która będzie inkrementować propertis Millage dokładnie tak jakby samochód jechał.
Piszemy pierwszy test
Od czego zaczynamy?
Od kubka kawy. Jak ktoś nie lubi to zawsze można złapać herbatę. Dalej źle? To pomiń ten krok i siądź do napisania testu, który nie przechodzi. Tworzymy pusty plik o nazwie "CarTest.cs" w naszym projekcie testów. Musimy stworzyć nową klasę, której zadaniem będzie testowanie funkcjonalności klasy "Car".
using Application;
using Xunit;
namespace Application.Tests
{
public class CarTest
{
}
}
Decydujemy, czy test, który właśnie piszemy jest faktem czy teorią. Co to oznacza? Sięgnijmy do dokumentacji xUnit-a:
Facts are tests which are always true. They test invariant conditions.
Theories are tests which are only true for a particular set of data.
W wolnym tłumaczeniu: za pomocą atrybutu "Fact" testujemy tylko jeden przypadek, zaś za pomocą "Teorii" możemy przetestować wiele przypadków. Może uda mi się to bardziej zobrazować w dalszej części posta.
Obecnie chcemy przetestować tylko jeden przypadek, że liczba przechowywana w propertisie Milage zostanie zwiększona po wywołaniu metody "IncrementMilage", dlatego też użyjemy do tego atrybutu "Fact". Co spowoduje że nasz test będzie wyglądał następująco:
[Fact]
public void Should_Increment_Milage()
{
var expectedMilage = 160001;
var car = new Car("Audi", "80", 160000);
car.IncrementMilage();
Assert.Equal(expectedMilage, car.Milage);
}
Oczywiście, jeżeli uruchomimy test runner-a, to w terminalu pokaże się czerwony napis:
CarTest.cs(14,17): error CS1061: Element „Car” nie zawiera definicji „IncrementMilage”, a nie odnaleziono metody rozszerzenia „IncrementMilage”, która przyjmuje pierwszy argument typu „Car” (czy nie brakuje dyrektywy using lub odwołania do zestawu?). [/Users/dante/workspace/blog/tests/unittests/tests/Application.Tests/Application.Tests.csproj]
Nie przejmujmy się tym zbyt mocno! Wszystko jest na swoim miejscu ;). Po prostu, zgodnie z regułami TDD, najpierw napisaliśmy test a dopiero teraz przejdziemy do implementacji.
Abra kadabra, czary many zmień na zielono za browary! Na całe szczęście danina nie jest konieczna (magiczne formuły też nie, to w końcu technologia!). Tak więc piszemy minimalną ilość kodu potrzebnego aby ponownie cieszyć się z "zielonych testów". Implementujemy bardzo prostą metodę w klasie Car
public void IncrementMilage()
{
Milage++;
}
Tylko tyle wystarczy aby testy jednostkowe ponownie zaświeciły się na zielono.
Czas na pierwszą teorię
O co dokładnie chodzi z tymi teoriami? Zobrazujmy to na przykładzie sprzedaży samochodu. Będziemy potrzebowali kolejnej klasy, nazwijmy ją "Person". Niech zawiera 3 propertisy:imię osoby, fakt posiada uprawnienia do kierowania autem i budżet na zakup samochodu. Nasz kod wygląda następująco:
public class Person
{
public string Name { get; set; }
public bool License { get; set; }
public int Founds { get; set; }
}
Dodatkowo w klasie Car dodajmy propertis "owner", który będzie przechowywał referencję do obiektu obecnego właściciela samochodu.
Pora na kilka reguł biznesowych, których będziemy przestrzegać, mianowicie:
- Nie można sprzedać samochodu osobie bez uprawnień
- Nie można sprzedać samochodu osobie, której fundusze nie są większe niż 100000
- Nie można sprzedać samochodu osobie, która nie posiada imienia
Testy najprostszych przypadków
Wyobraźmy sobie, że świat składa się z żelków. jednorożców i ogarniętych użytkowników. Przejdźmy więc do implementacji testu tzw. "happy path programing", czyli wtedy kiedy żadna z reguł biznesowych nie jest złamana.
Aby w pełni wykorzystać potencjał teorii z xunit-a stwórzmy generyczną klasę pomocniczą. Będzie ona rozbudowywała klasę TheoryData i wraz z atrybutem MemberData pozwoli na łatwą definicję wielu przypadków testowych. Oprócz atrybutu MemberData w bibliotece xUnit znajdują się jeszcze takie atrybuty jak ClassData i InlineData, które mają minimalnie inne zastosowania, acz to nie miejsce i czas na ich opisywanie.
Wracając do klasy pomocniczej, którą nazwałem PersonTheoryData - jej implementacja wygląda następująco:
public class PersonTheoryData<T> : TheoryData<T>
{
public PersonTheoryData(IEnumerable<T> data)
{
foreach (var t1 in data)
{
Add(t1);
}
}
}
Dzięki tej prostej klasie możemy zdefiniować wiele przypadków testowych za jednym razem. Więc wróćmy do klasy CarTest aby stworzyć przypadki testowe, które odzwierciedlać nam będą założenia biznesowe. Czyli potrzebujemy tutaj dwóch obiektów klasy Person, które możemy zawrzeć w tablicy w polu klasy
private static Person[] personsCanBuy = new Person[]
{
new Person { Name = "Kamil", License = true, Founds = 120000 },
new Person { Name = "Piotr", License = true, Founds = 100000 }
};
Oba obiekty klasy Person spełniają wszystkie reguły biznesowe dotyczące zakupu samochodowego.
Teraz możemy przejść do kolejnego etapu czyli, wykorzystania naszej klasy pomocniczej abyśmy mogli użyć nasze przypadki testowe w prawdziwym teście jednostkowym. Musimy stworzyć kolejny propertis klasy i po prostu zainicjować obiekt PersonTheoryData za pomocą tablicy, którą utworzyliśmy wcześniej.
public static PersonTheoryData<Person> PersonCanBuyData { get; } = new PersonTheoryData<Person>(personsCanBuy);
Ważnym elementem jest użycie tutaj statycznych propertisów klasy, w przeciwnym wypadku nasz test runner będzie informował nas o błędzie.
Teraz możemy przejść do definicji naszego testu.
[Theory]
[MemberData(nameof(PersonCanBuyData))]
public void Should_sell_car(Person person)
{
var car = new Car("Audi", "80", 160000);
car.SellTo(person);
Assert.Equal(person, car.Owner);
}
W tym teście chcemy sprawdzić tylko przypadki poprawnej sprzedaży auta i (unikając zbędnego rozbudowywania i tak dość obszernego przykładu) nie zamierzam implementować obsługi zmniejszania funduszy po zakupie czy innych podobnych case-ów, istotnych w przypadku prawdziwej aplikacji.
Implementujemy obsługę testu
Minimalna ilością kodu, aby test zaświecił się na zielono wygląda tak:
public void SellTo(Person person)
{
Owner = person;
}
Konieczne jest przypisanie referencji obiektu person do propertis-a Owner w klasie Car.
Odpalając testy jednostkowe znowu wszystkie zaświecą się na zielono.
Piszemy testy jednostkowe dla pozostałych przypadków
Jeżeli doszedłeś do tego fragmentu świadczy to o jednym z trzech przypadków:
-
rozumiesz, jak istotne jest odpowiednie testowanie kodu
-
uwielbiasz kończyć co zacząłeś niezależnie od tego, czy rzeczywiście jest ci to potrzebne do szczęścia (stąd ta jedna naklejka spowodowała, że uczestniczysz w walce o nową kolekcje Świeżaków)
-
jesteś moim korektorem i wiesz, że wstawiłem jedno wulgarne słowo w losowe miejsce i jak nie sprawdzisz dokładnie to to zauważę
Licząc na to, że kierujesz się punktem pierwszym przyszedł czas, by zdefiniować przypadki, w których jakiś obiekt klasy Person nie będzie mógł kupić samochodu. Tak więc zgodnie z naszymi założeniami biznesowymi tworzymy kolejną tablicę obiektów Person, w której będziemy przechowywać takie przypadki
private static Person[] personsCanNotBuy = new Person[]
{
new Person { Name = "", License = true, Founds = 100},
new Person { Name = "Kamil", License = false, Founds = 110000 }
};
Ja w swoim kodzie zdefiniowałem tylko dwa przypadki, chodź jest ich o wieeeeeele więcej. Gorąco zachęcam was to wypisywania wszystkich ewentualności, acz na potrzeby artykułu pozwolę sobie na odrobinę lenistwa, ponieważ rozpisanie wszystkiego nie wniosłoby żadnej dodatkowej wartości.
Ponownie wykorzystujemy naszą klasę pomocniczą aby zdefiniować test case-y dla naszej teorii.
public static PersonTheoryData<Person> PersonsCanNotBuy { get; } = new PersonTheoryData<Person>(personsCanNotBuy);
Definiujemy test:
[Theory]
[MemberData(nameof(PersonsCanNotBuy))]
public void Should_not_sell_car(Person person)
{
var car = new Car("Audi", "80", 160000);
Assert.ThrowsAny<Exception>(() => {
car.SellTo(person);
});
}
Zdecydowałem, że obsługą braku możliwości kupna samochodu przez obiekt Person, będzie rzucenie wyjątkiem, tak aby móc zareagować na niego w innej warstwie aplikacji. Oczywiście, w aplikacji dużo bardziej rozbudowanej poszerzyłbym ten przykład o jakieś customowe klasy błędów, jednak tutaj posłużymy się zwykłym dobrym obiektem Exception.
Po stworzeniu testu przejdźmy do implementacji tego zachowania po stronie klasy Car.
Nie będzie to najlepsza obsługa błędów jaką w życiu napisałem, ale na potrzeby tego przykładu da radę :p
public void SellTo(Person person)
{
if (string.IsNullOrWhiteSpace(person.Name))
throw new Exception("Person should have a name");
if (!person.License)
throw new Exception("Person should have a license");
if (person.Founds < 100000)
throw new Exception("Peson have not enough founds");
Owner = person;
}
Co tutaj się dzieje? Kiedy coś nam się nie podoba z obiektem person to rzucamy błędem i dziękuje pozamiatane :). Obsługa tego błędu powinna być zaimplementowana już w innej warstwie, tak aby poinformować użytkownika o tym, że nie może zakupić tego samochodu.
Na koniec odpalmy jeszcze raz testy jednostkowe aby zobaczyć, czy wszystko jest w porządku.
Jak widać testy jednostkowe świecą się na zielono, co jest zawsze dobrym znakiem :)
Podsumowanie
W dzisiejszym poście pokazałem wam jak rozpocząć swoją przygodę z biblioteką xUnit i jak za jej pomocą tworzyć testy jednostkowe, niektóre proste... inne trochę bardziej skomplikowane.
Trochę też opowiedziałem wam czym są fakty a czym teorię i pokazałem dość rozbudowane zastosowanie teorii przy okazji implementacji procesu sprzedaży samochodu.
I znowu pobiłem rekord długości posta :) Pomyśleć że początkowo, kiedy zaczynałem pisać, to chciałem tu jeszcze dodać kilka słów o mock-ach i bibliotece Moq, ale w tedy to by się z tego już zrobił jakiś mini e-book. Cóż, co się odwlecze to nie uciecze!
Na koniec chciałbym wam życzyć aby testy zawsze świeciły się na zielono!
Cześć
PS: nie sprawdzajcie czy korektor znalazł brzydkie słowo, ani jego ani wyrazu nie ma i nie było :P