Blazor Component Testing with bUnit: A Complete Tutorial
bUnit is the most capable tool available for Blazor component testing. While the basics of rendering and clicking buttons are well-documented, production Blazor applications involve cascading parameters, render fragments, complex state machines, and JavaScript interop. This tutorial covers all of it.
Project Setup for bUnit
Create a dedicated test project referencing your Blazor app:
<!-- MyApp.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../MyApp/MyApp.csproj" />
<PackageReference Include="bunit" Version="1.29.7" />
<PackageReference Include="Moq" Version="4.20.72" />
<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" />
</ItemGroup>
</Project>Add a shared _Imports.razor file to your test project to avoid repetitive using directives:
@using Bunit
@using Xunit
@using Moq
@using Microsoft.Extensions.DependencyInjection
@using MyApp.Components
@using MyApp.ServicesRendering and DOM Queries
bUnit's query API mirrors the browser DOM:
public class ProductCardTest : TestContext
{
[Fact]
public void RendersProductName()
{
var product = new Product { Id = "p-1", Name = "Widget Pro", Price = 49.99m };
var cut = RenderComponent<ProductCard>(p => p.Add(x => x.Product, product));
// Single element
var nameEl = cut.Find(".product-name");
Assert.Equal("Widget Pro", nameEl.TextContent);
// Multiple elements
var badges = cut.FindAll(".badge");
Assert.True(badges.Count > 0);
// CSS selector with contains
cut.Find("[data-testid='price']").MarkupMatches(
"<span data-testid=\"price\">$49.99</span>"
);
}
[Fact]
public void DoesNotRenderDiscountWhenAbsent()
{
var product = new Product { Id = "p-2", Name = "Basic Widget", Price = 9.99m };
var cut = RenderComponent<ProductCard>(p => p.Add(x => x.Product, product));
Assert.Throws<ElementNotFoundException>(() => cut.Find(".discount-badge"));
}
}Snapshot Testing
bUnit supports snapshot testing — comparing rendered output against a saved baseline:
public class UserAvatarSnapshotTest : TestContext
{
[Fact]
public void MatchesSnapshot()
{
var cut = RenderComponent<UserAvatar>(p => p
.Add(x => x.UserName, "Alice Smith")
.Add(x => x.AvatarUrl, "https://example.com/alice.jpg")
.Add(x => x.Size, AvatarSize.Medium));
// On first run, creates the snapshot file
// On subsequent runs, compares against it
cut.MarkupMatches(cut.Markup); // placeholder — replace with stored expected markup
}
}For a more robust snapshot approach, store expected HTML in .html files and load them in tests:
[Fact]
public void MatchesStoredSnapshot()
{
var cut = RenderComponent<UserAvatar>(/* params */);
string expected = File.ReadAllText("Snapshots/UserAvatar.html");
cut.MarkupMatches(expected);
}Testing Cascading Parameters
Cascading parameters require setting up the cascade in the render tree:
<!-- ThemeProvider.razor -->
<CascadingValue Value="@Theme" Name="Theme">
@ChildContent
</CascadingValue>
@code {
[Parameter] public AppTheme Theme { get; set; } = AppTheme.Light;
[Parameter] public RenderFragment? ChildContent { get; set; }
}
<!-- ThemedButton.razor -->
@code {
[CascadingParameter(Name = "Theme")]
public AppTheme Theme { get; set; }
}public class ThemedButtonTest : TestContext
{
[Fact]
public void UsesDarkThemeWhenCascaded()
{
var cut = RenderComponent<ThemedButton>(p => p
.AddCascadingValue("Theme", AppTheme.Dark));
var button = cut.Find("button");
Assert.Contains("btn-dark", button.ClassName);
}
[Fact]
public void WrapInThemeProviderComponent()
{
// Alternative: wrap in the provider component
var cut = Render(@<ThemeProvider Theme="AppTheme.Dark">
<ThemedButton Label="Click" />
</ThemeProvider>);
Assert.Contains("btn-dark", cut.Find("button").ClassName);
}
}Testing RenderFragment Parameters
Components with RenderFragment child content:
<!-- Card.razor -->
<div class="card">
<div class="card-header">@Header</div>
<div class="card-body">@Body</div>
</div>
@code {
[Parameter] public RenderFragment? Header { get; set; }
[Parameter] public RenderFragment? Body { get; set; }
}public class CardTest : TestContext
{
[Fact]
public void RendersChildContent()
{
var cut = RenderComponent<Card>(p => p
.Add(x => x.Header, @<h2>My Card</h2>)
.Add(x => x.Body, @<p>Card content here</p>));
cut.Find(".card-header h2").MarkupMatches("<h2>My Card</h2>");
cut.Find(".card-body p").MarkupMatches("<p>Card content here</p>");
}
[Fact]
public void RenderFragmentWithTypedContent()
{
var items = new List<string> { "Apple", "Banana", "Cherry" };
// RenderFragment<T> — typed child content
var cut = RenderComponent<ItemList<string>>(p => p
.Add(x => x.Items, items)
.Add(x => x.ItemTemplate, item => @<li>@item</li>));
var listItems = cut.FindAll("li");
Assert.Equal(3, listItems.Count);
Assert.Equal("Apple", listItems[0].TextContent);
}
}Testing JavaScript Interop
Components that call JavaScript need the IJSRuntime mock:
public class ClipboardButtonTest : TestContext
{
[Fact]
public async Task CopiesTextToClipboard()
{
var jsRuntime = Services.GetRequiredService<IJSRuntime>();
// bUnit provides a JSInterop mock by default
JSInterop.Setup<string>("navigator.clipboard.writeText", "copy-me")
.SetResult(string.Empty);
var cut = RenderComponent<ClipboardButton>(p => p
.Add(x => x.TextToCopy, "copy-me"));
await cut.Find("button").ClickAsync(new MouseEventArgs());
// Verify JS was called
JSInterop.VerifyInvoke("navigator.clipboard.writeText")
.Arguments[0].As<string>().Equals("copy-me");
}
[Fact]
public void MocksWindowAlert()
{
JSInterop.SetupVoid("window.alert", "Are you sure?");
var cut = RenderComponent<ConfirmDialog>(p => p
.Add(x => x.Message, "Are you sure?"));
cut.Find("button").Click();
JSInterop.VerifyInvoke("window.alert");
}
}Testing Component Lifecycle
Verify behavior during OnInitialized, OnParametersSet, and OnAfterRender:
public class DataLoaderTest : TestContext
{
[Fact]
public async Task LoadsDataOnInitialization()
{
var mockDataService = new Mock<IDataService>();
mockDataService
.Setup(s => s.GetItemsAsync())
.ReturnsAsync(new List<Item> { new Item("1", "First"), new Item("2", "Second") });
Services.AddSingleton(mockDataService.Object);
var cut = RenderComponent<DataLoader>();
// Wait for async initialization
await cut.WaitForStateAsync(
() => cut.FindAll(".item").Count == 2,
timeout: TimeSpan.FromSeconds(3)
);
var items = cut.FindAll(".item");
Assert.Equal("First", items[0].TextContent.Trim());
Assert.Equal("Second", items[1].TextContent.Trim());
}
[Fact]
public async Task ShowsErrorOnServiceFailure()
{
var mockDataService = new Mock<IDataService>();
mockDataService
.Setup(s => s.GetItemsAsync())
.ThrowsAsync(new HttpRequestException("Service unavailable"));
Services.AddSingleton(mockDataService.Object);
var cut = RenderComponent<DataLoader>();
await cut.WaitForStateAsync(
() => cut.Find("[role=alert]") != null,
timeout: TimeSpan.FromSeconds(3)
);
Assert.Contains("Service unavailable", cut.Find("[role=alert]").TextContent);
}
}Testing State Containers
Blazor apps often use a shared state pattern. Test state changes through the component:
public class ShoppingCartTest : TestContext
{
[Fact]
public void AddingItemUpdatesCount()
{
var cartState = new CartState();
Services.AddSingleton(cartState);
Services.AddSingleton<ICartService>(new CartService(cartState));
var cut = RenderComponent<CartSummary>();
Assert.Equal("0 items", cut.Find(".cart-count").TextContent);
// Add item through state
cartState.AddItem(new CartItem("product-1", "Widget", 1, 9.99m));
cut.Render(); // Force re-render
Assert.Equal("1 items", cut.Find(".cart-count").TextContent);
}
}Writing Tests in Razor Syntax
bUnit supports writing tests as .razor files, which can be more natural for component-heavy tests:
<!-- CounterTest.razor -->
@inherits TestContext
@code {
[Fact]
public void ClickButtonIncrementsCounter()
{
// Render using Razor syntax
var cut = Render(@<Counter />);
cut.Find("button").Click();
cut.Find("p").MarkupMatches("<p>Current count: 1</p>");
}
}Integrating bUnit with CI/CD
bUnit tests run as standard xUnit tests — no special CI configuration needed:
# Run all tests
dotnet <span class="hljs-built_in">test
<span class="hljs-comment"># Run with detailed output
dotnet <span class="hljs-built_in">test --logger <span class="hljs-string">"console;verbosity=detailed"
<span class="hljs-comment"># Run specific test class
dotnet <span class="hljs-built_in">test --filter <span class="hljs-string">"FullyQualifiedName~CounterTest"
<span class="hljs-comment"># Run with coverage
dotnet <span class="hljs-built_in">test --collect:<span class="hljs-string">"XPlat Code Coverage"Beyond bUnit: Behavioral Testing
bUnit verifies what components render. For verifying what your application does — that users can complete a purchase, that a form submission reaches the backend — you need behavioral tests.
HelpMeTest provides plain-English test scenarios for your deployed Blazor application. Write tests like "Go to the products page, add the first item to the cart, complete checkout" without maintaining Playwright scripts. It monitors your application continuously and alerts you when behavior changes.
Summary
- Use
.razortest files for component-heavy tests — the syntax feels natural in Blazor projects WaitForStateAsyncis non-negotiable for async component initialization — don't skip itAddCascadingValueand cascade wrapping both work for cascading parameter tests- bUnit's
JSInteropmock records all calls — useVerifyInvoketo assert JS was called correctly - Typed
RenderFragment<T>testing uses lambda parameters inAdd() - Snapshot testing (stored HTML files) works for stable, complex components