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 на конкретном примере, а не только определением.
- Указывает последствия для производительности, тестируемости и поддержки.
- Различает документированное поведение текущего стека и устаревшие практики.