Pora na unit testy w Blazorze z bUnit

Gdy już napiszemy pierwsze komponenty, a nasza aplikacja zaczyna realizować pewne założenia biznesowe to tak jak w przypadku innych aplikacji warto jest zweryfikować czy na pewno działają prawidłowo oraz czy obsłużyliśmy skrajne przypadki.

Sam jestem wielkim fanem pisania testów i musze przyznać, ze była to jedna z tych rzeczy, które w Blazorze poznałem jako pierwsze. Do testów możemy użyć dowolnego toola, który wspiera pisanie ich w C#, ja wykorzystam xUnit. Potrzebujemy jeszcze bibliotekę, która pozwala na renderowanie komponentów blazorowych, wykorzystamy do tego bUnit, która jest obecnie najbardziej rozwinięta biblioteka do testowania w Blazorze. Pora na pierwszy test!

Pierwszy test

Zacznijmy od utworzenia projektu. Na pierwszy test wybrałem komponent Counter, który znajduje się w podstawowej aplikacji Blazor. Nazwałem ją BlazorApp, wiec projekt testowy powinien mieć nazwę zbliżona do BlazorAppTests. Po utworzeniu projektu należy się upewnić, ze jest on typu Microsoft.NET.Sdk.Razor, aby to zrobić wejdź do pliku csproj projektu i sprawdzić czy ma ustawione odpowiednie sdk. Należy jeszcze dodać bibliotekę bUnit oraz dodać referencje projektu BlazorApp do projektu testowego.

Teraz możemy utworzyć pierwszy test, nazwę go CounterTests.razor, zawartość pliku wygląda następująco:

@using BlazorApp.Pages
@using Xunit
@using Bunit

@inherits TestContext

@code {

    [Fact]
    public void Counter_Should_Increment()
    {
        var cut = Render(@<Counter />);

        cut.Find("button").Click();

        var status = cut.Find("p");
        status.MarkupMatches(@<p role="status">Current count: 1</p>);
    }
}

Wyjaśniło się czemu musieliśmy ustawić sdk na projekt Razor, nasze testy same są komponentami. Jest to jedno z podejść do tworzenia testów, dzięki niemu możemy mieszać tagi html oraz kod C#, co znacząco zwiększa czytelność. Oczywiście dalej możesz stworzyć test jako zwyczajna klasa, sam nie polecam tego podejścia.

Pierwsza rzeczą, która musieliśmy zrobić w naszym teście było dziedziczenie po TestContext. Jest to klasa z metodami do renderowania, komunikacji oraz weryfikacji komponentów. Testowy komponent Counter wyrenderowalismy przy pomocy Render(@<Counter />). W taki sposób możemy również podawać parametry komponentu, przykładowo gdybyśmy chcieli przekazać krok inkrementacji to moglibyśmy to zrobić poprzez Render(@<Counter Step=2 />). Dalej mamy metodę Find do której podajemy selektor po którym można odnaleźć tag. Do metody Find jako selektor mozemy przekazac id (#tagId), klasę (.tagClass) lub xpath. Akcje kliknięcia wywołujemy metoda Click, mamy jeszcze kilka innych metod do interakcji z interfejsem takich jak DoubleClick, Change, czy Select, wszystkie znajdziesz w dokumentacji. Pozostaje zweryfikowanie czy akcja kliknięcia w przycisk zwiększyła wartość countera. Możemy to sprawdzić przy pomocy metody MarkupMatches. Sprawdza ona czy podany tag html jest identyczny z tym, który znajduje się w obiekcie, który weryfikujemy.

Po uruchomieniu test przeszedł, czyli logika aplikacji działa zgodnie z założeniami. Teraz pora dobrać się do logiki komponentów, zobaczmy kilka bardziej zaawansowanych przykładów.

Dostęp do wnętrza komponentu

Po pobraniu elementu możemy uzyskać dostęp do jego wewnętrznej struktury html poprzez properte InnerHTML, weryfikacja zmiany w komponencie mogłaby wyglądać następująco:

Assert.Equal("Current count: 1", status.InnerHtml);

W prostszych przypadkach taki zapis może być nawet czytelniejszy i bardziej odporny na zmiany niż użycie MarkupMatches. Mimo wszystko zalecam używanie MarkupMatches i zostawienie InnerHTML do przypadków w których ciężko jest porównać cała zawartość, a wykorzystanie InnerHTML z Contains znacząco uprościłoby test.

Dostęp do komponentu możemy mieć nie tylko poprzez pobranie go metoda Find, która zwraca interfejs IElement. Innym sposobem jest użycie metody FindComponent, która zwróci obiekt komponentu. Najlepszym przykładem będzie test komponentu Index.razor, który sprawdza czy ankieta została wyświetlona. Wygląda on następująco:

@using BlazorApp.Pages
@using BlazorApp.Shared
@using Xunit
@using Bunit

@inherits TestContext

@code {
    [Fact]
    public void Index_Should_DisplaySurvey()
    {
        var cut = Render(@<Index />);
        
        var survey = cut.FindComponent<SurveyPrompt>();
        Assert.NotNull(survey);
    }
}

W ten sposób możemy nie tylko sprawdzić czy komponent został utworzony ale także dostać się do jego wnętrza. Załóżmy, ze w komponencie SurveyPrompt chcemy ustawić domyślna wartość, gdy nie zostanie nic przekazane. Oto implementacja:

[Parameter]
public string? Title { get; set; } = "Sample Survey";

Wykorzystując komponent pobrany przez metodę FindComponent możemy dostać się do property Instance, a następnie do parametru Title i sprawdzić czy jest on poprawnie ustawiony.

[Fact]
public void Survey_Should_DisplayDefaultTitle()
{
    var cut = Render(@<Index />);
        
    var survey = cut.FindComponent<SurveyPrompt>();
    Assert.Equal("Sample Survey", survey.Instance.Title);
}

W podobny sposób możemy wywoływać metody komponentu, co może być przydatne do testowania logiki, która będzie podpięta do bardziej złożonych akcji.

Wstrzykiwanie zależności do komponentów

Testowanie komponentów czasami wymaga wstrzyknięcia zależności. Właśnie taki przypadek mamy w komponencie FetchData, gdzie pobierane są wyniki prognozy pogody. Poprawiłem ten komponent poprzez dodanie interfejsu IWeatherForecastService, który wygląda następująco:

public interface IWeatherForecastService
{
    Task<WeatherForecast[]> GetAllAsync();
}

Możemy teraz napisać najprostszy test, który sprawdzi czy udało się wyrenderować komponent. Wyglądałby w ten sposób:

@using BlazorApp.Pages
@using Xunit
@using Bunit

@inherits TestContext

@code {

    [Fact]
    public void FetchData_Should_Render()
    {
        var cut = Render(@<FetchData/>);

        Assert.NotNull(cut);
    }

}

Uruchomienie takiego testu da wynik negatywny z następującym błędem

System.InvalidOperationException : Cannot provide a value for property 'WeatherForecastService' on type 'BlazorApp.Pages.FetchData'. There is no registered service of type 'BlazorApp.IWeatherForecastService'.

Błąd wynika z braku wstrzykniętej zależności IWeatherForecastService. Można go naprawić używając kolekcji Services z TestContext. Ja dodatkowo pokuszę się o użycie Moq i utworzenie mocka tego serwisu. Oto poprawiony kod testu:

[Fact]
public void FetchData_Should_Render()
{
    var weatherForecastServiceMock = new Mock<IWeatherForecastService>();
    Services.AddSingleton(weatherForecastServiceMock.Object);

    var cut = Render(@<FetchData/>);

    Assert.NotNull(cut);
}

Po dodaniu serwisu do kolekcji Services cały test kończy się sukcesem.

Dobre praktyki i inne przypadki

Na koniec chciałbym się jeszcze podzielić z Tobą praktykami, które w mojej opinii pozwolą na osiągniecie najlepszych efektów, oto kilka z nich:

  • Gdy wykorzystujesz metodę do odnalezienia komponentu lub tagu html to staraj się wykorzystywać jego unikalna nazwę, która może być zawarta w id lub class. Wyszukiwanie komponentów po xpath nie jest odporne na zmiany struktury komponentu i może powodować fałszywie negatywne wyniki w testach,
  • Jeżeli jest to możliwe to staraj się mockować zależności oraz inne komponenty dzięki temu masz pewność, ze weryfikujesz wyłącznie określony komponent i ewentualne zmiany bądź błędy w jego zależnościach nie będą miały wpływu na wynik testu,
  • Testy komponentów ogranicz do najważniejszych ścieżek takich jak happy path oraz najbardziej prawdopodobne błędy, pisanie testu do każdego możliwego scenariusza zajmie zdecydowanie za dużo czasu i da niewielkie korzyści,
  • Najczęściej wykorzystywane metody takie jak weryfikacja atrybutu czy setupowanie zależności warto przenieść do klasy bazowej, przydatne jest tez przeniesienie do konstruktora powtarzalnego kod z testów.

W moich projektach spotkałem się również z bardziej zaawansowanymi tematami o których także warto wspomnieć, są to:

  • Weryfikowanie rezultatu poprzez MarkupMatches może pominąć sprawdzanie różnic w tagu html, gdy dodamy do w nim diff:ignore, jest to przydatne, gdy nasze tagi maja automatycznie generowane atrybuty takie jak class,
  • W przypadku akcji asynchronicznych co do których nie mamy pewności czy zdążyły się już wyrenderowac możemy użyć WaitForElement lub WaitForAssertion, wtedy test będzie czekać na pojawienie się podanego elementu,
  • Weryfikacja nawigacji jest jednym z trudniejszych tematów, dlatego w bUnit powstał mock NavigationManager, który nosi nazwę FakeNavigationManager, jeżeli Twoja logika wykonuje przekierowania to możesz to sprawdzić w propercie History.

Pisanie testów w Blazorze jest w większości przypadków zadaniem prostym oraz intuicyjnym, jeżeli masz już doświadczenie w pisaniu testów to tworzenie ich dla komponentów w Blazorze nie powinno sprawić Ci większych problemów. Dzięki wykorzystaniu podejścia z tworzeniem testów w plikach Razor jest to jeszcze łatwiejsze, a po poznaniu klasy TestContext oraz jej metod takich jak Render, Find oraz MarkupMatches będziesz przygotowany na większość przypadków.

Aby zwiększyć skuteczność Twoich testów zachęcam skorzystania z mojego doświadczenia, czyli podejścia z jednoznacznym referowaniu do komponentów i tagów z których chcesz korzystać, unikaniu duplikacji kodu w testach oraz uważaniu, aby nie wpaść w spirale pisania testów do każdej możliwej zmiany bez względu na to czy przetestowanie jej może w przyszłości pozwolić na wykrycie defektu. Zachęcam również do zapoznania się z dokumentacja bUnit oraz na kanał YouTube Egil Hansen – twórcy bUnit.

Spotkałem się z rożnymi postawami wobec pisania testów, szczególnie dla frontendu. Mam nadzieje, ze Ty znajdujesz wartość w ich pisaniu. Jeżeli tak to proszę daj mi znać!

Jeżeli uznasz ten post za przydatny to proszę udostępnij go innym. Zapraszam również do mojego newslettera na którym regularnie udostępniam przedpremierowo artykuły, materiały wideo i wiele więcej.

Share via
Copy link
Powered by Social Snap