In my Blazor projects, the business logic has been tested with xUnit for a long time, but the components themselves were not. If an @if showed the wrong button, I only saw it by opening the page. In this post, we add a bUnit test project to a Blazor application and cover the cases I run into most often.
bUnit renders a Blazor component in memory, without a browser. You can then look for elements in the produced HTML, click buttons and check the result. The examples use bUnit 2.11, xUnit and .NET 10.
The full code is available here: mongeon/code-examples · blazor-bunit-testing.
The test project
We start from a solution that already contains the Blazor project (here Arbitres.Web). We create an xUnit project next to it, reference the Blazor project and add the bunit package:
dotnet new xunit -o Arbitres.Web.Tests
dotnet add Arbitres.Web.Tests reference Arbitres.Web
dotnet add Arbitres.Web.Tests package bunit
That’s all for the setup. Tests can be written in C# or in .razor files. In this post, I use regular C# classes, which work with the Microsoft.NET.Sdk SDK created by the xUnit template. To write tests in .razor, you need to change the test project’s SDK to Microsoft.NET.Sdk.Razor.
Watch out: if you have used bUnit 1.x, several names changed in version 2. TestContext became BunitContext, RenderComponent<T>() became Render<T>() and AddTestAuthorization() became AddAuthorization(). The examples you find online often still use the old names.
The component under test
The first component displays a baseball game. If umpires are missing, it shows how many and a button to volunteer. It also has a button that copies the game’s link to the clipboard with JavaScript.
@* GameCard.razor *@
@inject IJSRuntime JS
<div class="game-card">
<h3>@Game.AwayTeam @@ @Game.HomeTeam</h3>
<p>@Game.StartTime.ToString("yyyy-MM-dd HH:mm")</p>
@if (Game.MissingUmpires > 0)
{
<span class="missing">Missing @Game.MissingUmpires umpire(s)</span>
<button class="volunteer" @onclick="() => OnVolunteer.InvokeAsync(Game.Id)">Volunteer</button>
}
<button class="copy-link" @onclick="CopyLinkAsync">Copy link</button>
</div>
@code {
[Parameter, EditorRequired]
public Game Game { get; set; } = default!;
[Parameter]
public EventCallback<int> OnVolunteer { get; set; }
private async Task CopyLinkAsync()
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", $"https://arbitres.ca/matchs/{Game.Id}");
}
}
The model is a simple record:
public record Game(int Id, string HomeTeam, string AwayTeam, DateTime StartTime, int RequiredUmpires, int AssignedUmpires)
{
public int MissingUmpires => RequiredUmpires - AssignedUmpires;
}
A first test with Render and MarkupMatches
The test class inherits from BunitContext. This gives access to Render<T>(), the services and bUnit’s JSInterop. BunitContext implements IDisposable, so xUnit takes care of cleaning it up after each test.
using Bunit;
namespace Arbitres.Web.Tests;
public class GameCardTests : BunitContext
{
private static readonly Game FullGame = new(1, "Expos", "Blue Jays", new DateTime(2026, 10, 3, 19, 0, 0), 2, 2);
private static readonly Game GameMissingOne = new(2, "Expos", "Blue Jays", new DateTime(2026, 10, 4, 13, 0, 0), 2, 1);
[Fact]
public void Shows_the_teams()
{
// Component parameters are passed with typed expressions
var cut = Render<GameCard>(parameters => parameters
.Add(p => p.Game, FullGame));
cut.Find("h3").MarkupMatches("<h3>Blue Jays @ Expos</h3>");
}
[Fact]
public void Full_game_does_not_show_the_button()
{
var cut = Render<GameCard>(parameters => parameters
.Add(p => p.Game, FullGame));
Assert.Empty(cut.FindAll("button.volunteer"));
}
[Fact]
public void Incomplete_game_shows_the_missing_count()
{
var cut = Render<GameCard>(parameters => parameters
.Add(p => p.Game, GameMissingOne));
cut.Find(".missing").MarkupMatches("<span class=\"missing\">Missing 1 umpire(s)</span>");
}
}
cut stands for component under test, the convention in the bUnit documentation. Find() takes a CSS selector and throws an exception if no element matches. To check that an element is absent, use FindAll() with Assert.Empty instead.
MarkupMatches() compares HTML semantically. Whitespace, attribute order and comments don’t count, so the test doesn’t break when you reformat the .razor file. I recommend using it rather than a string comparison on cut.Markup.
Simulating a click and checking an EventCallback
To test the “Volunteer” button, we pass a lambda to the OnVolunteer parameter and click the button:
[Fact]
public void Clicking_volunteer_sends_the_game_id()
{
int? volunteeredGameId = null;
var cut = Render<GameCard>(parameters => parameters
.Add(p => p.Game, GameMissingOne)
.Add(p => p.OnVolunteer, id => volunteeredGameId = id));
cut.Find("button.volunteer").Click();
Assert.Equal(2, volunteeredGameId);
}
Click() triggers the component’s @onclick handler, then bUnit renders it again. There are also Change(), Input(), Submit() and the other DOM events. If the component changes its own state on click, you can check the new HTML right after.
JavaScript calls in strict mode
The “Copy link” button calls navigator.clipboard.writeText. By default, bUnit’s JSInterop is in strict mode: any JavaScript call that has not been set up fails the test. If we click the button without setting anything up, we get this:
Bunit.JSRuntimeUnhandledInvocationException: bUnit's JSInterop has not been configured to handle the call:
InvokeVoidAsync("navigator.clipboard.writeText", "https://arbitres.ca/matchs/1")
Configure bUnit's JSInterop to handle the call with following:
SetupVoid("navigator.clipboard.writeText", "https://arbitres.ca/matchs/1")
The message gives the code to add directly. We set up the call before rendering, then check that it happened:
[Fact]
public void Copy_link_calls_the_clipboard()
{
// The expected call, with its arguments
JSInterop.SetupVoid("navigator.clipboard.writeText", "https://arbitres.ca/matchs/1");
var cut = Render<GameCard>(parameters => parameters
.Add(p => p.Game, FullGame));
cut.Find("button.copy-link").Click();
JSInterop.VerifyInvoke("navigator.clipboard.writeText");
}
For calls that return a value, use JSInterop.Setup<T>("function").SetResult(value). If a component makes a lot of JavaScript calls that don’t matter for the test, you can switch to loose mode with JSInterop.Mode = JSRuntimeMode.Loose. Personally, I keep strict mode, because it reports the JavaScript calls I had not planned for.
A component that loads its data
The second component displays the list of upcoming games. It injects an IGameService and loads the data in OnInitializedAsync. It also shows the logged-in user’s name with AuthorizeView, which we cover in the next section.
@* GameList.razor *@
@inject IGameService GameService
<AuthorizeView>
<Authorized>
<p class="welcome">Hello @context.User.Identity?.Name</p>
</Authorized>
<NotAuthorized>
<a class="login" href="authentication/login">Log in</a>
</NotAuthorized>
</AuthorizeView>
@if (games is null)
{
<p class="loading">Loading...</p>
}
else if (games.Count == 0)
{
<p class="empty">No upcoming games.</p>
}
else
{
@foreach (var game in games)
{
<GameCard Game="game" />
}
}
@code {
private IReadOnlyList<Game>? games;
protected override async Task OnInitializedAsync()
{
games = await GameService.GetUpcomingGamesAsync();
}
}
In the application, IGameService calls an API. In the tests, we register a fake service in Services, which is a regular IServiceCollection. If you forget, bUnit throws There is no registered service of type 'Arbitres.Web.IGameService' on render.
The fake service uses a TaskCompletionSource. This lets us decide when the call completes, and therefore test the “Loading…” state before the data arrives:
public class FakeGameService : IGameService
{
private readonly TaskCompletionSource<IReadOnlyList<Game>> tcs = new();
public Task<IReadOnlyList<Game>> GetUpcomingGamesAsync() => tcs.Task;
// Completes the call with the given games
public void Complete(params Game[] games) => tcs.SetResult(games);
}
The tests:
using Bunit;
using Bunit.TestDoubles;
using Microsoft.Extensions.DependencyInjection;
namespace Arbitres.Web.Tests;
public class GameListTests : BunitContext
{
private readonly FakeGameService gameService = new();
private readonly BunitAuthorizationContext auth;
public GameListTests()
{
Services.AddSingleton<IGameService>(gameService);
auth = AddAuthorization();
}
[Fact]
public void Shows_loading_during_the_call()
{
var cut = Render<GameList>();
cut.Find(".loading").MarkupMatches("<p class=\"loading\">Loading...</p>");
}
[Fact]
public void Shows_one_card_per_game()
{
var cut = Render<GameList>();
gameService.Complete(
new Game(1, "Expos", "Blue Jays", DateTime.Today, 2, 2),
new Game(2, "Capitales", "Aigles", DateTime.Today, 2, 0));
// Rendering happens after the task completes, so we wait for it
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindComponents<GameCard>().Count));
}
[Fact]
public void Shows_a_message_when_there_are_no_games()
{
var cut = Render<GameList>();
gameService.Complete();
cut.WaitForAssertion(() => cut.Find(".empty").MarkupMatches("<p class=\"empty\">No upcoming games.</p>"));
}
}
Render() returns after the first render, even if OnInitializedAsync has not finished. That’s why the first test sees “Loading…”. When the task completes, the component renders again on its own, and WaitForAssertion() reruns the assertion on each render until it passes or the one-second timeout expires. Without WaitForAssertion(), the test can check the HTML before the second render and fail intermittently.
FindComponents<GameCard>() returns the rendered child components. This lets you check the structure without depending on the HTML of GameCard, which is already tested on its own.
AuthorizeView in tests
The GameListTests constructor calls AddAuthorization(). Without this call, rendering fails because AuthorizeView needs the authorization services:
Cannot provide a value for property 'AuthorizationPolicyProvider' on type
'Microsoft.AspNetCore.Components.Authorization.AuthorizeView'.
There is no registered service of type 'Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider'.
AddAuthorization() registers fake authorization services and returns a BunitAuthorizationContext. By default, the user is not logged in. To simulate a logged-in user, call SetAuthorized() before rendering:
[Fact]
public void Visitor_sees_the_login_link()
{
var cut = Render<GameList>();
Assert.NotNull(cut.Find("a.login"));
}
[Fact]
public void Logged_in_user_sees_their_name()
{
auth.SetAuthorized("Gabriel");
var cut = Render<GameList>();
cut.Find(".welcome").MarkupMatches("<p class=\"welcome\">Hello Gabriel</p>");
}
The same object also has SetRoles(), SetPolicies() and SetClaims() to test an AuthorizeView Roles="Admin" or a specific policy. This is a case where bUnit is very useful: to check by hand that an admin button does not appear for a regular umpire, you have to log in with two different accounts.
What bUnit does not cover
bUnit does not run a browser. CSS is not applied, JavaScript is never executed (we only check that it is called) and navigation between pages is not real. For a button hidden by a CSS rule or a problem in the JavaScript code, you need an end-to-end test with a tool like Playwright. bUnit is for testing the display logic of components, and a few Playwright tests can cover the main user flows.
With dotnet watch test (see this post), bUnit tests run in a few seconds every time a .razor file changes.
Happy coding, and double-check the method names if you follow an example written for bUnit 1.x.
This post was written with AI assistance and edited by me.