Testing Blazor components using bUnit

 
 
  • Gérald Barré

bUnit is a testing framework for Blazor components. It lets you verify that a component renders the expected HTML and responds to user events.

bUnit runs in-memory and does not require a browser, so tests run in isolation and execute quickly. Because there is no browser, it cannot execute JavaScript code. As a result, you may not be able to fully test components that rely on JS interop. If your JavaScript is simple and does not significantly impact component behavior, you can mock the IJSRuntime interface, as shown later. For more complex scenarios, you will need end-to-end (E2E) testing with tools such as Selenium, Puppeteer, or Playwright as explained in the previous post.

#bUnit basics

Let's create a sample component to test, based on the Counter.razor component from the Blazor template:

Razor
<p>Current count: @currentCount</p>
<button @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount += Increment;
    }
}

Create a unit test project using xUnit with Visual Studio or the command line:

Shell
dotnet new xunit -o MyTestProject

Then add a project reference to the component project and add the NuGet packages bunit.web and bunit.xunit. The .csproj file should look like this:

csproj (MSBuild project file)
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="bunit.web" Version="1.0.0-beta-10" />
    <PackageReference Include="bunit.xunit" Version="1.0.0-beta-10" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
    <PackageReference Include="xunit" Version="2.4.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\MyProject\MyProject.csproj" />
  </ItemGroup>
</Project>

You can now create the first test:

C#
[Fact]
public void CounterComponentTest()
{
    // Arrange
    using var ctx = new TestContext();
    var component = ctx.RenderComponent<Counter>();

    // Act
    component.Find("button").Click();

    // Assert
    component.MarkupMatches(@"<p>Current count: 1</p><button>Click me</button>");
}

#Testing component parameters

Let's extend the previous component with a parameter.

Razor
<p>Current count: @currentCount</p>
<button @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    [Parameter]
    public int Increment { get; set; } = 1;

    private void IncrementCount()
    {
        currentCount += Increment;
    }
}

Set parameter values using the SetParametersAndRender method.

C#
[Fact]
public void CounterComponentTest()
{
    // Arrange
    using var ctx = new TestContext();
    var component = ctx.RenderComponent<Counter>();
    component.SetParametersAndRender(parameters => parameters.Add(p => p.Increment, 2));

    // Act
    var button = component.Find("button");
    button.Click();
    button.Click();

    // Assert: Count should be 4
    component.MarkupMatches(@"<p>Current count: 4</p><button>Click me</button>");
}

#Testing asynchronous events

When testing an asynchronous event handler, you must wait for it to complete before validating the result. bUnit provides WaitForAssertion for this purpose. It retries the assertion until it passes or a timeout is reached.

Let's update the component to use an asynchronous event handler:

Razor
<p>Current count: @currentCount</p>
<button @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    private async Task IncrementCount()
    {
        await Task.Delay(500);
        currentCount += 1;
    }
}

Update the test to use component.WaitForAssertion. The test will wait until the event handler completes.

C#
[Fact]
public void CounterComponentTest()
{
    // Arrange
    using var ctx = new TestContext();
    var component = ctx.RenderComponent<Counter>();

    // Act
    var btn = component.Find("button");
    btn.Click();

    // Assert
    component.WaitForAssertion(() => component.MarkupMatches(@"<p>Current count: 1</p><button>Click me</button>"), timeout: TimeSpan.FromSeconds(2));
}

#Mock services

If your component uses Dependency Injection to inject services, you can mock them in tests using the TestContext:

Razor
@inject IClock SystemTime

<p>@SystemTime.Now().ToString("HH:mm")</p>
C#
[Fact]
public void ClockComponentTest()
{
    // Arrange
    var clockFake = A.Fake<IClock>();
    A.CallTo(() => clockFake.Now()).Returns(new DateTimeOffset(2020, 09, 01, 21, 42, 0, TimeSpan.Zero));

    using var ctx = new TestContext();
    ctx.Services.AddSingleton(clockFake);

    // Act
    var component = ctx.RenderComponent<Clock>();

    // Assert
    component.WaitForAssertion(() => component.MarkupMatches(@"<p>21:42</p>"), timeout: TimeSpan.FromSeconds(2));
}

#Mock IJSRuntime

Although bUnit cannot execute JavaScript, you can mock the IJSRuntime interface. bUnit includes built-in support for mocking it, so no additional mock framework is needed.

Consider this component:

Razor
@inject IJSRuntime JSRuntime

<button @onclick="OnClick">Alert</button>

@code {
    private async Task OnClick()
    {
        await JSRuntime.InvokeVoidAsync("alert", "Sample message");
    }
}
C#
[Fact]
public void JSRuntimeComponentTest()
{
    // Arrange
    using var ctx = new TestContext();
    var mockJS = ctx.Services.AddMockJSRuntime(JSRuntimeMockMode.Strict);
    mockJS.SetupVoid("alert", "Sample message");
    // Can use mockJS.SetupVoid("alert", argumentsMatcher: _ => true); to match any parameter

    var component = ctx.RenderComponent<SampleJSRuntime>();

    // Act
    component.Find("button").Click();

    // Assert
    mockJS.VerifyInvoke("alert", calledTimes: 1);
}

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?