bUnit(ビーユニット)は、Blazorコンポーネントをブラウザを起動せずにテストする.NET向けのライブラリです。コンポーネントをメモリ上でレンダリングし、出力されたHTML・クリックなどの操作・状態の変化をxUnitやNUnitの単体テストとして検証します。最新版は2026年10月25日公開の2.13.3です。2025年11月のv2でTestContextがBunitContextに、RenderComponentがRenderに変わったため、ネット上に多いv1の書き方は2.xでビルドが通りません。この記事のテストコードは、bUnit 2.13.3と.NET SDK 10.0.401のプロジェクトに置いてdotnet testが成功することを確認しています。
まとめ
- bUnitはBlazorのコンポーネント単体テスト用ライブラリ。テストランナーではないので、xUnit・NUnit・MSTest・TUnitのいずれかと組み合わせる。Microsoft公式ではなくコミュニティ製(MITライセンス・無料)。
- 入れるNuGetパッケージは
bunitの1つだけ。2.xは.NET 8以上が対象で、テストプロジェクトのSDKはMicrosoft.NET.Sdk.Razorにする(TUnitだけは例外でMicrosoft.NET.Sdkのまま)。 - 基本形は
BunitContextを継承し、Render<T>()で描画、Findで要素を取り、MarkupMatchesで比較する。 - v1のコードを2.xでビルドすると、
RenderComponentはエラーCS0619、SetParametersAndRenderはCS1061、FakeNavigationManagerはCS0246で止まる。TestContextだけは警告CS0618で通る。 - JSInteropは既定でStrictモード。設定していないJS呼び出しがあるとテストが例外で落ちる。
- CSSの見た目や実際のJS実行はbUnitでは確かめられない。そこはPlaywrightなどのE2Eテストで担う。
以下、テストプロジェクトの作成、よく書くテストの型、v1からの移行、E2Eとの分担の順に説明します。
bUnitの位置づけ:Blazorの単体テストを担うコミュニティ製ライブラリ
Blazorには、Microsoftが提供する公式のコンポーネントテスト用フレームワークがありません。Microsoft Learnの「Razorコンポーネントをテストする」は、単体テストの手段としてbUnitを紹介したうえで、bUnitはサードパーティ製でMicrosoftがサポート・保守するものではないと注記しています。リポジトリは.NET Foundation配下で、ライセンスはMITです。
bUnit自体はテストを実行しません。テストの発見・実行・結果の出力はxUnitなどの汎用フレームワークが受け持ち、bUnitはその上でコンポーネントの描画・操作・検証の機能を足します。そのため実行は通常の単体テストと同じくdotnet testやVisual Studioのテストエクスプローラーで行い、CIにもそのまま載ります。Azure DevOpsでの組み込み方はAzure Pipelinesの使い方|YAMLの書き方とCI/CDパイプライン作成手順が参考になります。
実ブラウザを使わないので速いのが利点です。本記事に掲載したテスト10件を含む検証用プロジェクト(11件)を、bUnit 2.13.3・.NET SDK 10.0.401のdotnet testで実行した記録では、テストの実行時間は合計約1秒でした。Blazor自体の採用判断はBlazor(ブレイザー)の将来性は?2026年の採用判断と.NET 10・11の動向で扱っています。
bUnitのインストールとテストプロジェクトの作成
bUnitはNuGetのbunitパッケージで配布されています。v1ではbunit.coreとbunit.webに分かれていましたが、v2でbunitに一本化され、旧2パッケージは1.x系のまま更新されません。
bunit.templateによるテストプロジェクトの新規作成
新しくテストプロジェクトを作るなら、bunit.templateを使うのが早道です。--frameworkにはxunit・xunitv3・nunit・mstestを指定でき、省略するとxUnitになります。
dotnet new install bunit.template::2.13.3
dotnet new bunit --framework xunitv3 -o BlazorApp.Tests
dotnet sln BlazorApp.sln add BlazorApp.Tests/BlazorApp.Tests.csproj
dotnet add BlazorApp.Tests/BlazorApp.Tests.csproj reference BlazorApp/BlazorApp.csproj
2.13.3のテンプレートで生成した.csprojは、TargetFrameworkがnet10.0、SDKがMicrosoft.NET.Sdk.Razorで、bunit 2.13.3とxunit.v3 3.2.2を参照していました。ImplicitUsingsは有効になっていないため、Taskを使うファイルにはusing System.Threading.Tasks;を自分で書く必要があります。書き忘れるとエラーCS0246でビルドが止まります。
既存テストプロジェクトへのbUnit追加
すでにxUnitやNUnitのプロジェクトがある場合は、パッケージを追加してSDKを差し替えます。公式の作成手順では、SDKをMicrosoft.NET.Sdk.Razorにし、ターゲットをnet8.0以上にするよう指示しています。Razor SDKにしないと、テスト側に置いた.razorファイルがコンパイルされません。
dotnet add package bunit --version 2.13.3
<!-- .csproj の先頭 -->
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
TUnitにはテンプレートが無く、Razor SDKとも組み合わせられないと公式手順に書かれています。TUnitではMicrosoft.NET.Sdkのまま、テストを.csファイルだけで書くことになります。.NET 6や7のプロジェクトでは2.xを入れられないので、1.x系の最終版1.40.0を使います。.NETのバージョン選びは.NET 10(LTS)とは?SDKの選び方と入手・サポート期限・C# 14と移行を解説を参照してください。
最初のテストを書く:Render・Find・MarkupMatches
以降の例は、テンプレートで作ったBlazorApp.Testsプロジェクトに置く前提です。テンプレートの.csprojにはBunit・Bunit.TestDoubles・Xunit・Microsoft.Extensions.DependencyInjectionのusingが登録済みなので、足りない分をGlobalUsings.csにまとめておきます。プロジェクト名を変えた場合は、各ファイルのnamespaceも合わせて変えてください。
// GlobalUsings.cs
global using System.Threading.Tasks;
global using Microsoft.AspNetCore.Components.Web;
global using Microsoft.JSInterop;
テンプレートに入っているCounterコンポーネント(ボタンを押すと数が1増える)を例にします。テストクラスはBunitContextを継承し、Render<Counter>()で描画した結果(テスト対象の意味でcutと呼ぶ慣習があります)から要素を探して操作します。
namespace BlazorApp.Tests;
public class CounterTests : BunitContext
{
[Fact]
public void ボタンを押すとカウントが1増える()
{
var cut = Render<Counter>();
cut.Find("button").Click();
cut.Find("p").MarkupMatches("<p>Current count: 1</p>");
}
}
FindはCSSセレクターで最初に一致した要素を返し、見つからなければ例外を投げます。MarkupMatchesは文字列の完全一致ではなく、HTMLとして意味が同じかを比べます。Counterの<p>の中には改行と空白が入っていますが、意味のない空白は無視されるので上のテストは通ります。Assert.Equalで文字列を比べると、この空白の違いで落ちます。
テストは.razorファイルにも書けます。期待するHTMLをRazorの記法で書けるので、マークアップが長いコンポーネントでは読みやすくなります。
@inherits BunitContext
@code {
[Fact]
public void 初期表示は0()
{
var cut = Render(@<Counter />);
cut.Find("p").MarkupMatches(@<p>Current count: 0</p>);
}
}
xUnit v3にもTestContextという同名のクラスがあり、v1のTestContextを継承するとどちらを指すのか曖昧になります。テンプレートのコメントにもこの衝突が書かれており、v2でBunitContextへ改称された理由の1つです。
よく書くテストの型:パラメータ・非同期・DI・JSInterop・認証
実際のコンポーネントは、親から値を受け取り、サービスからデータを取り、JavaScriptや認証状態に依存します。ここでは、それぞれの依存をテスト側で用意する書き方を示します。テスト対象は次の5つのコンポーネントと1つのインターフェースで、いずれもテストプロジェクトに置いて動作を確認しています。
// IWeatherService.cs
namespace BlazorApp.Tests;
public interface IWeatherService { Task<string> GetSummaryAsync(); }
@* Greeting.razor *@
<p class="greeting">こんにちは、@Name さん</p>
<button @onclick="() => OnSelected.InvokeAsync(Name)">選択</button>
@code {
[Parameter] public string Name { get; set; } = "";
[Parameter] public EventCallback<string> OnSelected { get; set; }
}
@* WeatherCard.razor *@
@inject IWeatherService Weather
@if (summary is null) { <p>読み込み中...</p> }
else { <p>今日の天気:@summary</p> }
@code {
string? summary;
protected override async Task OnInitializedAsync()
=> summary = await Weather.GetSummaryAsync();
}
@* FocusInput.razor *@
@inject IJSRuntime JS
<input @ref="input" />
@code {
ElementReference input;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender) await JS.InvokeVoidAsync("focusElement", input);
}
}
@* SecretPanel.razor *@
@using Microsoft.AspNetCore.Components.Authorization
<AuthorizeView>
<Authorized><p>ようこそ、@context.User.Identity?.Name さん</p></Authorized>
<NotAuthorized><p>ログインしてください</p></NotAuthorized>
</AuthorizeView>
@* OrderButton.razor *@
@inject NavigationManager Nav
<button @onclick='() => Nav.NavigateTo("/orders/42")'>注文詳細へ</button>
Renderによるパラメータとイベントコールバックの指定
Render<T>の引数にビルダーを渡し、Addでパラメータを指定します。EventCallbackもラムダ式でそのまま渡せます。描画後にパラメータを変えたいときは、v1のSetParametersAndRenderではなく、描画結果のRenderを呼びます(詳細は公式のパラメータの渡し方)。
public class GreetingTests : BunitContext
{
[Fact]
public void ボタン押下でコールバックに名前が渡る()
{
string? selected = null;
var cut = Render<Greeting>(parameters => parameters
.Add(p => p.Name, "佐藤")
.Add(p => p.OnSelected, (string name) => selected = name));
cut.Find("button").Click();
Assert.Equal("佐藤", selected);
}
[Fact]
public void パラメータを変えて再描画する()
{
var cut = Render<Greeting>(parameters => parameters.Add(p => p.Name, "佐藤"));
cut.Render(parameters => parameters.Add(p => p.Name, "鈴木"));
Assert.Contains("鈴木", cut.Find("p.greeting").TextContent);
}
}
DIによるサービスの差し替えと非同期描画の待機
BunitContextのServicesはBlazor本体と同じIServiceCollectionなので、@injectしているサービスをテスト用の実装に差し替えられます。外部APIを呼ぶサービスを固定値を返す実装にすれば、表示ロジックだけを検証できます。テスト用の偽実装の作り方はモックとは?スタブとの違い・テストダブル5分類と5言語の書き方を実行例で解説で整理しています。
public class WeatherCardTests : BunitContext
{
sealed class ControlledWeatherService : IWeatherService
{
public TaskCompletionSource<string> Completion { get; } = new();
public Task<string> GetSummaryAsync() => Completion.Task;
}
[Fact]
public void 取得後に天気を表示する()
{
var service = new ControlledWeatherService();
Services.AddSingleton<IWeatherService>(service);
var cut = Render<WeatherCard>();
cut.Find("p").MarkupMatches("<p>読み込み中...</p>");
service.Completion.SetResult("晴れ");
cut.WaitForAssertion(() =>
cut.Find("p").MarkupMatches("<p>今日の天気:晴れ</p>"));
}
}
OnInitializedAsyncでデータを取るコンポーネントは、待っているTaskが完了するまで「読み込み中」を描画します。Task.Delayで遅らせると、マシンの速さ次第で最初の検証より先に完了してしまうため、上の例ではTaskCompletionSourceで完了のタイミングをテスト側から決めています。WaitForAssertionは再描画のたびにアサーションを試し、通るまで待ちます。待つ上限は既定で1秒で、超えるとWaitForFailedExceptionで失敗します。CIのマシンが遅くて落ちる場合は、第2引数でTimeSpanを渡すか、静的プロパティBunitContext.DefaultWaitTimeoutを延ばします。HTTP通信そのものを差し替えたい場合、公式はRichardSzalay.MockHttpを使う方法を案内しています。
JSInteropの設定とStrictモードの挙動
bUnitにはIJSRuntimeの代わりになる実装が最初から組み込まれています。既定はStrictモードで、あらかじめ設定していないJS呼び出しを受けると例外を投げます。たとえばOnAfterRenderAsyncでfocusElementを呼ぶコンポーネントを何も設定せずに描画すると、JSRuntimeUnhandledInvocationExceptionでテストが落ちます。
public class FocusInputTests : BunitContext
{
[Fact]
public void 設定していないJS呼び出しは例外になる()
{
Assert.Throws<JSRuntimeUnhandledInvocationException>(() => Render<FocusInput>());
}
[Fact]
public void JS呼び出しを登録して呼ばれたことを確かめる()
{
JSInterop.SetupVoid("focusElement", _ => true).SetVoidResult();
Render<FocusInput>();
JSInterop.VerifyInvoke("focusElement");
}
[Fact]
public void Looseモードなら未設定の呼び出しを無視する()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Render<FocusInput>();
}
}
SetVoidResult()を付けないと模擬された呼び出しが完了せず、コンポーネントのawaitの先が実行されません。戻り値のある呼び出しはJSInterop.Setup<T>("関数名").SetResult(値)で返す値を決めます。Looseモードは未設定の呼び出しに既定値を返して先へ進むので、サードパーティのUI部品がJSを大量に呼ぶ場合は楽です。ただし、呼ぶべきJSを呼んでいない不具合も素通りします。自分で書いたJS連携はStrictのままVerifyInvokeで呼び出しを確かめる方が安全です。
AuthorizeViewを含むコンポーネントは、AddAuthorization()で認証の仕組みを登録してから描画します。何も設定しなければ未ログイン、SetAuthorized("ユーザー名")でログイン済み、SetRolesでロール付きの状態を作れます(公式の認証の説明)。
public class SecretPanelTests : BunitContext
{
[Fact]
public void ログイン済みならユーザー名を表示する()
{
var auth = AddAuthorization();
auth.SetAuthorized("tanaka");
var cut = Render<SecretPanel>();
cut.MarkupMatches("<p>ようこそ、tanaka さん</p>");
}
}
public class OrderButtonTests : BunitContext
{
[Fact]
public void ボタンを押すと注文詳細へ遷移する()
{
var nav = Services.GetRequiredService<BunitNavigationManager>();
var cut = Render<OrderButton>();
cut.Find("button").Click();
Assert.Equal("http://localhost/orders/42", nav.Uri);
}
}
ページ遷移を伴うボタンのテストでは、BunitNavigationManagerのUriで遷移先を確かめます。ベースURLはhttp://localhost/です。v1ではFakeNavigationManagerという名前でした。
v1のコードを2.xでビルドしたときのエラーと置き換え
bUnit 2.0.66(2025年11月11日公開)で、.NET 8未満の対象が外れ、.NET 10が追加され、APIが整理されました。Microsoft Learnのサンプルを含め、検索で見つかる解説の多くはv1のTestContextとRenderComponentのまま書かれています。そこで、v1の書き方のテストを2.13.3でビルドし、どこで止まるかを確かめました。
| v1の書き方 | 2.13.3でのビルド結果 | v2の書き方 |
|---|---|---|
: TestContext |
警告CS0618 | : BunitContext |
RenderComponent<T>() |
エラーCS0619 | Render<T>() |
cut.SetParametersAndRender(...) |
エラーCS1061 | cut.Render(...) |
FakeNavigationManager |
エラーCS0246 | BunitNavigationManager |
DisposeComponents() |
移行ガイドで改名 | await DisposeComponentsAsync() |
IRenderedFragment |
移行ガイドで削除 | IRenderedComponent<T> |
bunit.core・bunit.web |
1.x止まり | bunit |
上4行は実際にビルドした結果、下3行は公式の1.xから2.xへの移行ガイドの記載です。差が出るのは、旧APIの残し方が違うためです。v2.13.3のソースでは、TestContextはBunitContextを継承したクラスとして残り、[Obsolete]の第2引数がfalse(警告のみ)になっています。一方RenderComponentは[Obsolete]の第2引数がtrueで、呼ぶとコンパイルエラーになります。継承元だけBunitContextに直してRenderComponentを残すと、テストは1件も動きません。
移行では、パッケージ参照、対象の.NET、APIの順に確認します。まずbunit.core・bunit.webの参照を外してbunitを入れ、ターゲットを.NET 8以上にします。次にエラーCS0619・CS1061・CS0246が出る箇所を表のとおり置き換え、最後に警告CS0618を見ながらTestContextをBunitContextへ直します。MouseEventArgsの各項目を引数で並べるClick(detail: 2, ctrlKey: true)のような呼び方も廃止されたので、new MouseEventArgs { Detail = 2, CtrlKey = true }を渡す形に書き換えます。
2.0.66以降の更新は不具合の修正と機能追加が中心です。2.6.2で.NET 11に対応し、2.8.6ではWaitFor系のヘルパーに非同期のアサーションを渡せるオーバーロードが入りました。最新の2.13.3では、AngleSharp 1.8.6と組み合わせたときにFindがTypeLoadExceptionを投げる不具合が直っています。変更履歴はGitHubのCHANGELOGで確認できます。
bUnitとE2Eテスト(Playwright)の分担
bUnitはブラウザを使わないので、CSSが当たった見た目、実際のJavaScriptの動作、ブラウザごとの差は確かめられません。JSInteropも前の章のとおり呼び出しを模擬するだけです。Microsoft Learnは、コンポーネントがどれだけJSに依存するかで手段を分けるよう勧めています。
| コンポーネントの性質 | 向く手段 |
|---|---|
| JSを使わない | bUnit |
| フォーカスやアニメーション程度のJS呼び出し | bUnit(JSInteropを設定) |
| 大きなJSライブラリと単純な呼び出しで連携 | bUnit+JS側の単体テスト |
| JSによるDOM操作に動作が依存する | E2Eテスト |
| 模擬しにくい依存を持つ他社製部品に頼る | E2Eテスト |
おすすめの分け方は、条件分岐・入力チェック・状態遷移・権限による表示の出し分けをbUnitで細かく押さえ、E2Eはログインから注文完了までのような主要な画面遷移に絞ることです。bUnitのテストは1件あたりミリ秒単位で動きますが、実行時間や保守の負担は件数と処理内容によって増えます。E2Eは1件ごとにブラウザを動かすため、本数が増えたときのCIの遅れがbUnitよりはるかに大きくなります。
反対に、bUnitを主役にすべきでない場面もあります。画面の大半をサードパーティのUI部品が描いていて、そのJS呼び出しを1つずつ設定しないと描画すら通らない場合です。Looseモードで黙らせてもテストが保証する範囲はごくわずかなので、その画面はE2Eで確かめる方が費用に見合います。PlaywrightのE2EはPlaywright(プレイライト)とは?仕組み・できること・Seleniumとの違いを解説、E2Eの範囲の決め方はE2E(エンドツーエンド)テストのベストプラクティス|結合テストとの違いとツール選定で解説しています。既存のBlazorアプリにテストを後から足していく体制づくりは、一草の保守運用・内製化支援でも相談を受けています。
よくある質問
bUnitはMicrosoftの公式ツールですか?
公式ではありません。コミュニティが開発するライブラリで、Microsoft LearnもMicrosoftがサポート・保守するものではないと明記しています。ライセンスはMITで、商用プロジェクトでも無料で使えます。
bUnitのNuGetパッケージはどれを入れればよいですか?
bunitの1つだけです。v1時代のbunit.coreとbunit.webは2.xでbunitに統合され、1.x系のまま更新が止まっています。新しくプロジェクトを作るならbunit.templateのテンプレートを使うと、テストフレームワークの参照まで揃います。
bUnitはxUnit以外のテストフレームワークでも使えますか?
使えます。NUnit・MSTest・TUnitにも対応しています。テンプレートで選べるのはxunit・xunitv3・nunit・mstestの4つで、TUnitは手動で作成します。TUnitはRazor SDKと組み合わせられないため、テストは.csファイルで書きます。
「TestContext は旧形式です」という警告が出るのはなぜですか?
v2でTestContextがBunitContextに改称され、旧名は警告だけを出す[Obsolete]付きで残されているためです。xUnit v3にも同名のクラスがあり、名前の衝突を避ける目的もあります。継承元をBunitContextに変えれば警告は消えます。同時にRenderComponentもRenderへ直さないとビルドエラーになります。
Blazor ServerとBlazor WebAssemblyのどちらもテストできますか?
どちらもテストできます。bUnitの対象はコンポーネント単体で、ホスティングモデルに依存しません。サーバー側のサービスやHTTP通信は、DIで差し替えるかRichardSzalay.MockHttpで模擬して検証します。