mcpField
D
dotnet-blazor-testing

dotnet-blazor-testing

github.com/wshaddixテスト・品質8676 回インストール35 週間前に更新
リポジトリ

dotnet-blazor-testing について

.NET 8 以降の Blazor コンポーネントを bUnit でテストするスキルです。ブラウザーを使わずインメモリでレンダリングし、イベント、依存サービス、JavaScript 相互運用、非同期ライフサイクルを検証します。

主な機能

  1. 01インメモリでのコンポーネント描画
  2. 02マークアップと出力のアサーション
  3. 03イベントとフォーム入力のテスト
  4. 04カスケードパラメーターの設定
  5. 05JavaScript 相互運用のモック
  6. 06非同期ライフサイクルのテスト

活用シーン

  1. 01コンポーネントの描画マークアップ確認
  2. 02クリックやフォーム操作のテスト
  3. 03カスケード値を使うコンポーネントの検証
  4. 04読み込み中とエラー状態のテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-blazor-testing

インストール後、Claude Code で/dotnet-blazor-testingを入力して読み込まれたか確認します。

詳細

dotnet-blazor-testing

Blazor コンポーネントの bUnit テストです。コンポーネントのレンダリングとマークアップのアサーション、イベント処理、カスケードパラメーターとカスケード値、JavaScript 相互運用のモック、非同期コンポーネントのライフサイクルテストを扱います。bUnit はブラウザーを使わずにコンポーネントを実行できる、メモリ内の Blazor レンダラーを提供します。

バージョンの前提条件: .NET 8.0 以降を基準とし、bUnit 1.x(安定版)を使用します。例では最新の bUnit API を使用しています。bUnit は Blazor Server と Blazor WebAssembly の両方のコンポーネントをサポートします。

対象外: Blazor アプリのブラウザーベースの E2E テストについては、[skill:dotnet-playwright]を参照してください。共通の UI テストパターン(ページオブジェクトモデル、セレクター、待機戦略)については、[skill:dotnet-ui-testing-core]を参照してください。テストプロジェクトのスキャフォールディングについては、[skill:dotnet-add-testing]が担当します。

前提条件: [skill:dotnet-add-testing]を使ってスキャフォールディングし、bUnit パッケージを参照する Blazor テストプロジェクトが必要です。テスト対象のコンポーネントは、参照されている Blazor プロジェクト内にある必要があります。

関連項目:[skill:dotnet-ui-testing-core](共通の UI テストパターン(POM、セレクター、待機戦略))、[skill:dotnet-xunit](xUnit フィクスチャとテストの整理)、[skill:dotnet-blazor-patterns](ホスティングモデルとレンダーモード)、[skill:dotnet-blazor-components](コンポーネントのアーキテクチャと状態管理)。


パッケージのセットアップ

<PackageReference Include="bunit" Version="1.*" />

bUnit のテストクラスはTestContextを継承します(またはコンポジションによって使用します)。

using Bunit;
using Xunit;

// Inheritance approach (less boilerplate)
public class CounterTests : TestContext
{
    [Fact]
    public void Counter_InitialRender_ShowsZero()
    {
        var cut = RenderComponent<Counter>();

        cut.Find("[data-testid='count']").MarkupMatches("<span data-testid=\"count\">0</span>");
    }
}

// Composition approach (more flexibility)
public class CounterCompositionTests : IDisposable
{
    private readonly TestContext _ctx = new();

    [Fact]
    public void Counter_InitialRender_ShowsZero()
    {
        var cut = _ctx.RenderComponent<Counter>();
        Assert.Equal("0", cut.Find("[data-testid='count']").TextContent);
    }

    public void Dispose() => _ctx.Dispose();
}

コンポーネントのレンダリング

基本的なレンダリングとマークアップのアサーション
public class AlertTests : TestContext
{
    [Fact]
    public void Alert_WithMessage_RendersCorrectMarkup()
    {
        var cut = RenderComponent<Alert>(parameters => parameters
            .Add(p => p.Message, "Order saved successfully")
            .Add(p => p.Severity, AlertSeverity.Success));

        // Assert on text content
        Assert.Contains("Order saved successfully", cut.Markup);

        // Assert on specific elements
        var alert = cut.Find("[data-testid='alert']");
        Assert.Contains("success", alert.ClassList);
    }

    [Fact]
    public void Alert_Dismissed_RendersNothing()
    {
        var cut = RenderComponent<Alert>(parameters => parameters
            .Add(p => p.Message, "Info")
            .Add(p => p.IsDismissed, true));

        Assert.Empty(cut.Markup.Trim());
    }
}
子コンテンツを含むレンダリング
[Fact]
public void Card_WithChildContent_RendersChildren()
{
    var cut = RenderComponent<Card>(parameters => parameters
        .AddChildContent("<p>Card body content</p>"));

    cut.Find("p").MarkupMatches("<p>Card body content</p>");
}

[Fact]
public void Card_WithRenderFragment_RendersTemplate()
{
    var cut = RenderComponent<Card>(parameters => parameters
        .Add(p => p.Header, builder =>
        {
            builder.OpenElement(0, "h2");
            builder.AddContent(1, "Card Title");
            builder.CloseElement();
        })
        .AddChildContent("<p>Body</p>"));

    cut.Find("h2").MarkupMatches("<h2>Card Title</h2>");
}
依存性注入を使ったレンダリング

依存するサービスを必要とするコンポーネントをレンダリングする前に、サービスを登録します。

public class OrderListTests : TestContext
{
    [Fact]
    public async Task OrderList_OnLoad_DisplaysOrders()
    {
        // Register mock service
        var mockService = Substitute.For<IOrderService>();
        mockService.GetOrdersAsync().Returns(
        [
            new OrderDto { Id = 1, CustomerName = "Alice", Total = 99.99m },
            new OrderDto { Id = 2, CustomerName = "Bob", Total = 149.50m }
        ]);
        Services.AddSingleton(mockService);

        // Render component -- DI resolves IOrderService automatically
        var cut = RenderComponent<OrderList>();

        // Wait for async data loading
        cut.WaitForState(() => cut.FindAll("[data-testid='order-row']").Count == 2);

        var rows = cut.FindAll("[data-testid='order-row']");
        Assert.Equal(2, rows.Count);
        Assert.Contains("Alice", rows[0].TextContent);
    }
}

イベント処理

クリックイベント
[Fact]
public void Counter_ClickIncrement_IncreasesCount()
{
    var cut = RenderComponent<Counter>();

    cut.Find("[data-testid='increment-btn']").Click();

    Assert.Equal("1", cut.Find("[data-testid='count']").TextContent);
}

[Fact]
public void Counter_MultipleClicks_AccumulatesCount()
{
    var cut = RenderComponent<Counter>();

    var button = cut.Find("[data-testid='increment-btn']");
    button.Click();
    button.Click();
    button.Click();

    Assert.Equal("3", cut.Find("[data-testid='count']").TextContent);
}
フォーム入力イベント
[Fact]
public void SearchBox_TypeText_UpdatesResults()
{
    Services.AddSingleton(Substitute.For<ISearchService>());
    var cut = RenderComponent<SearchBox>();

    var input = cut.Find("[data-testid='search-input']");
    input.Input("wireless keyboard");

    Assert.Equal("wireless keyboard", cut.Instance.SearchTerm);
}

[Fact]
public async Task LoginForm_SubmitValid_CallsAuthService()
{
    var authService = Substitute.For<IAuthService>();
    authService.LoginAsync(Arg.Any<string>(), Arg.Any<string>())
        .Returns(new AuthResult { Success = true });
    Services.AddSingleton(authService);

    var cut = RenderComponent<LoginForm>();

    cut.Find("[data-testid='email']").Change("[email protected]");
    cut.Find("[data-testid='password']").Change("P@ssw0rd!");
    cut.Find("[data-testid='login-form']").Submit();

    // Wait for async submission
    cut.WaitForState(() => cut.Instance.IsAuthenticated);

    await authService.Received(1).LoginAsync("[email protected]", "P@ssw0rd!");
}
EventCallback パラメーター
[Fact]
public void DeleteButton_Click_InvokesOnDeleteCallback()
{
    var deletedId = 0;
    var cut = RenderComponent<DeleteButton>(parameters => parameters
        .Add(p => p.ItemId, 42)
        .Add(p => p.OnDelete, EventCallback.Factory.Create<int>(
            this, id => deletedId = id)));

    cut.Find("[data-testid='delete-btn']").Click();

    Assert.Equal(42, deletedId);
}

カスケードパラメーター

CascadingValue のセットアップ
[Fact]
public void ThemedButton_WithDarkTheme_AppliesDarkClass()
{
    var theme = new AppTheme { Mode = ThemeMode.Dark, PrimaryColor = "#1a1a2e" };

    var cut = RenderComponent<ThemedButton>(parameters => parameters
        .Add(p => p.Label, "Save")
        .AddCascadingValue(theme));

    var button = cut.Find("button");
    Assert.Contains("dark-theme", button.ClassList);
}

[Fact]
public void UserDisplay_WithCascadedAuthState_ShowsUserName()
{
    var authState = new AuthenticationState(
        new ClaimsPrincipal(new ClaimsIdentity(
        [
            new Claim(ClaimTypes.Name, "Alice"),
            new Claim(ClaimTypes.Role, "Admin")
        ], "TestAuth")));

    var cut = RenderComponent<UserDisplay>(parameters => parameters
        .AddCascadingValue(Task.FromResult(authState)));

    Assert.Contains("Alice", cut.Find("[data-testid='user-name']").TextContent);
}
名前付きカスケード値
[Fact]
public void LayoutComponent_ReceivesNamedCascadingValues()
{
    var cut = RenderComponent<DashboardWidget>(parameters => parameters
        .AddCascadingValue("PageTitle", "Dashboard")
        .AddCascadingValue("SidebarCollapsed", true));

    Assert.Contains("Dashboard", cut.Find("[data-testid='widget-title']").TextContent);
}

JavaScript 相互運用のモック

IJSRuntime経由で JavaScript を呼び出す Blazor コンポーネントでは、bUnit でモックを設定する必要があります。bUnit には組み込みの JS 相互運用モックが用意されています。

基本的な JSInterop のセットアップ
public class ClipboardButtonTests : TestContext
{
    [Fact]
    public void CopyButton_Click_InvokesClipboardAPI()
    {
        // Set up JS interop mock -- bUnit's JSInterop is available via this.JSInterop
        JSInterop.SetupVoid("navigator.clipboard.writeText", "Hello, World!");

        var cut = RenderComponent<CopyButton>(parameters => parameters
            .Add(p => p.TextToCopy, "Hello, World!"));

        cut.Find("[data-testid='copy-btn']").Click();

        // Verify the JS call was made
        JSInterop.VerifyInvoke("navigator.clipboard.writeText", calledTimes: 1);
    }
}
戻り値を使った JSInterop
[Fact]
public void GeoLocation_OnLoad_DisplaysCoordinates()
{
    // Mock JS call that returns a value
    var location = new { Latitude = 47.6062, Longitude = -122.3321 };
    JSInterop.Setup<object>("getGeoLocation").SetResult(location);

    var cut = RenderComponent<LocationDisplay>();

    cut.WaitForState(() => cut.Find("[data-testid='coordinates']").TextContent.Contains("47.6"));
    Assert.Contains("47.6062", cut.Find("[data-testid='coordinates']").TextContent);
}
すべての呼び出しを受け付ける JSInterop モード

多数の JS 呼び出しを行うコンポーネントでは、すべての呼び出しを設定しなくても済むよう、ルーズモードを使用します。

[Fact]
public void RichEditor_Render_DoesNotThrowJSErrors()
{
    // Loose mode: unmatched JS calls return default values instead of throwing
    JSInterop.Mode = JSRuntimeMode.Loose;

    var cut = RenderComponent<RichTextEditor>(parameters => parameters
        .Add(p => p.Content, "Initial content"));

    // Component renders without JS exceptions
    Assert.NotEmpty(cut.Markup);
}

非同期コンポーネントのライフサイクル

OnInitializedAsync のテスト
[Fact]
public void ProductList_WhileLoading_ShowsSpinner()
{
    var tcs = new TaskCompletionSource<List<ProductDto>>();
    var productService = Substitute.For<IProductService>();
    productService.GetProductsAsync().Returns(tcs.Task);
    Services.AddSingleton(productService);

    var cut = RenderComponent<ProductList>();

    // Component is still loading -- spinner should be visible
    Assert.NotNull(cut.Find("[data-testid='loading-spinner']"));

    // Complete the async operation
    tcs.SetResult([new ProductDto { Name = "Widget", Price = 9.99m }]);
    cut.WaitForState(() => cut.FindAll("[data-testid='product-item']").Count > 0);

    // Spinner gone, products visible
    Assert.Throws<ElementNotFoundException>(
        () => cut.Find("[data-testid='loading-spinner']"));
    Assert.Single(cut.FindAll("[data-testid='product-item']"));
}
エラー状態のテスト
[Fact]
public void ProductList_ServiceError_ShowsErrorMessage()
{
    var productService = Substitute.For<IProductService>();
    productService.GetProductsAsync()
        .ThrowsAsync(new HttpRequestException("Service unavailable"));
    Services.AddSingleton(productService);

    var cut = RenderComponent<ProductList>();

    cut.WaitForState(() =>
        cut.Find("[data-testid='error-message']").TextContent.Length > 0);

    Assert.Contains("Service unavailable",
        cut.Find("[data-testid='error-message']").TextContent);
}

重要な原則

  • コンポーネントを分離してレンダリングします。 bUnit ではブラウザーを使わずに個々のコンポーネントをテストできるため、高速で決定的なテストを実施できます。コンポーネントのロジックにはこれを使用し、アプリ全体の E2E フローには[skill:dotnet-playwright]を使用してください。
  • レンダリング前にすべての依存関係を登録します。 コンポーネントが[Inject]経由で注入するサービスは、RenderComponentを呼び出す前にServicesへ登録する必要があります。
  • 非同期コンポーネントにはWaitForStateとWaitForAssertionを使用します。 Task.Delayは使用しないでください。bUnit には目的に特化した待機機能が用意されています。
  • JS 相互運用を明示的にモックします。 bUnit の厳格モードでは、処理されていない JS 相互運用呼び出しはデフォルトで例外をスローします。期待される呼び出しを設定するか、JS 呼び出しの多いコンポーネントではルーズモードに切り替えてください。
  • コンポーネントの内部ではなく、レンダリング結果をテストします。 プライベートフィールドや内部状態ではなく、マークアップ、テキストコンテンツ、要素属性をアサートしてください。

エージェントで注意すべき点

  1. RenderComponent の前にサービスを登録するのを忘れないでください。 [Inject] されたサービスが不足していると、bUnit はレンダリング時に例外をスローします。注入される依存関係ごとに、モックまたはフェイクを登録してください。
  2. プライベートメンバーへのアクセスに cut.Instance を使わないでください。 Instance からアクセスできるのは、コンポーネントの公開 API のみです。内部状態をテストする必要がある場合は、公開プロパティを通じて公開するか、レンダリングされた出力を使ってテストしてください。
  3. 非同期操作をトリガーした後は、cut.WaitForState() を呼び出すのを忘れないでください。 呼び出さないと、コンポーネントが再レンダリングされる前にアサーションが実行され、誤った失敗が発生します。
  4. 同じテストクラスで bUnit と Playwright を併用しないでください。 bUnit はメモリ内でコンポーネントを実行し(ブラウザーは使用しません)、Playwright は実際のブラウザーで実行します。用途が異なり、ライフサイクルに互換性がありません。
  5. カスケード値を必要とするコンポーネントには、カスケード値を設定するのを忘れないでください。 [CascadingParameter] を持つコンポーネントは、CascadingValue が指定されていないと null を受け取り、レンダリング中に NullReferenceException が発生することがあります。

参考資料

コメント