BlazorMiddleTechnical

Как тестировать компоненты Blazor с помощью bUnit?

bUnit рендерит Blazor-компоненты в памяти через TestContext, предоставляя API Find/Click/WaitForStateAsync для проверки HTML-вывода и событий; сервисы мокируются через встроенный IServiceCollection, JS Interop — через JSInterop.Setup().

Что такое bUnit

bUnit — библиотека для unit-тестирования Blazor-компонентов на C#. Она создаёт изолированный TestContext, рендерит компонент в памяти (без браузера), предоставляет API для поиска элементов, триггера событий и проверки HTML-вывода. Аналог Testing Library для React.

Установка

# В тестовый проект (xUnit или NUnit)
dotnet add package bunit
dotnet add package bunit.web

Базовый тест компонента

Допустим, есть компонент Counter.razor:

<p>Current count: @Count</p>
<button @onclick="Increment">Click me</button>

@code {
    [Parameter] public int InitialValue { get; set; } = 0;
    private int Count;

    protected override void OnInitialized() => Count = InitialValue;
    private void Increment() => Count++;
}

Тест с bUnit (xUnit):

using Bunit;
using Xunit;

public class CounterTests : TestContext
{
    [Fact]
    public void Counter_StartsAtInitialValue()
    {
        // Arrange + Act
        var cut = RenderComponent<Counter>(p =>
            p.Add(c => c.InitialValue, 5));

        // Assert
        cut.Find("p").TextContent.ShouldContain("Current count: 5");
    }

    [Fact]
    public void Counter_Increments_OnButtonClick()
    {
        var cut = RenderComponent<Counter>();

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

        cut.Find("p").TextContent.ShouldContain("Current count: 1");
    }
}

Мокирование сервисов через DI

bUnit наследует TestContext, который содержит Services — стандартный IServiceCollection. Моки регистрируются до рендера:

public class WeatherTests : TestContext
{
    [Fact]
    public async Task WeatherPage_ShowsForecast()
    {
        // Мокируем HttpClient через bUnit helper
        var forecasts = new[] { new WeatherForecast { Summary = "Sunny" } };

        Services.AddSingleton<IWeatherService>(
            Mock.Of<IWeatherService>(s =>
                s.GetForecastAsync() == Task.FromResult(forecasts.AsEnumerable())));

        var cut = RenderComponent<WeatherPage>();

        // Ждём завершения async OnInitializedAsync
        await cut.WaitForElementAsync("table");

        cut.FindAll("td")[0].TextContent.ShouldBe("Sunny");
    }
}

Тестирование EventCallback

[Fact]
public void Child_FiresOnSelected_WhenButtonClicked()
{
    Product? received = null;
    var product = new Product { Id = 1, Name = "Widget" };

    var cut = RenderComponent<ProductCard>(p =>
    {
        p.Add(c => c.Item, product);
        p.Add(c => c.OnSelected, (Product p) => received = p);
    });

    cut.Find("button.select-btn").Click();

    Assert.Equal(product, received);
}

Работа с CascadingValue

[Fact]
public void Component_UsesThemeFromCascading()
{
    var cut = RenderComponent<ThemedButton>(builder =>
        builder.AddCascadingValue(new Theme { Color = "gold" }));

    cut.Find("button").GetAttribute("style").ShouldContain("gold");
}

WaitForState и асинхронные компоненты

[Fact]
public async Task Page_ShowsData_AfterAsync()
{
    var cut = RenderComponent<DataPage>();

    // Ждём пока не появится элемент или не изменится состояние
    await cut.WaitForStateAsync(() =>
        cut.FindAll(".data-row").Count > 0,
        timeout: TimeSpan.FromSeconds(2));

    cut.FindAll(".data-row").Count.ShouldBeGreaterThan(0);
}

Подводные камни

  • bUnit рендерит в памяти — JS Interop по умолчанию бросает исключение. Нужно зарегистрировать JSInterop.Setup<T>() или использовать JSInterop.SetupVoid() для void-вызовов.
  • WaitForStateAsync и WaitForElementAsync имеют дефолтный таймаут 1 секунда — в медленных CI-окружениях лучше явно указывать больший.
  • Snapshot-тестирование через cut.MarkupMatches() чувствительно к пробелам и атрибутам — используйте MarkupMatches с RegexMatcher или Find/FindAll для точечных проверок.
  • Компоненты с @rendermode (Interactive Server/WASM) в Blazor Web App (.NET 8) не поддерживаются bUnit напрямую — тестируются без render mode аннотации.
  • Если компонент реализует IDisposable, TestContext.DisposeComponents() нужно вызывать явно в Dispose() теста, иначе возможны утечки в параллельных тестах.
  • После Click() Blazor синхронно обрабатывает event — но если handler возвращает Task, нужно await cut.InvokeAsync(() => cut.Find(...).Click()) для корректного завершения.
  • bUnit не эмулирует CSS — тестировать видимость через display:none нельзя, только через @if условия в разметке.

Common mistakes

  • Путать component testing с bUnit и Blazor test context с похожим механизмом из другой версии или платформы.
  • Игнорировать runtime-границы Blazor: lifecycle, DI scope, SQL translation, UI thread или platform API.
  • Не обсуждать null/empty/error cases и поведение под нагрузкой.

What the interviewer is testing

  • Кандидат объясняет component testing с bUnit и Blazor test context на конкретном примере, а не только определением.
  • Указывает последствия для производительности, тестируемости и поддержки.
  • Различает документированное поведение текущего стека и устаревшие практики.

Sources

Related topics