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 应用端到端测试由 [skill:dotnet-playwright] 负责。通用 UI 测试模式(页面对象模型、选择器、等待策略)见 [skill:dotnet-ui-testing-core]。测试项目脚手架由 [skill:dotnet-add-testing] 负责。

**前置条件:**已通过 [skill:dotnet-add-testing] 创建 Blazor 测试项目脚手架,并引用 bUnit 包。被测组件必须位于已引用的 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 无需浏览器即可测试单个组件,因此测试快速且确定。使用它测试组件逻辑;使用 [skill:dotnet-playwright] 测试完整应用的端到端流程。
  • **在渲染之前注册所有依赖项。**组件通过 [Inject] 注入的任何服务,都必须在调用 RenderComponent 之前注册到 Services 中。
  • **对异步组件使用 WaitForState 和 WaitForAssertion。**不要使用 Task.Delay——bUnit 提供了专门的等待机制。
  • **明确模拟 JS 互操作。**在 bUnit 严格模式下,未处理的 JS 互操作调用默认会抛出异常。请设置预期调用,或对 JS 调用较多的组件切换到宽松模式。
  • **测试渲染输出,而非组件内部实现。**断言标记、文本内容和元素属性,而不是私有字段或内部状态。

Agent 注意事项

  1. 在 RenderComponent 之前,别忘了注册服务。 如果缺少通过 [Inject] 注入的服务,bUnit 会在渲染时抛出异常。请为每个注入的依赖注册模拟对象或伪对象。
  2. 不要使用 cut.Instance 访问私有成员。 Instance 仅公开组件的公共 API。如果需要测试内部状态,请通过公共属性公开,或通过渲染结果进行测试。
  3. 触发异步操作后,别忘了调用 cut.WaitForState()。 否则,断言会在组件重新渲染前运行,导致误报。
  4. 不要在同一个测试类中混用 bUnit 和 Playwright。 bUnit 在内存中运行组件(不使用浏览器);Playwright 则在真实浏览器中运行。两者用途不同,生命周期也不兼容。
  5. 别忘了为需要级联值的组件提供级联值。 如果没有提供 CascadingValue,带有 [CascadingParameter] 的组件将接收到 null,这可能导致渲染期间出现 NullReferenceException。

参考资料

评论

支持 Markdown