Testing Blazor components using bUnit

  • Gérald Barré

bUnit is a framework to test Blazor components. It allows validating that a component renders the expected HTML and reacts to events. bUnit runs in-memory and doesn't require a browser. This means tests run in isolation and are fast. Because there is no browser, it cannot execute JavaScript code. So, you may not be able to test correctly components that rely on JS interop. If your JS is very simple and does impact the behavior of the component it is possible to mock the IJSRuntime interface as we'll see later. For more complex scenarios, you'll need to do end-to-end (E2E) testing with solutions such as Selenium, Puppeteer, or Playwright as explained in the previous post.

#bUnit basics

Let's create a component to test. It is based on the Counter.razor component from the template:

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

@code {
    private int currentCount = 0;

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

Now you can create a unit test project that uses xUnit using Visual Studio or the command line:

dotnet new xunit -o MyTestProject

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

<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:

[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 improve the previous sample with a parameter.

<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;
    }
}

You can set the parameter values using the SetParametersAndRender method.

[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

If you have an asynchronous event handler, you have to wait for the event to be complete before validating the result. bUnit provides a way to do that using WaitForAssertion. This method retries the assertion until it passes.

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

<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;
    }
}

Now, we can update the test to use component.WaitForAssertion. This way the test will wait until the event handler is completed.

[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 (DI) to inject services, you can mock them in the tests using the TestContext:

@inject IClock SystemTime

<p>@SystemTime.Now().ToString("HH:mm")</p>
[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

Even if bUnit doesn't allow to run JS code, you can mock the IJSRuntime interface. bUnit provides everything needed to mock this interface, so you don't need to use a mock framework.

Let's consider this component:

@inject IJSRuntime JSRuntime

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

@code {
    private async Task OnClick()
    {
        await JSRuntime.InvokeVoidAsync("alert", "Sample message");
    }
}
[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?Buy Me A Coffee