Blazor Testing Guide: bUnit, Playwright and xUnit
Blazor brings C# to the browser, but testing it requires a different mindset depending on what layer you're testing. Component logic and rendering belong to bUnit. End-to-end user flows belong to Playwright. And pure C# business logic can stay in xUnit without any UI framework at all. This guide covers the full testing stack for Blazor applications.
The Blazor Testing Stack
Understanding which tool to reach for saves hours of friction:
- xUnit + pure C#: Business logic, services, data models — no Blazor dependency
- bUnit: Component rendering, user interactions, component state, event handling
- Playwright: Full browser automation, page navigation, realistic user flows
Setting Up bUnit
bUnit is the de facto standard for Blazor component testing. It renders components in a test context without a browser:
<PackageReference Include="bunit" Version="1.29.7" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />Writing Your First bUnit Test
Given a simple counter component:
<!-- Counter.razor -->
@page "/counter"
<h1>Counter</h1>
<p>Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}The bUnit test:
using Bunit;
using Xunit;
public class CounterTest : TestContext
{
[Fact]
public void CounterStartsAtZero()
{
var cut = RenderComponent<Counter>();
cut.Find("p").MarkupMatches("<p>Current count: 0</p>");
}
[Fact]
public void ClickIncrements()
{
var cut = RenderComponent<Counter>();
cut.Find("button").Click();
cut.Find("p").MarkupMatches("<p>Current count: 1</p>");
}
[Fact]
public void MultipleClicksIncrement()
{
var cut = RenderComponent<Counter>();
cut.Find("button").Click();
cut.Find("button").Click();
cut.Find("button").Click();
cut.Find("p").MarkupMatches("<p>Current count: 3</p>");
}
}TestContext is the bUnit test base class that manages the component lifecycle. RenderComponent<T> renders the component and returns a IRenderedComponent<T> wrapper.
Testing Components with Parameters
<!-- Alert.razor -->
<div class="alert alert-@Type">
<strong>@Title</strong>: @Message
</div>
@code {
[Parameter] public string Type { get; set; } = "info";
[Parameter] public string Title { get; set; } = "";
[Parameter] public string Message { get; set; } = "";
}public class AlertTest : TestContext
{
[Fact]
public void RendersWithParameters()
{
var cut = RenderComponent<Alert>(parameters => parameters
.Add(p => p.Type, "danger")
.Add(p => p.Title, "Error")
.Add(p => p.Message, "Something went wrong"));
var div = cut.Find("div");
Assert.Contains("alert-danger", div.ClassName);
cut.Find("strong").MarkupMatches("<strong>Error</strong>");
}
[Fact]
public void DefaultTypeIsInfo()
{
var cut = RenderComponent<Alert>(parameters => parameters
.Add(p => p.Title, "Note")
.Add(p => p.Message, "Hello"));
Assert.Contains("alert-info", cut.Find("div").ClassName);
}
}Mocking Services in bUnit
When components inject services, register mocks in the test context:
<!-- WeatherDisplay.razor -->
@inject IWeatherService WeatherService
@if (forecast == null)
{
<p>Loading...</p>
}
else
{
<p>Temperature: @forecast.TemperatureC°C</p>
}
@code {
private WeatherForecast? forecast;
protected override async Task OnInitializedAsync()
{
forecast = await WeatherService.GetCurrentForecast();
}
}public class WeatherDisplayTest : TestContext
{
[Fact]
public async Task ShowsLoadingThenData()
{
var mockWeatherService = new Mock<IWeatherService>();
mockWeatherService
.Setup(s => s.GetCurrentForecast())
.ReturnsAsync(new WeatherForecast { TemperatureC = 22 });
Services.AddSingleton(mockWeatherService.Object);
var cut = RenderComponent<WeatherDisplay>();
// Check loading state
cut.Find("p").MarkupMatches("<p>Loading...</p>");
// Wait for async operation
await cut.WaitForStateAsync(() => cut.Find("p").TextContent.Contains("22"));
cut.Find("p").MarkupMatches("<p>Temperature: 22°C</p>");
}
[Fact]
public async Task ServiceCalledOnce()
{
var mockWeatherService = new Mock<IWeatherService>();
mockWeatherService
.Setup(s => s.GetCurrentForecast())
.ReturnsAsync(new WeatherForecast { TemperatureC = 15 });
Services.AddSingleton(mockWeatherService.Object);
var cut = RenderComponent<WeatherDisplay>();
await cut.WaitForStateAsync(() => !cut.Find("p").TextContent.Contains("Loading"));
mockWeatherService.Verify(s => s.GetCurrentForecast(), Times.Once);
}
}Testing Component Events and EventCallback
<!-- DeleteButton.razor -->
<button @onclick="OnDeleteConfirmed">Delete</button>
@code {
[Parameter] public EventCallback<string> OnDelete { get; set; }
[Parameter] public string ItemId { get; set; } = "";
private async Task OnDeleteConfirmed()
{
await OnDelete.InvokeAsync(ItemId);
}
}public class DeleteButtonTest : TestContext
{
[Fact]
public void ClickRaisesOnDeleteWithCorrectId()
{
string? receivedId = null;
var cut = RenderComponent<DeleteButton>(parameters => parameters
.Add(p => p.ItemId, "item-42")
.Add(p => p.OnDelete, (id) => { receivedId = id; }));
cut.Find("button").Click();
Assert.Equal("item-42", receivedId);
}
}Testing Forms with Validation
public class LoginFormTest : TestContext
{
[Fact]
public void ShowsValidationErrorForEmptyEmail()
{
var cut = RenderComponent<LoginForm>();
cut.Find("form").Submit();
var errorMessages = cut.FindAll(".validation-message");
Assert.Contains(errorMessages, e => e.TextContent.Contains("Email is required"));
}
[Fact]
public async Task SuccessfulLoginCallsService()
{
var mockAuthService = new Mock<IAuthService>();
mockAuthService
.Setup(s => s.LoginAsync("user@example.com", "password123"))
.ReturnsAsync(new AuthResult { Success = true });
Services.AddSingleton(mockAuthService.Object);
var cut = RenderComponent<LoginForm>();
cut.Find("#email").Change("user@example.com");
cut.Find("#password").Change("password123");
cut.Find("form").Submit();
await cut.WaitForStateAsync(() =>
!cut.Find("button[type=submit]").IsDisabled());
mockAuthService.Verify(
s => s.LoginAsync("user@example.com", "password123"),
Times.Once);
}
}End-to-End Testing with Playwright
Playwright tests run against a real browser — crucial for testing full page navigation, JavaScript interop, and realistic user flows:
[Collection("Playwright")]
public class LoginPagePlaywrightTest : IAsyncLifetime
{
private IPlaywright _playwright = null!;
private IBrowser _browser = null!;
private IPage _page = null!;
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync();
_page = await _browser.NewPageAsync();
}
[Fact]
public async Task LoginRedirectsToDashboard()
{
await _page.GotoAsync("https://localhost:5001/login");
await _page.FillAsync("#email", "user@example.com");
await _page.FillAsync("#password", "correctpassword");
await _page.ClickAsync("button[type=submit]");
await _page.WaitForURLAsync("**/dashboard");
Assert.Contains("/dashboard", _page.Url);
}
public async Task DisposeAsync()
{
await _browser.CloseAsync();
_playwright.Dispose();
}
}Hosting Blazor in Tests with WebApplicationFactory
For Playwright tests against a real Blazor Server instance:
public class BlazorPlaywrightFixture : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.AddSingleton<IWeatherService, FakeWeatherService>();
});
}
}Connecting Blazor Tests to Continuous Monitoring
bUnit tests verify component behavior in isolation. Playwright tests verify end-to-end flows. But continuous monitoring — ensuring your deployed Blazor application works for real users 24/7 — requires a behavioral monitoring layer.
HelpMeTest complements your Blazor test stack with plain-English test scenarios that run against your live application on a schedule. Catch regressions between deployments before users do, without writing and maintaining Playwright scripts for every workflow.
Summary
- bUnit is the right tool for component-level testing — rendering, parameters, events, service mocks
- Playwright handles end-to-end flows — navigation, real browser behavior, JavaScript interop
- xUnit alone for pure business logic that doesn't touch UI
WaitForStateAsynchandles async component updates — don't skip this for async operationsServices.AddSingleton()in the bUnitTestContextregisters test doubles for injected services- Component event tests should verify both the callback was invoked and the correct arguments were passed