Wprowadzenie
W ostatnim wpisie poruszyłem temat testowania, a w dzisiejszym wpisie pozostaniemy przy tym temacie. O ile z testowaniem warstwy logiki biznesowej na ogół nie mamy problemów, to już dużo gorzej wygląda to z warstwą dostępu do danych. Entity Framework z pudełka nie umożliwia prostego pisania testów jednostkowych. Istnieją różne rozwiązania tego problemu. Część osób idzie tak naprawdę w testy integracyjne i wykonuje zapytania na realnej bazie. Część osób korzysta z baz danych w pamięci (np. SQLite). Ja w tym wpisie natomiast pokażę Ci, jak wykorzystać bibliotekę Effort do pisania testów Entity Framework.
Repozytorium do testów
W przykładzie przetestujemy metodę GetAllActive z ProductRepository. Metoda jest bardzo prosta i zwraca z bazy produkty, które mają ustawioną flagę IsActive na true:
public class ProductRepository | |
{ | |
private Lazy<DataContext> _db; | |
protected DataContext Db | |
{ | |
get { return _db.Value; } | |
} | |
public ProductRepository(Lazy<DataContext> db) | |
{ | |
_db = db; | |
} | |
public IEnumerable<Product> GetAllActive() | |
{ | |
return Db.Products | |
.Where(p => p.IsActive) | |
.ToList(); | |
} | |
} |
Klasa Product zwracana przez repozytorium pojawiała się już wielokrotnie na blogu, ale przypomnę ją jeszcze raz:
public class Product : Model | |
{ | |
public string Name { get; set; } | |
} |
public class Model | |
{ | |
public Model() | |
{ | |
IsActive = true; | |
} | |
public int Id { get; set; } | |
public bool IsActive { get; set; } | |
} |
Repozytorium w konstruktorze otrzymuje zależność dotyczącą klasy data contextu z Entity Framework. W testach nie będziemy tej klasy mockować, jak to jest w normalnych testach jednostkowych. Effort udostępnia specjalny rodzaj połączenia do bazy, które dopiero będzie mockować działanie bazy. Dlatego też w testach będziemy potrzebować klasy kontekstu, która wygląda tak:
public class DataContext : DbContext | |
{ | |
public DataContext() | |
: base("Name=DefaultConnection") | |
{ | |
} | |
public DataContext(DbConnection connection) | |
: base(connection, true) | |
{ | |
} | |
public DbSet<Product> Products { get; set; } | |
} |
Zwróć uwagę, że klasa posiada dwa konstruktory. Pierwszy bezparametrowy będzie wykorzystywany normalnie w aplikacji (z niego będzie korzystał kontener). Natomiast drugi konstruktor posłuży nam do testów. Za pomocą niego przekażemy połączenie do bazy utworzone przez bibliotekę Effort, która zamockuje nam bazę danych.
Effort
Effort (https://entityframework-effort.net) jest darmową biblioteką, która ułatwia testowanie kodu wykorzystującego Entity Framework. Jest tworzona przez tę samą firmę, co opisywana ostatnio biblioteka Entity Framework Plus. W tym momencie Effort wspiera tylko normalnego Entity Frameworka (5 oraz 6). Niestety nie ma jeszcze wsparcia dla wersji Core.
Biblioteka udostępnia w pamięci bazę danych, na której możemy wykonywać operacje. Dzięki temu możemy w izolacji wykonać testy repozytoriów, bez potrzeby użycia zewnętrznych baz danych. Dodatkowo Effort umożliwia wczytanie początkowego stanu bazy danych z plików csv. Możemy przygotować różne wersje danych do testów bez oprogramowania tego w kodzie.
Effort – użycie
W swoich projektach dla warstwy dostępu do danych tworzę dedykowany projekt z testami. Każda klasa z testami repozytoriów będzie potrzebowała używać zamockowanego data contextu. Dlatego w projekcie tworzę bazową klasę dla testów, w której znajduje się kod tworzący instancje klasy DataContext, która korzysta z połączenia do bazy dostarczonego przez Effort:
public class RepositoryTests | |
{ | |
protected DataContext CreateContext(string path = null) | |
{ | |
IDataLoader loader = new CsvDataLoader(path ?? "Data"); | |
return new DataContext(DbConnectionFactory.CreateTransient(loader)); | |
} | |
} |
Metoda CreateContext będzie uruchamiana przez każdy z testów repozytorium. Korzysta ona z klasy CsvDataLoader z Effort, która jest odpowiedzialna za załadowanie startowej zawartości bazy danych z plików csv (niżej pokażę, jak tworzyć pliki csv z danymi). Domyślnie metoda wczytuje dane z katalogu Data, w którym znajduje się podstawowa wersja danych pokrywająca większość scenariuszów testowych.
Metoda dodatkowo udostępnia opcjonalny parametr path, za pomocą którego możemy przekazać ścieżkę do innego folderu z danymi. Wykorzystuję to w niektórych testach, gdzie domyślna zawartość bazy danych nie umożliwia sprawdzenia danego przypadku.
Doświadczenie pokazało mi, że takie rozwiązanie sprawdza się najlepiej. Z jednej strony moglibyśmy dla każdego testu tworzyć specyficzny zestaw danych, aby zmiana wspólnego zestawu nie wpływała na testy. Z drugiej strony jednak takie podejście wydłuża pisanie testów. Dodatkowo jakieś większe zmiany w strukturze bazy danych powodują, że musimy zaktualizować większą liczbę plików z danymi testowymi.
Test repozytorium
Dla testów klas w swoich projektach tworzę dedykowany folder (w tym przypadku jest to folder o nazwie ProductRepositoryTests). W nim tworzę oddzielną klasę z testami dla każdej z metod. Dodatkowo dodaję również klasę bazową, w której znajdują się wspólne elementy (głównie metoda Create tworząca klasę, którą będę testował, oraz ewentualne mocki). W przykładzie klasa bazowa wygląda tak:
public class BaseTests : RepositoryTests | |
{ | |
protected ProductRepository Create(string path = null) | |
{ | |
var context = CreateContext(path); | |
return new ProductRepository(new Lazy<DataContext>(() => context)); | |
} | |
} |
Dziedziczy ona po klasie RepositoryTests, dzięki czemu ma dostęp do metody CreateContext, którą wywołuje przez utworzenie repozytorium w metodzie Create.
Metoda Create również posiada opcjonalny parametr ze ścieżką, który przekazuje do metody CreateContext.
Dla metody GetAllActive z ProductRepository przygotowałem dwa testy. Pierwszy wykorzystuje domyślny zestaw danych i zwraca z niego tylko aktywne produkty. Drugi test natomiast korzysta ze specyficznego zestawu danych, w którym znajdują się tylko nieaktywne obiekty. Klasa z testami wygląda tak:
public class GetAllActiveTests : BaseTests | |
{ | |
[Fact] | |
public void Return_Only_Active_Products() | |
{ | |
var repository = Create(); | |
var products = repository.GetAllActive(); | |
products.Should().NotBeNull(); | |
products.Count().Should().Be(9); | |
products.Should().OnlyContain(p => p.IsActive); | |
} | |
[Fact] | |
public void Return_Empty_Collection() | |
{ | |
var repository = Create(@"ProductRepositoryTests\Data\NoActiveProducts"); | |
var products = repository.GetAllActive(); | |
products.Should().NotBeNull(); | |
products.Count().Should().Be(0); | |
} | |
} |
W testach używałem do assertów opisywanego w poprzednim wpisie Fluent Assertions. Pierwszy test sprawdza, czy lista zwróconych produktów zawiera 9 elementów (tyle jest aktywnych produktów w testowych danych w domyślnym zestawie) oraz czy wszystkie produkty mają ustawioną flagę IsActive na true.
Drugi test natomiast ładuje przygotowane specjalne dla niego dane (przekazuje ścieżkę do metody Create) i sprawdza, czy zwrócona lista jest pusta (dane zawierają tylko nieaktywne produkty).
Dzięki wykorzystaniu biblioteki Effort testy te wykonają się bez konieczności konfiguracji środowiska w jakiś sposób. W szczególności nie potrzebujemy żadnej bazy testowej w systemie.
Warto jeszcze zobaczyć, jak wygląda solution w testowym projekcie:
Widać ładny domyślny katalog Data z danymi w głównym folderze projektu oraz katalog NoActiveProducts w katalogu z testami ProductRepository, z danymi specyficznymi dla drugiego testu. Warto też zauważyć, że nie potrzebujemy w tym przypadku wszystkich tabel z bazy. Możemy wrzucać tylko te, które są używane w danym teście.
Przygotowanie danych testowych
Aby móc korzystać z biblioteki Effort za pomocą wyżej przedstawionej techniki, potrzebujemy do tego danych testowych. Można je przygotować na kilka sposobów. Ja najczęściej wykorzystuję do tego bibliotekę Bogus, która generuje mi bazę testową. Bazę tę wykorzystuję normalnie do testów oraz na jej podstawie generuję pliki csv dla podstawowego zestawu testów.
Do wygenerowania plików csv wykorzystuję narzędzie dostarczone przez twórców Efforta. Effort.CsvTool (https://entityframework-effort.net/export-data-to-csv) umożliwia przekazanie connection stringa do bazy danych, z której chcemy wygenerować dane, oraz katalogu, w którym zostaną zapisane pliki. Narzędzie wygeneruje pliki w takiej postaci, aby sama biblioteka mogła bez przeszkód ich użyć.
Największym problemem tego narzędzia jest to, że jest ono dostępne w formie kodu, który trzeba pobrać i skompilować u siebie w komputerze.
Do generowania specyficznych danych dla testów wykorzystuję głównie SQL Management Studio. W nim przygotowuję zapytanie, które zwróci mi z testowej bazy dane specyficzne dla danego przypadku. W przypadku drugiego testu wykonałem na bazie danych takie zapytanie:
SELECT * | |
FROM [dbo].[Products] | |
where IsActive = 0 |
Jego wynik zapisałem do pliku Products.csv w katalogu ProductRepositoryTests/Data/NoActiveProducts.
Przykład
Na githubie znajduje się przykład do tego wpisu (https://github.com/danielplawgo/EffortTests). Po jego pobraniu nie trzeba niczego dodatkowo konfigurować, można od razu wykonać testy.
Podsumowanie
Testowanie automatyczne warstwy dostępu do danych nie jest prostym zadaniem. Na szczęście takie biblioteki jak Effort bardzo to ułatwiają.
Dzięki Effortowi możesz łatwo przetestować kod Entity Framework, a to wszystko bez potrzeby korzystania z realnej bazy danych.
A jak Ty testujesz kod Entity Framework?
Nice! https://zzzprojects.com daje rade polecam
Siemka,
Brzmi bardzo ciekawie. Osobiście miałem kilka podejść do testowania baz danych, aż doszedłem do wniosku, że bez instancjonowania prawdziwej się nie obejdze, ale może to da radę.
Sprawdzaleś może jak radzi z constraintami czy procedurami?
Spawdzać nie sprawdzałem, ale z tego co się orientuje to chyba z tym sobie średnio radzi – https://stackoverflow.com/questions/47033466/create-stored-procedure-on-effort-database-for-unit-test
Tak też myślałem, ale to i tak wyglada dobrze. Dla podstawowych testów jest wystarczające. A gdy wchodzimy w elementy zaawansowane to pozostają integracyjne na prawdziwej bazie.