Trzy bóle, od których zaczynam
Obiecywałem sobie w czerwcu, że na jesieni poukładam dane testowe w jedną strategię zamiast łatać je tam, gdzie akurat boli. Jest wrzesień, więc siadam do tego. Zacznę od trzech konkretnych sytuacji, bo one najlepiej tłumaczą, po co w ogóle ten wpis.
Pierwsza. Zestaw testów integracyjnych chodzi na jednej bazie i wszystkie testy dzielą tego samego użytkownika. Test A zakłada, że użytkownik nie ma jeszcze zamówień, test B właśnie mu jedno dodał. Uruchomione po kolei przechodzą, uruchomione w innej kolejności nie. Gdy któryś przerwie się w połowie, następny przebieg startuje z bazą w stanie, którego nikt nie zaprojektował. Najgorsze jest to, że taki test nie jest czerwony na stałe. Jest czerwony co czwarty raz, więc zespół uczy się go klikać ponownie zamiast czytać.
Druga. Fixture’y siedzą w plikach JSON, które ktoś kiedyś wygenerował, i rozjeżdżają się z modelem. Do klasy dochodzi wymagane pole, deserializacja wstawia w nie null, test przechodzi, bo asercja tego pola nie dotyka, i dopiero w aplikacji widać, że połowa danych startowych jest niepełna. Plik JSON nie kompiluje się razem z projektem, więc nikt nie dostaje ostrzeżenia.
Trzecia. Mock API zwraca inny kształt niż produkcja. Opisywałem w maju, jak stawiam WireMocka w kontenerze, i ten sam wpis kończył się uczciwym zastrzeżeniem: stub to moje wyobrażenie o cudzej odpowiedzi. Kiedy stub leży w katalogu na dysku agenta CI, a nie w repozytorium obok testu, to wyobrażenie zaczyna żyć własnym życiem.
Wszystkie trzy bóle mają wspólny mianownik. Nie chodzi o narzędzie, tylko o to, że nikt nie podjął świadomej decyzji, skąd biorą się dane i kto je sprząta. Ten wpis jest próbą podjęcia tej decyzji na .NET 5, z przypiętymi wersjami, w wariantach, które da się dziś wdrożyć.
Mapa strategii: cztery warstwy danych testowych
Rozdzielam problem na cztery warstwy. To nie jest ranking. To jest lista, z której wybiera się świadomie i najczęściej używa się kilku naraz w jednym repozytorium.
Warstwa pierwsza to dane w pamięci, budowane fabryką w kodzie testu. Nie ma bazy, nie ma sieci, obiekt powstaje i ginie razem z testem. Warstwa druga to jedna wspólna baza z resetem stanu przed każdym testem. Baza stoi, schemat jest gotowy, przed testem zdejmuję z niej dane. Warstwa trzecia to baza jednorazowa, podnoszona w kontenerze na test albo na klasę testową. Warstwa czwarta to brak bazy po drugiej stronie: zamiast prawdziwego serwisu odpowiada stub HTTP.
| Warstwa | Plusy | Minusy | Kiedy po nią sięgam |
|---|---|---|---|
| Fabryka w pamięci (Bogus, AutoFixture) | Milisekundy, zero infrastruktury, pełna powtarzalność przy ustawionym ziarnie | Nie sprawdza mapowania, migracji ani zapytań SQL | Testy jednostkowe i logika domenowa |
| Wspólna baza + reset (Respawn) | Realne SQL i realne constraints, szybszy niż podnoszenie bazy od zera | Wymaga stojącej instancji i porządku w kluczach obcych, kolizja przy równoległości | Zestaw integracyjny na jednym agencie |
| Baza jednorazowa w kontenerze (Testcontainers) | Pełna izolacja, ten sam obraz lokalnie i na CI, brak wycieku stanu | Kilkanaście sekund startu, wymaga Dockera na agencie | Testy, które muszą startować z gwarancją czystej bazy |
| Stub HTTP (WireMock.Net) | Sterowalne błędy, brak limitów cudzego API, natychmiastowa odpowiedź | Nie gwarantuje zgodności z prawdziwym kontraktem | Integracja z zewnętrznym serwisem, którego nie kontroluję |
Jedna kolumna, której w tej tabeli nie ma i nie będzie, to “kopia produkcji”. Wiem, dlaczego jest kusząca. Dump ma prawdziwe rozkłady, prawdziwe przypadki brzegowe i nie trzeba go wymyślać. Ma też prawdziwe dane osobowe, prawdziwe adresy i prawdziwe kwoty, które lądują na laptopie każdego, kto sklonuje repozytorium. Anonimizacja dumpa to osobny projekt, a nie krok w skrypcie startowym, i dopóki tego projektu nie ma, wszystkie przykłady w tym wpisie są syntetyczne.
Fabryka z ziarnem: Bogus 33.1.1
Zaczynam od warstwy najtańszej, bo ona rozwiązuje drugi ból z listy. Fixture w kodzie kompiluje się razem z modelem. Dodanie wymaganego pola do klasy psuje build fabryki, a nie ciszę w pliku JSON.
W .NET używam do tego Bogusa. Najświeższa wersja to 33.1.1 z 29 sierpnia, czyli sprzed dwóch tygodni. Wcześniej siedziałem na 33.0.2 z lutego.
$ dotnet add package Bogus --version 33.1.1
$ dotnet add package Respawn --version 4.0.0
$ dotnet add package WireMock.Net --version 1.4.20
$ dotnet add package DotNet.Testcontainers --version 1.5.0Wersje przypinam z tego samego powodu, dla którego przypinam tagi obrazów: chcę, żeby przebieg za trzy miesiące dostał tę samą bibliotekę, a nie tę, która akurat wyjdzie. Pakiet kontenerów nadal nazywa się DotNet.Testcontainers i to jest nazwa, której szukam na NuGecie.
Fabryka wygląda u mnie tak:
public sealed class UserFactory
{
private readonly Faker<User> _faker = new Faker<User>("pl")
.UseSeed(42)
.RuleFor(u => u.Id, f => f.Random.Guid())
.RuleFor(u => u.FirstName, f => f.Name.FirstName())
.RuleFor(u => u.LastName, f => f.Name.LastName())
.RuleFor(u => u.Email, (f, u) => f.Internet.Email(u.FirstName, u.LastName, "example.com"))
.RuleFor(u => u.CreditLimit, f => f.Finance.Amount(100m, 5000m))
.RuleFor(u => u.CreatedAt, f => f.Date.Past(2, new DateTime(2021, 9, 1)));
public User Build() => _faker.Generate();
public List<User> Build(int count) => _faker.Generate(count);
}Trzy decyzje są tu celowe.
UseSeed(42) to najważniejsza linijka całej klasy. Bez niej Bogus generuje za każdym razem inny zestaw, więc test, który wczoraj przeszedł, dziś potrafi trafić na imię z apostrofem albo na kwotę z trzema miejscami po przecinku i wybuchnąć. Z ustawionym ziarnem ten sam kod daje ten sam wynik na moim laptopie i na agencie. Kiedy chcę więcej wariantów, nie zdejmuję ziarna, tylko podaję inne i zapisuję je jawnie w nazwie testu.
Locale "pl" daje mi imiona, nazwiska i adresy, które wyglądają jak polskie. To nie jest kosmetyka. Zestaw wygenerowany domyślnym locale nie ma diakrytyków, a diakrytyki są dokładnie tym, co psuje kodowanie w eksporcie do CSV i szerokość kolumny na liście. Jeżeli aplikacja obsługuje kilka rynków, trzymam po jednej fabryce na locale.
Adresy lądują w domenie example.com, czyli w domenie zarezerwowanej do dokumentacji. Nic z tego nikomu nie wyśle maila, jeżeli konfiguracja środowiska testowego okaże się dziurawa.
Kiedy test potrzebuje jednego, konkretnego konta, na które asertuję po adresie, nie zostawiam tego losowi:
public static User SeededUser() => new Faker<User>("pl")
.UseSeed(42)
.RuleFor(u => u.Id, f => f.Random.Guid())
.RuleFor(u => u.Email, _ => "test.user+seed42@example.com")
.RuleFor(u => u.FirstName, f => f.Name.FirstName())
.RuleFor(u => u.LastName, f => f.Name.LastName())
.Generate();Adres jest brzydki i taki ma być. Ma w sobie plusa, żeby przy okazji sprawdzić, czy walidacja adresu go nie odrzuca, i ma w nazwie ziarno, żeby po logu z produkcyjnej pomyłki od razu było widać, że to konto z testów.
Nazw User1, User2, User3 nie używam już nigdzie. Nie dlatego, że są brzydkie, tylko dlatego, że nic nie mówią. Kiedy test wywala się na SeededUser albo UserBezZamowien, wiem z samej nazwy, czym ten rekord się różnił.
Alternatywą jest AutoFixture, u mnie w wersji 4.17.0 z kwietnia. Sięgam po nią wtedy, kiedy obiekt nie ma semantyki domenowej i naprawdę nie obchodzi mnie, co jest w polach, a chodzi tylko o to, żeby nie były puste. fixture.Create<AddressDto>() w jednej linijce zdejmuje mi trzydzieści linijek przypisań w DTO, którego test w ogóle nie ogląda. Tam, gdzie dane muszą wyglądać jak dane, wracam do Bogusa, bo AutoFixture wypełni mi Email losowym GUID-em i będzie miał rację, tylko ja z tego nic nie mam.
Jedno zdanie na temat, który wisi w powietrzu od czerwca: generator oparty o Completions to eksperyment przy biurku do kilkunastu rekordów oglądanych przez człowieka, a nie strategia na codzienny zestaw testów, i w tym wpisie nie zajmuje żadnej z czterech warstw.
Baza współdzielona: Respawn 4.0.0 i Checkpoint
Fabryka nie sprawdzi mapowania ani migracji. Do tego potrzebna jest prawdziwa baza, a razem z nią wraca pierwszy ból: stan zostaje po teście.
Przez lata robiłem to najbrutalniej, czyli drop i create całego schematu przed klasą testową. To działa i jest bardzo powolne. Migracje na średniej wielkości bazie potrafią chodzić kilkadziesiąt sekund, a robię to tylko po to, żeby usunąć kilkanaście wierszy.
Respawn odwraca ten pomysł. Zamiast odtwarzać schemat, zostawia go w spokoju i usuwa z niego dane, wyliczając wcześniej kolejność tabel na podstawie kluczy obcych. Na wrzesień 2021 aktualna linia to 4.0.0 i API kręci się wokół klasy Checkpoint.
public sealed class DatabaseFixture
{
private static readonly Checkpoint Checkpoint = new Checkpoint
{
SchemasToInclude = new[] { "dbo", "sales" },
TablesToIgnore = new[] { "Lookups", "Countries", "__EFMigrationsHistory" }
};
public string ConnectionString { get; }
public Task ResetAsync() => Checkpoint.Reset(ConnectionString);
}I użycie w teście xUnit, u mnie nadal w wersji 2.4.1:
public class OrderRepositoryTests : IClassFixture<DatabaseFixture>, IAsyncLifetime
{
private readonly DatabaseFixture _database;
private readonly UserFactory _users = new UserFactory();
public OrderRepositoryTests(DatabaseFixture database) => _database = database;
public Task InitializeAsync() => _database.ResetAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Nowy_uzytkownik_nie_ma_zamowien()
{
var user = _users.Build();
await _database.InsertAsync(user);
var orders = await _database.GetOrdersAsync(user.Id);
Assert.Empty(orders);
}
}Reset robię przed testem, a nie po nim. Różnica jest praktyczna: kiedy test się wywali, chcę móc zajrzeć do bazy i zobaczyć, co w niej zostało. Sprzątanie po sobie zabiera mi dowody z miejsca zdarzenia.
TablesToIgnore to lista, którą trzeba przemyśleć raz i potem tylko pilnować. Tabele słownikowe, czyli kraje, waluty, statusy, słowniki VAT, nie są danymi testu. Wjeżdżają migracją albo skryptem startowym i ich kasowanie oznacza, że po każdym resecie trzeba je wypełniać od nowa, a przy okazji sypią się klucze obce z tabel, które ich używają. Historia migracji Entity Framework jest tu z tego samego powodu: wyczyszczenie jej sprawia, że aplikacja uzna bazę za nietkniętą i zacznie migrować od zera.
Jedno ograniczenie trzeba znać, zanim się to wdroży. Respawn wylicza kolejność usuwania z relacji kluczy obcych w bazie. Jeżeli te relacje nie są zadeklarowane, bo ktoś kiedyś “tymczasowo” zdjął constraint na wdrożeniu, to biblioteka o nich nie wie i pierwszy reset kończy się błędem naruszenia integralności albo, gorzej, cichym sierotą w tabeli podrzędnej. Cykle w grafie kluczy też potrafią zaboleć. To nie jest wada narzędzia. To jest darmowy audyt schematu, tylko wykonany w niewygodnym momencie.
Baza jednorazowa: seed po StartAsync
Respawn zakłada, że baza gdzieś stoi. Kiedy nie chcę tego zakładać, podnoszę ją z poziomu testu. Opisywałem to w kwietniu przy okazji uruchamiania MSSQL przez Testcontainers i nie będę tu powtarzał podstaw samej biblioteki. Dopisuję jedną rzecz, której tamten wpis nie obejmował: co się dzieje z danymi po StartAsync.
Kontener startuje z pustą instancją. To znaczy, że po starcie muszę zrobić dwie rzeczy w ustalonej kolejności: postawić schemat i wsadzić dane startowe. Schemat to migracje aplikacji, a nie ręcznie pisany SQL w teście, bo inaczej test sprawdza schemat, którego nigdzie nie ma. Dane startowe to ta sama fabryka, którą już mam.
public sealed class SqlContainerFixture : IAsyncLifetime
{
private readonly MsSqlTestcontainer _container = new TestcontainersBuilder<MsSqlTestcontainer>()
.WithDatabase(new MsSqlTestcontainerConfiguration
{
Password = "yourStrong(!)Password123"
})
.Build();
public string ConnectionString => _container.ConnectionString;
public async Task InitializeAsync()
{
await _container.StartAsync();
await MigrationRunner.RunAsync(ConnectionString);
await SeedAsync();
}
private async Task SeedAsync()
{
var users = new UserFactory().Build(20);
await BulkInsert.UsersAsync(ConnectionString, users);
}
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}Pierwsze uruchomienie boli, bo obraz MSSQL trzeba pobrać, a serwer potrzebuje chwili, zanim zacznie przyjmować połączenia. Na rozgrzanym środowisku to kilkanaście sekund. To jest cena, którą płacę za pewność, że nikt przede mną nic w tej bazie nie zostawił.
Największa pokusa przy tym podejściu to podnieść jeden kontener na cały przebieg i współdzielić go między klasami testowymi, żeby zaoszczędzić te kilkanaście sekund. Robiłem tak i odradzam, jeżeli myśli się o równoległości. W momencie, w którym dwie klasy chodzą jednocześnie na tej samej instancji, wracamy dokładnie do pierwszego bólu z początku wpisu, tylko z dodatkowym kontenerem po drodze. Jeżeli już współdzielę instancję, to każda klasa dostaje swoją bazę wewnątrz niej albo swój schemat, a nie te same tabele.
Sam kontener nie ma zdania na temat tego, co ma być w rekordzie. Daje pustą, izolowaną bazę. Treść nadal pochodzi z fabryki i te dwie rzeczy się nie zastępują.
Bez bazy: WireMock.Net 1.4.20 w kodzie testu
Czwarta warstwa dotyczy danych, których w mojej bazie w ogóle nie ma, bo należą do cudzego serwisu. W maju podnosiłem WireMocka jako kontener w Compose, z mappingami w plikach JSON. Ta ścieżka nadal jest u mnie w użyciu dla środowiska, które stoi obok aplikacji. Dziś pokazuję wariant drugi, bliższy tematowi tego wpisu: bibliotekę WireMock.Net w wersji 1.4.20 z 6 sierpnia, uruchamianą wprost z kodu testu .NET.
public class CatalogClientTests : IAsyncLifetime
{
private WireMockServer _catalog;
public Task InitializeAsync()
{
_catalog = WireMockServer.Start();
_catalog
.Given(Request.Create()
.WithPath("/api/products/1001")
.UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBodyAsJson(new
{
id = 1001,
name = "Kubek termiczny 400 ml",
price = 59.9m,
currency = "PLN",
inStock = true
}));
_catalog
.Given(Request.Create()
.WithPath("/api/products/9999")
.UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(503)
.WithDelay(TimeSpan.FromSeconds(3)));
return Task.CompletedTask;
}
[Fact]
public async Task Niedostepny_katalog_nie_wywraca_koszyka()
{
var client = new CatalogClient(new Uri(_catalog.Urls[0]));
var result = await client.GetProductAsync(9999);
Assert.False(result.IsAvailable);
}
public Task DisposeAsync()
{
_catalog.Stop();
return Task.CompletedTask;
}
}Trzy rzeczy różnią ten wariant od majowego i to one są powodem, dla którego go tu wstawiam.
Dane mocka są w kodzie testu, więc kompilują się i refaktoryzują razem z resztą. To jest odpowiedź na trzeci ból z początku. W maju pilnowałem, żeby mappingi leżały w repozytorium i przechodziły review jak kod, ale plik JSON i tak nie kompiluje się z projektem, więc nikt nie dostaje ostrzeżenia, kiedy stub przestaje pasować do klienta. Stub w metodzie testowej widać w tym samym diffie, co zmianę klienta HTTP.
WireMockServer.Start() bez argumentów bierze wolny port i oddaje go w Urls. Nie wpisuję numeru portu na sztywno, bo to jest gwarantowana kolizja przy równoległych testach na jednym agencie. Adres wstrzykuję do klienta, co znowu wymaga, żeby ten adres był konfigurowalny, a nie zaszyty w kodzie produkcyjnym.
Scenariusz błędu jest tu tak samo tani jak scenariusz szczęśliwy. 503 z trzysekundowym opóźnieniem to pięć linijek, a na sandboxie partnera nie mam jak go wywołać na żądanie.
To nadal nie jest gwarancja zgodności z kontraktem i nadal trzymam poza tym zestawem wąską grupę testów, które uderzają w prawdziwy sandbox i sprawdzają wyłącznie kształt odpowiedzi.
Izolacja kontra prędkość
Cztery warstwy różnią się przede wszystkim tym, ile płacę za izolację. Warto to sobie ustawić w kolejności.
Najtańsza jest transakcja z rollbackiem: otwieram transakcję przed testem, robię swoje, cofam. To trwa milisekundy i jest kuszące. Ma jednak dwa ograniczenia, na które trafiłem w praktyce. Kod pod testem musi używać mojego połączenia, więc scenariusz, w którym aplikacja odpowiada po HTTP i sama sięga do bazy z innego połączenia, odpada. Drugie ograniczenie jest subtelniejsze: transakcja zmienia zachowanie tego, co testuję. Kod, który sam zarządza transakcjami albo liczy na to, że po commicie widać go z innej sesji, zachowa się inaczej niż na produkcji, a ja dostanę zielony test na sytuacji, która nigdy nie zachodzi.
Środkiem jest Respawn. Reset trwa ułamek sekundy zamiast kilkudziesięciu sekund migracji, działa niezależnie od tego, kto i skąd pisze do bazy, i nie zmienia zachowania kodu. Kosztuje tyle, że stan między testami znika naprawdę, więc nie da się na niczym pojechać na skróty.
Najdroższy i najbezpieczniejszy jest nowy kontener. Kilkanaście sekund startu za gwarancję, że nikt tu wcześniej nie był.
Mam co do tego hipotezę, której nie zdążyłem jeszcze porządnie zmierzyć, więc podaję ją jako hipotezę. Na CI z równoległymi jobami najbezpieczniejszy jest stan lokalny dla joba: własny kontener albo własny stub uruchomiony w tym samym procesie, co testy. Współdzielony zasób, nawet resetowany, staje się przy równoległości punktem, w którym joby wchodzą sobie w drogę, a im więcej agentów, tym częściej. Globalny dump odtwarzany raz na noc jest tego skrajną wersją: wszyscy patrzą na te same dane i nikt nie wie, kto je ostatnio ruszył. Miałem podobne wnioski przy siatce Selenium w kontenerach, gdzie współdzielona przeglądarka okazała się kosztowniejsza niż osobny węzeł na joba.
Warto tu wyprostować jedno nieporozumienie. Kiedy w lipcu pisałem o politykach retry z Polly, rozwiązywałem problem chwilowego wyścigu na odczycie: coś jeszcze nie zdążyło się zmaterializować, więc pytam ponownie po chwili. Retry nie naprawia złych danych. Jeżeli test przechodzi dopiero za trzecim razem, bo za trzecim razem trafia na inny stan bazy, to retry nie jest lekarstwem, tylko środkiem przeciwbólowym, który zabiera mi jedyny sygnał, że stan wycieka między testami. Rozróżnienie jest proste: retry na to, co jest wolne, izolacja na to, co jest brudne.
Podsumowanie: wybór warstwy zapisany w README testów
Wychodzi mi z tego podział, który od dziś stosuję domyślnie. Logika domenowa dostaje fabrykę z ziarnem, bez bazy. Zapytania, mapowanie i migracje dostają prawdziwą bazę z resetem przez Respawn, a jeżeli chcę mieć pewność, że startuję z pustki, to kontener podniesiony z testu. Wszystko, co należy do cudzego serwisu, dostaje stub HTTP z danymi wersjonowanymi razem z testem.
Najważniejsze jest jednak nie samo drzewko decyzyjne, tylko to, żeby ono gdzieś było zapisane. U mnie ląduje w README.md w katalogu z testami: która warstwa obowiązuje w którym projekcie, gdzie stoi fabryka, co jest na liście tabel pomijanych przez Respawn i dlaczego. Bez tego pliku po trzech miesiącach w repozytorium są cztery warstwy naraz, każda wprowadzona przez kogoś innego, i nikt nie wie, którą wybrać, pisząc nowy test. Kilkanaście zdań w README jest tańsze niż ta rozmowa powtarzana co sprint.
Dwie rzeczy zostawiam na koniec, bo są dla mnie regułami, a nie preferencjami. Kopia produkcji nie jest strategią danych testowych, dopóki anonimizacja nie jest osobnym, utrzymywanym projektem. Generator oparty o model językowy nie zastępuje seedu: daje kilkanaście ładnych rekordów do obejrzenia przez człowieka, a nie powtarzalny stan, na którym stoi asercja.
I tak jak przy każdym poprzednim narzędziu, warto sprawdzić, gdzie to się mieści w całym procesie testowym. Uporządkowane dane nie naprawiają procesu. Sprawiają tylko, że czerwony test wreszcie znaczy “aplikacja jest zepsuta”, a nie “ktoś przede mną coś tu zostawił”.

