Wszystkie wpisy

xUnit i NUnit na .NET 6 - Fact, fixture, kolekcje
xUnit i NUnit na .NET 6 - Fact, fixture, kolekcje

English

xUnit i NUnit na .NET 6 - Fact, fixture, kolekcje

Styczeń 2022: organizacja testów C# na .NET 6. xUnit 2.4.1 [Fact] i collection fixtures vs NUnit 3.13.2 FixtureLifeCycle. Izolacja stanu przed równoległością.

.NET

Wstęp

W kwietniu odpalałem MSSQL z Testcontainers w projekcie założonym przez dotnet new xunit. W sierpniu refaktor page objectów siedział na NUnit i Selenium. W październiku pisałem o równoległych testach na CI. W podsumowaniu roku domknąłem 2021 tezą o izolacji, a na blogu widać, że przez rok skakałem między xUnit a NUnit bez spisania zasad. Styczeń jest dobrym momentem, żeby to nadrobić, bo SDK w moich projektach jest już na .NET 6 i szablony zakładane od zera lądują na net6.0.

Ten wpis nie jest o tym, który framework jest lepszy. Jest o jednym pytaniu, na które w obu przypadkach trzeba odpowiedzieć świadomie: gdzie żyje stan. Klasa testowa ma pola, fixture ma połączenie do bazy, kontener ma port. Jeżeli nie wiem, kiedy to powstaje i kiedy znika, to nie wiem też, czy dwa testy mogą pójść obok siebie. Reszta, czyli atrybuty, filtry i konfiguracja runnera, jest pochodną tej jednej decyzji.

Nie migruję tu Selenium i nie tnę czasu pipeline’u. Demo jest syntetyczne: dwa projekty testowe na tej samej aplikacji, jeden na xUnit, drugi na NUnit, obydwa z jedną klasą, która potrzebuje bazy, i jedną, która nie potrzebuje niczego. Wersje mam przypięte i zamierzam się ich trzymać przez cały wpis:

<PropertyGroup>
  <TargetFramework>net6.0</TargetFramework>
  <ImplicitUsings>enable</ImplicitUsings>
  <Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
  <PackageReference Include="xunit" Version="2.4.1" />
  <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
</ItemGroup>

Po stronie NUnit jest to NUnit w wersji 3.13.2 z 27 kwietnia zeszłego roku plus NUnit3TestAdapter 3.17.0, czyli dokładnie ten sam zestaw, na którym stał sierpniowy refaktor. Zmieniam target framework, nie bibliotekę testową.

Dwa modele instancji

Najważniejsza różnica między tymi frameworkami nie jest nazwą atrybutu, tylko cyklem życia klasy testowej.

xUnit tworzy nową instancję klasy dla każdego [Fact]. Konstruktor jest odpowiednikiem SetUp, a Dispose odpowiednikiem TearDown. Nie ma tu żadnego atrybutu do włączenia, bo tak działa framework od zawsze i nie da się tego wyłączyć:

public class CartTests : IDisposable
{
    private readonly List<string> _events = new();

    public CartTests() => _events.Add("ctor");

    [Fact]
    public void PierwszyTestWidziJednoZdarzenie() => Assert.Single(_events);

    [Fact]
    public void DrugiTestTezWidziJednoZdarzenie() => Assert.Single(_events);

    public void Dispose() => _events.Clear();
}

Obydwa testy przechodzą i przeszłyby nawet bez Dispose, bo _events z pierwszego testu w ogóle nie dożywa drugiego. To jest ta sama lista, ale nie ten sam obiekt.

NUnit domyślnie robi odwrotnie: jedna instancja klasy obsługuje wszystkie testy w fixture. Przepisany wprost odpowiednik powyższego wygląda tak i drugi test pada:

[TestFixture]
public class CartTests
{
    private readonly List<string> _events = new();

    [SetUp]
    public void SetUp() => _events.Add("setup");

    [Test]
    public void PierwszyTest() => Assert.That(_events, Has.Count.EqualTo(1));

    [Test]
    public void DrugiTest() => Assert.That(_events, Has.Count.EqualTo(1));
}

Drugi test widzi dwa wpisy, bo [SetUp] wykonał się dwa razy na tym samym obiekcie. Zwykle nie wykrywam tego tak szybko, bo w prawdziwym kodzie pole jest nadpisywane, a nie dopisywane, i przez rok nikt nie zauważa, że stan przecieka. Zauważa go dopiero pierwszy przebieg, w którym kolejność testów jest inna niż zwykle.

Od NUnit 3.13, wydanego 7 stycznia zeszłego roku, jest na to atrybut i to jego opisywałem przy page objectach:

[TestFixture]
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
public class CartTests : IDisposable

Z tym atrybutem NUnit zachowuje się jak xUnit: nowa instancja na każdy przypadek testowy, konstruktor przed testem, Dispose po teście. Wolę ustawić to raz dla całego assembly przez [assembly: FixtureLifeCycle(LifeCycle.InstancePerTestCase)] niż pamiętać o atrybucie w każdej nowej klasie.

Jest jeszcze jedna konsekwencja, o której łatwo zapomnieć przy przepisywaniu testów. W xUnit nie ma odpowiednika [SetUp] i nie ma go celowo: skoro konstruktor wykonuje się przed każdym testem, to drugi mechanizm byłby tym samym pod inną nazwą. Kiedy przenoszę klasę z NUnit, metoda oznaczona [SetUp] musi wylądować w konstruktorze albo zostać wywołana ręcznie, a [TearDown] trafia do Dispose. Automat tego nie zrobi i nie ma tu ostrzeżenia kompilatora, bo osierocona metoda SetUp jest po prostu zwykłą metodą, której nikt nie woła. Warto o tym pamiętać, bo test przechodzi wtedy z powodów, których nikt nie sprawdził.

I tu jest sedno: w xUnit izolacja instancji jest domyślna, a współdzielenie kontekstu wymaga jawnej deklaracji. W NUnit jest odwrotnie, współdzielenie jest domyślne, a izolacja wymaga atrybutu. Oba modele da się dowieźć do tego samego miejsca, ale kierunek, z którego się do niego dochodzi, jest przeciwny i dlatego przenoszenie nawyków między frameworkami boli.

Atrybuty, które naprawdę organizują suite

Kiedy cykl życia jest jasny, zostaje warstwa, o której zwykle myślimy jako o “składni”. W praktyce te atrybuty decydują o dwóch rzeczach: ile mam testów zamiast jednej pętli i czy da się je odfiltrować na CI.

Testy parametryzowane to w xUnit [Theory] z [InlineData] dla wartości stałych i [MemberData] dla wszystkiego, co trzeba policzyć:

[Theory]
[InlineData("", false)]
[InlineData("abc", false)]
[InlineData("uzytkownik@example.com", true)]
public void WalidacjaAdresu(string input, bool expected) =>
    Assert.Equal(expected, EmailValidator.IsValid(input));

public static IEnumerable<object[]> Rabaty() =>
    new[] { new object[] { 100m, 0.1m, 90m }, new object[] { 50m, 0m, 50m } };

[Theory]
[MemberData(nameof(Rabaty))]
public void ObliczanieRabatu(decimal cena, decimal rabat, decimal oczekiwana) =>
    Assert.Equal(oczekiwana, Pricing.Apply(cena, rabat));

W NUnit te same dwa przypadki to [TestCase] i [TestCaseSource]. Różnica praktyczna jest jedna i warto ją znać przed migracją: NUnit potrafi zwracać TestCaseData z nazwą i kategorią przypisaną do pojedynczego przypadku, a xUnit operuje na tablicach object[] i nazwę buduje z wartości argumentów. Kiedy dane wejściowe są długie, raport z xUnit robi się mniej czytelny i to jest argument za [MemberData] zwracającym typy z porządnym ToString(), a nie za surowymi stringami.

Kategoryzacja to w xUnit [Trait], w NUnit [Category]:

[Fact]
[Trait("Category", "Integration")]
public async Task ZapisZamowieniaTrafiaDoBazy() { }
[Test]
[Category("Integration")]
public async Task ZapisZamowieniaTrafiaDoBazy() { }

Jedno i drugie sprowadza się na wejściu do dotnet test --filter, odpowiednio --filter "Category=Integration" dla NUnit i tego samego wyrażenia dla traitu o kluczu Category w xUnit. Trzymam dokładnie trzy wartości: Unit, Integration, Ui. Widziałem projekty z piętnastoma kategoriami i kończy się to tak, że nikt nie wie, którą wpisać, więc nowe testy nie dostają żadnej. Kategoria, która jest tylko na połowie testów, nie nadaje się do filtrowania, bo nie wiem, co dostanę po jej wykluczeniu.

Warto też ustalić, czy kategoria jest właściwością testu, czy właściwością projektu. Moja odpowiedź na dziś brzmi: właściwością projektu. Testy jednostkowe i integracyjne trzymam w osobnych projektach csproj, bo wtedy podział jest wymuszony przez referencje, a nie przez pamięć autora. Atrybut zostaje jako drugie sito wewnątrz projektu integracyjnego, na przykład do wyłączenia garstki testów wymagających czegoś, czego na agencie nie ma.

xUnit: fixtures i kolekcje

Skoro instancja klasy żyje tyle co jeden test, to gdzie postawić bazę, której start trwa dziesięć sekund? Nie w konstruktorze klasy testowej, bo zapłacę dziesięć sekund za każdy [Fact]. Do tego służą fixture.

IClassFixture<T> to jeden obiekt na całą klasę testową. xUnit tworzy go raz, wstrzykuje przez konstruktor każdej instancji i sprząta po ostatnim teście w klasie:

public sealed class DatabaseFixture : IAsyncLifetime
{
    public string ConnectionString { get; private set; } = string.Empty;

    public async Task InitializeAsync()
    {
        ConnectionString = await TestDatabase.StartAsync();
    }

    public async Task DisposeAsync() => await TestDatabase.StopAsync();
}

IAsyncLifetime jest tu istotne, bo start bazy jest asynchroniczny, a konstruktor nie może być async. xUnit wywoła InitializeAsync przed pierwszym testem i DisposeAsync po ostatnim.

Kiedy tę samą bazę ma dzielić kilka klas, potrzebna jest kolekcja. Definicja składa się z trzech elementów i wszystkie trzy muszą leżeć w tym samym assembly co testy:

[CollectionDefinition("database")]
public sealed class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{
}

[Collection("database")]
public class OrderRepositoryTests
{
    private readonly DatabaseFixture _db;

    public OrderRepositoryTests(DatabaseFixture db) => _db = db;

    [Fact]
    public async Task ZapisZamowieniaZwiekszaLiczbeWierszy()
    {
        await using var repo = new OrderRepository(_db.ConnectionString);
        var before = await repo.CountAsync();

        await repo.AddAsync(new Order("PL-1", 100m));

        Assert.Equal(before + 1, await repo.CountAsync());
    }
}

Klasa DatabaseCollection jest pusta i taka ma być. To tylko miejsce, w którym nazwa kolekcji spotyka się z typem fixture. Sam [CollectionDefinition] bez ICollectionFixture też ma sens, bo grupuje klasy, ale niczego między nimi nie dzieli.

Najważniejsza konsekwencja jest taka, że kolekcja jest jednostką równoległości w xUnit. Testy wewnątrz jednej kolekcji idą po kolei, kolekcje względem siebie idą równolegle. Domyślnie każda klasa jest osobną kolekcją, więc wpisanie [Collection("database")] na czterech klasach nie tylko daje im wspólną bazę, ale też szereguje je względem siebie. To bywa dokładnie tym, czego chcę, bo cztery klasy piszące do jednej bazy i tak by sobie przeszkadzały. Trzeba jednak wiedzieć, że płacę za to czasem, a nie dostaję współdzielenia za darmo.

Wybór między IClassFixture a kolekcją sprowadzam do jednego pytania: czy koszt postawienia tego obiektu jest większy niż koszt szeregowego przebiegu klas, które go używają. Kontener z bazą jest wart kolekcji. Zbudowany raz obiekt konfiguracji albo wygenerowany token nie jest, bo tanio go odtworzyć i szkoda za to płacić utratą równoległości.

Alternatywą, którą widuję i której nie polecam, jest static z leniwą inicjalizacją. Działa, dopóki nie potrzebuję posprzątać po sobie, bo pole statyczne nie ma momentu, w którym runner mógłby wywołać na nim Dispose. Kontener zostaje wtedy na maszynie do następnego restartu Dockera.

NUnit: FixtureLifeCycle i Parallelizable

W NUnit odpowiednik collection fixture wygląda inaczej, bo framework nie wstrzykuje niczego do konstruktora. Wspólny kontekst dla wielu klas w jednej przestrzeni nazw daje [SetUpFixture]:

[SetUpFixture]
public class DatabaseSetup
{
    public static string ConnectionString { get; private set; } = string.Empty;

    [OneTimeSetUp]
    public async Task StartDatabase() => ConnectionString = await TestDatabase.StartAsync();

    [OneTimeTearDown]
    public async Task StopDatabase() => await TestDatabase.StopAsync();
}

Klasa oznaczona [SetUpFixture] odpala się raz dla wszystkich fixture w swojej przestrzeni nazw i podprzestrzeniach. Ma to jedną wadę wobec kolekcji z xUnit: dostęp do połączenia idzie przez pole statyczne, więc z sygnatury klasy testowej nie widać, że ona czegokolwiek potrzebuje. Kiedy w xUnit zapomnę [Collection], dostanę przy starcie jasny komunikat, że parametr konstruktora nie ma pasującego fixture. W NUnit zapomniany [SetUpFixture] daje pusty string w połowie testu i błąd połączenia trzy metody dalej.

Druga rzecz, o której trzeba pamiętać przy InstancePerTestCase: [OneTimeSetUp] i [OneTimeTearDown] w samej klasie testowej muszą być wtedy statyczne, bo nie ma już jednej instancji, na której NUnit mógłby je wywołać. Kompilator tego nie wyłapie, dostanę błąd dopiero w trakcie przebiegu.

Jest też różnica w tym, kiedy wspólny zasób znika. Kolekcja z xUnit sprząta po ostatnim teście, który jej używał, więc kontener żyje dokładnie tyle, ile trwa ta grupa klas. [SetUpFixture] żyje do końca przebiegu w danej przestrzeni nazw, niezależnie od tego, ile testów faktycznie zostało uruchomionych po filtrowaniu. Przy pełnym przebiegu nie robi to różnicy, przy dotnet test --filter na jednej klasie owszem, bo płacę za start bazy, której prawie nie używam.

Trzecia to sama równoległość. NUnit jej domyślnie nie włącza i robi się to atrybutem [Parallelizable] na poziomie assembly, klasy albo metody. Rozpisywałem to w październikowym wpisie o CI razem z YAML-em i nie będę tego powtarzał. Zwrócę uwagę tylko na kolejność: atrybut ma sens dopiero wtedy, gdy cykl życia jest już ustawiony na instancję per test i wiem, co siedzi w [SetUpFixture]. Odwrotna kolejność, czyli najpierw [Parallelizable], potem szukanie, dlaczego czerwone, kosztuje kilka dni.

Kontrast z postami z 2021

Trzy rzeczy z zeszłego roku wyglądają dziś inaczej i chcę je nazwać wprost, zamiast udawać, że napisałem je od razu dobrze.

W kwietniowym wpisie o Testcontainers kontener startował wewnątrz [Fact], w bloku await using. Na demo to jest właściwa forma, bo cały mechanizm widać w jednym ekranie kodu. W zestawie z dwudziestoma testami integracyjnymi to samo podejście oznacza dwadzieścia startów kontenera i dwadzieścia razy ten sam czas. Dziś taki kontener idzie do IAsyncLifetime na fixture i jest dzielony przez kolekcję. Biblioteka to nadal DotNet.Testcontainers z linii 1.5.

W sierpniu przy refaktorze page objectów pokazywałem FixtureLifeCycle przy IWebDriver. To był ten sam atrybut w innym kontekście: driver miał przestać być statyczny. Zasada jest identyczna, tylko obiekt droższy.

Zostaje pytanie, którego nie rozwiązuje żaden z tych atrybutów: co włożyć do współdzielonej bazy. Fixture mówi, kiedy baza wstaje, a nie jakie ma mieć dane. Pisałem o tym przy strategiach zarządzania danymi testowymi i przy współdzielonym fixture staje się to ostrzejsze, bo test, który zakłada pustą tabelę, przestaje działać w momencie, w którym sąsiad z tej samej kolekcji coś do niej dopisze.

Pułapki na .NET 6

Nowe SDK nie zmienia semantyki runnera i to jest chyba najważniejsze zdanie tej sekcji. dotnet new xunit na SDK 6 daje projekt na net6.0 z C# 10, ale cykl życia klasy testowej jest ten sam co w 2018 roku. Nie ma flagi, która zamieni xUnit w NUnit ani odwrotnie.

Kilka drobiazgów, na które sam wpadłem przy przenoszeniu projektów:

  • ImplicitUsings nie obejmuje namespace’ów bibliotek testowych. System.Collections.Generic zniknie z góry pliku, using Xunit; i using NUnit.Framework; zostają. Można je dołożyć samemu przez <Using Include="NUnit.Framework" /> w csproj i wtedy pliki testowe robią się rzeczywiście krótsze.
  • Statyczny HttpClient jest w kodzie produkcyjnym rekomendacją, a w testach pułapką. Sam obiekt jest bezpieczny wielowątkowo, ale DefaultRequestHeaders już nie: test ustawia nagłówek autoryzacji i zostawia go następnemu.
  • Baza bez resetu między testami zachowuje się poprawnie ze szeregowym przebiegiem i losowo z równoległym. Kolejność testów nie jest kontraktem żadnego z tych frameworków.
  • Sztywny port w fixture blokuje dwa projekty testowe uruchomione naraz na jednej maszynie. Kontener powinien dostać port od Dockera i oddać go przez fixture.

I rzecz, którą muszę powtórzyć, bo pokusa wraca co kilka miesięcy. Ponawianie nie jest izolacją. Polityki Polly opisywałem w lipcu i nadal ich używam, ale one leczą chwilową usterkę zewnętrznej usługi, a nie wyciek stanu między testami. Retry nałożone na test, który przegrał wyścig o wiersz w bazie, zamienia jedyny sygnał o problemie w zielony wynik po drugiej próbie.

Podsumowanie

Wybór runnera przestał być dla mnie kwestią gustu, ale nie stał się też kwestią rankingu.

  • xUnit 2.4.1 biorę tam, gdzie chcę domyślnej izolacji instancji i jawnego współdzielenia. Fixture wstrzykiwany przez konstruktor mówi wprost, czego klasa potrzebuje, a kolekcja jest jednocześnie jednostką współdzielenia i jednostką równoległości.
  • NUnit 3.13.2 zostaje tam, gdzie zespół ma już setki [Test] i [TestCase]. Wtedy pierwszą rzeczą, jaką robię, jest [assembly: FixtureLifeCycle(LifeCycle.InstancePerTestCase)], a dopiero drugą rozmowa o [Parallelizable].
  • Organizacja fixture jest warunkiem równoległości, a nie jej skutkiem. Można mieć izolowane testy bez równoległości, ale nie odwrotnie.

Czego tu świadomie nie ma: konfiguracji pipeline’u i liczby wątków. To jest osobny temat sprzed trzech miesięcy i mieszanie go z organizacją kodu kończy się tym, że optymalizuję czas przebiegu, zanim wiem, czy wynik jest wiarygodny. Nie ma tu też odpowiedzi na pytanie, czy te testy w ogóle badają właściwe rzeczy. Uporządkowany cykl życia fixture nie zastąpi rozmowy o całym procesie testowym, tylko sprawia, że przy tej rozmowie mam czym operować.