Dlaczego oddzielać testy jednostkowe od testów integracyjnych?
Znalazłem kilka postów w internecie, na których programiści piszą, że nie widzą sensu oddzielania testów jednostkowych od testów integracyjnych. Piszą, że test to po prostu test i nie ma sensu tego oddzielać i wprowadzać dziwnego nazewnictwa. Zazwyczaj wtedy wszystkie testy nazywają testami jednostkowymi. Nie zgadzam się z tym i w swoich projektach zawsze wprowadzam taki podział. Moim zdaniem testy jednostkowe muszą zostać oddzielane od testów integracyjnych. Przede wszystkim taki podział wprowadza porządek do mojego projektu. Po wydzieleniu dwóch typów testów do osobnych projektów wiem, że dla testów integracyjnych mam jeszcze na przykład osobną konfigurację, która musi zostać uzupełniona prawidłowa, jest też osobna baza danych, całe środowisko pod takie testy musi zostać wcześniej przygotowane. W testach jednostkowych wiem, że test zawsze musi mi zwrócić ten sam wynik i nie muszę przejmować się innymi czynnikami, które mogą wpłynąć na ten test. Poza tym testy integracyjne wykonują się dużo dłużej niż testy jednostkowe i nie są one tak często uruchamiane. Z kolei testy jednostkowe, dzięki temu, że są bardzo szybkie, możemy je wykonywać nawet przy każdym buildzie, co za tym idzie mamy szybki feedback, jeżeli coś nie działa lub mamy jakąś regresję w naszym kodzie. Jeżeli testy nie są od siebie oddzielane, to później wszystkie są uruchamiane rzadko, z czasem coraz rzadziej, ponieważ nie chcemy czekać aż tak długo na wynik, a w przypadku testów integracyjnych, to zawsze będzie trwało. Wyobrażasz sobie uruchamianie testów, które trwają kilka minut po każdym buildzie? Nie bardzo.
Przygotowanie do testów integracyjnych
Jeżeli chcemy pisać testy integracyjne, musimy przygotować sobie odpowiednie środowisko. Załóżmy, że chcemy testować jakieś metody, które zawierają w sobie zapytania lub komendy na bazie danych. W takim przypadku najlepiej sobie przygotować osobną bazę danych dla testów integracyjnych. W pliku konfiguracyjnym dla projektu z testami integracyjnymi zdefiniować osobnego connection string'a do bazy przygotowanej pod testy integracyjne. Są różne podejścia do testowania współpracy z bazą danych, między innymi testowanie na bazie danych tworzonej w pamięci, ja jednak przedstawię inne podejście. Całą logikę, która współpracuje z bazą danych, będziemy wykonywać w transakcjach i po skończeniu każdego testu zrobimy rollback transakcji. W związku z tym, że często opieramy się na zewnętrznych zależnościach, testy integracyjne wykonują się dłużej niż testy jednostkowe.
Jak wygląda przykładowy test integracyjny napisany w C#?
Załóżmy, że mamy klasę UserRepository, która, żeby za bardzo nie komplikować, tylko dodaje nowe użytkownika za pomocą entity framework.
public class User
{
public int Id { get; set; }
public string Name { get; set; }
}
public class UserRepository
{
public void Add(User user)
{
using (var context = new ApplicationDbContext())
{
context.Users.Add(user);
context.SaveChanges();
}
}
}
Jak widzisz, klasa ta ma zewnętrzne zależności - współpracuje z bazą danych.
#1 Baza danych dla testów integracyjnych.
Aby przeprowadzić testy w takim przypadku najlepiej sobie przygotować bazę danych dla testów integracyjnych.
#2 Nowy projekt dla testów integracyjnych.
Aby nie mieszać testów integracyjnych z innymi testami. Najlepiej założyć osobny projekt, gdzie będą tylko testy integracyjne. Dzięki takiemu podziałowi, testy jednostkowe i integracyjne mogą być uruchamiane oddzielnie. Nowy projekt możesz nazwać Project.IntegrationTests.
#3 Instalujemy wymagane frameworki.
W moim przypadku będzie to EntityFramework, NUnit oraz NUnit3TestAdapter.
#4 Konfiguracja.
W pliku app.config uzupełniam connection string'a dla mojej bazy testowej.
#5 Izolacja testów.
Chcę, żeby po każdym teście, dodane przeze mnie dane znikały z bazy danych. Aby to zrobić, wszystkie moje komendy muszą być wykonywane w transakcji. Możemy to zrobić, dodając własny atrybut, który następnie będzie dodawany do każdego testu, po którym chcemy czyścic dane. Dodajemy klasę na przykład o nazwie Isolated. Dzięki oznaczeniu naszej klasy testującej atrybutem [Isolated], przed każdym testem będzie tworzona nowa transakcja, oraz po każdym teście będzie uruchamiany rollback tej transakcji.
public class Isolated : Attribute, ITestAction
{
private TransactionScope _transactionScope;
public ActionTargets Targets
{
get { return ActionTargets.Test; }
}
public void AfterTest(ITest test)
{
_transactionScope.Dispose();
}
public void BeforeTest(ITest test)
{
_transactionScope = new TransactionScope();
}
}
#6 Nowa klasa dla testów integracyjnych klasy UserRepository.
Trzymając się konwencji, dodajemy do projektu testowego nową klasę o nazwie UserRepositoryTests. Tutaj będą wszystkie metody, testujące klasę UserRepository.
#7 Piszemy test integracyjny.
public class UserRepositoryTests
{
[Test, Isolated]
public void Add_PassValidUser_ShouldAddUserToDatabase()
{
var context = new ApplicationDbContext();
var user = new User { Id = 1, Name = "name" };
var userRepository = new UserRepository();
userRepository.Add(user);
var usersCount = context.Users.Count(x => x.Id == user.Id && x.Name == user.Name);
Assert.That(usersCount, Is.EqualTo(1));
}
}
Musimy pamiętać, aby oznaczyć metodę atrybutami Test i Isolated. Dodaliśmy metodę, która po przekazaniu użytkownika, powinna dodać go do bazy danych. Najpierw jest inicjalizacja obiektów, które używamy w teście. Dodajemy użytkownika poprzez metodę Add klasy UserRepository. Na koniec sprawdzamy, czy w bazie danych jest tylko jeden użytkownik o takich danych. Test przechodzi, dodatkowo, jeżeli test odpalimy wiele razy, to zawsze będzie przechodził, ponieważ po każdym teście na bazie danych jest wykonywany rollback transakcji.
PODSUMOWANIE:
Mam nadzieję, że udało mi się Ciebie wprowadzić tym prostym przykładem w świat testów integracyjnych. Widzisz, jaka jest różnica pomiędzy testami jednostkowymi oraz integracyjnymi. Ten przykład był dość prosty, nie chciałem na początek męczyć Cię skomplikowanymi przykładami. Jeżeli będziesz zainteresowany tematem testów integracyjnych na blogu, to w kolejnych artykułach postaram się przedstawić bardziej skomplikowane przykłady.
Poprzedni artykuł - Jak Pozbywać się Zewnętrznych Zależności w Testach Jednostkowych? Wprowadzenie do Mockowania Danych w C#.
Następny artykuł - Test Driven Development: Korzyści ze stosowania TDD na przykładzie w .NET.