Blazor Component Testing with bUnit: A Complete Tutorial

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.Services

Rendering 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 .razor test files for component-heavy tests — the syntax feels natural in Blazor projects
  • WaitForStateAsync is non-negotiable for async component initialization — don't skip it
  • AddCascadingValue and cascade wrapping both work for cascading parameter tests
  • bUnit's JSInterop mock records all calls — use VerifyInvoke to assert JS was called correctly
  • Typed RenderFragment<T> testing uses lambda parameters in Add()
  • Snapshot testing (stored HTML files) works for stable, complex components

Read more