Blazor组件bUnit测试技能 dotnet-blazor-testing

此技能用于使用bUnit框架对Blazor组件进行高效、无浏览器的单元测试。它覆盖组件渲染、事件处理、级联参数、JavaScript互操作模拟和异步生命周期测试,专为.NET 8.0+和bUnit 1.x设计。关键词:Blazor测试、bUnit、.NET组件测试、前端开发、单元测试、异步测试、JS模拟、级联参数、事件模拟、测试框架。

测试 0 次安装 0 次浏览 更新于 3/6/2026

名称: dotnet-blazor-testing 描述: “测试Blazor组件。bUnit渲染、事件、级联参数、JS互操作模拟。” 用户可调用: false

dotnet-blazor-testing

Blazor组件的bUnit测试。覆盖组件渲染和标记断言、事件处理、级联参数和级联值、JavaScript互操作模拟以及异步组件生命周期测试。bUnit提供了一个内存中的Blazor渲染器,无需浏览器即可执行组件。

版本假设: .NET 8.0+基线,bUnit 1.x(稳定版)。示例使用最新的bUnit API。bUnit支持Blazor Server和Blazor WebAssembly组件。

范围

  • bUnit组件渲染和标记断言
  • 事件处理和用户交互模拟
  • 级联参数和级联值
  • JavaScript互操作模拟
  • 异步组件生命周期测试

超出范围

  • Blazor应用的基于浏览器的端到端测试——参见[技能:dotnet-playwright]
  • 共享UI测试模式(页面对象模型、选择器、等待策略)——参见[技能:dotnet-ui-testing-core]
  • 测试项目脚手架——参见[技能:dotnet-add-testing]

先决条件: 通过[技能:dotnet-add-testing]搭建的Blazor测试项目,引用了bUnit包。待测试组件必须在引用的Blazor项目中。

交叉引用:[技能:dotnet-ui-testing-core]用于共享UI测试模式(POM、选择器、等待策略),[技能:dotnet-xunit]用于xUnit装置和测试组织,[技能:dotnet-blazor-patterns]用于托管模型和渲染模式,[技能:dotnet-blazor-components]用于组件架构和状态管理。


包设置

<PackageReference Include="bunit" Version="1.*" />
<!-- bUnit内部依赖于xunit;确保兼容的xUnit版本 -->

bUnit测试类继承自TestContext(或通过组合使用它):

using Bunit;
using Xunit;

// 继承方法(样板代码较少)
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>");
    }
}

// 组合方法(更灵活)
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, "订单保存成功")
            .Add(p => p.Severity, AlertSeverity.Success));

        // 断言文本内容
        Assert.Contains("订单保存成功", cut.Markup);

        // 断言特定元素
        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, "信息")
            .Add(p => p.IsDismissed, true));

        Assert.Empty(cut.Markup.Trim());
    }
}

带子内容的渲染

[Fact]
public void Card_WithChildContent_RendersChildren()
{
    var cut = RenderComponent<Card>(parameters => parameters
        .AddChildContent("<p>卡片正文内容</p>"));

    cut.Find("p").MarkupMatches("<p>卡片正文内容</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, "卡片标题");
            builder.CloseElement();
        })
        .AddChildContent("<p>正文</p>"));

    cut.Find("h2").MarkupMatches("<h2>卡片标题</h2>");
}

带依赖注入的渲染

注册服务在渲染依赖它们的组件之前:

public class OrderListTests : TestContext
{
    [Fact]
    public async Task OrderList_OnLoad_DisplaysOrders()
    {
        // 注册模拟服务
        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);

        // 渲染组件——DI自动解析IOrderService
        var cut = RenderComponent<OrderList>();

        // 等待异步数据加载
        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("无线键盘");

    Assert.Equal("无线键盘", 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("user@example.com");
    cut.Find("[data-testid='password']").Change("P@ssw0rd!");
    cut.Find("[data-testid='login-form']").Submit();

    // 等待异步提交
    cut.WaitForState(() => cut.Instance.IsAuthenticated);

    await authService.Received(1).LoginAsync("user@example.com", "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, "保存")
        .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", "仪表板")
        .AddCascadingValue("SidebarCollapsed", true));

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

JavaScript互操作模拟

调用JavaScript via IJSRuntime的Blazor组件需要在bUnit中设置模拟。bUnit提供了一个内置的JS互操作模拟。

基本JSInterop设置

public class ClipboardButtonTests : TestContext
{
    [Fact]
    public void CopyButton_Click_InvokesClipboardAPI()
    {
        // 设置JS互操作模拟——bUnit的JSInterop可通过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();

        // 验证JS调用是否发生
        JSInterop.VerifyInvoke("navigator.clipboard.writeText", calledTimes: 1);
    }
}

带返回值的JSInterop

[Fact]
public void GeoLocation_OnLoad_DisplaysCoordinates()
{
    // 模拟返回值的JS调用
    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()
{
    // 松散模式:未匹配的JS调用返回默认值而不是抛出异常
    JSInterop.Mode = JSRuntimeMode.Loose;

    var cut = RenderComponent<RichTextEditor>(parameters => parameters
        .Add(p => p.Content, "初始内容"));

    // 组件渲染时不抛出JS异常
    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>();

    // 组件仍在加载——旋转器应可见
    Assert.NotNull(cut.Find("[data-testid='loading-spinner']"));

    // 完成异步操作
    tcs.SetResult([new ProductDto { Name = "小部件", Price = 9.99m }]);
    cut.WaitForState(() => cut.FindAll("[data-testid='product-item']").Count > 0);

    // 旋转器消失,产品可见
    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("服务不可用"));
    Services.AddSingleton(productService);

    var cut = RenderComponent<ProductList>();

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

    Assert.Contains("服务不可用",
        cut.Find("[data-testid='error-message']").TextContent);
}

关键原则

  • 隔离渲染组件。 bUnit无需浏览器测试单个组件,使其快速且确定。用于组件逻辑;完整应用的端到端流程使用[技能:dotnet-playwright]。
  • 在渲染前注册所有依赖。 组件通过[Inject]注入的任何服务必须在调用RenderComponent之前在Services中注册。
  • 使用WaitForStateWaitForAssertion处理异步组件。 不要使用Task.Delay——bUnit提供了专门的等待机制。
  • 显式模拟JS互操作。 默认情况下,bUnit严格模式下未处理的JS互操作调用会抛出。设置预期调用或切换到松散模式处理JS密集型组件。
  • 测试渲染输出,而不是组件内部。 断言标记、文本内容和元素属性——而不是私有字段或内部状态。

代理常见陷阱

  1. 不要忘记在RenderComponent前注册服务。 如果缺少[Inject]-ed服务,bUnit在渲染时会抛出。为每个注入的依赖注册模拟或伪对象。
  2. 不要使用cut.Instance访问私有成员。 Instance仅暴露组件的公共API。如果需要测试内部状态,通过公共属性暴露或通过渲染输出测试。
  3. 触发异步操作后不要忘记调用cut.WaitForState() 否则断言会在组件重新渲染前运行,导致误失败。
  4. 不要在同一测试类中混合bUnit和Playwright。 bUnit在内存中运行组件(无浏览器);Playwright在真实浏览器中运行。它们用途不同且生命周期不兼容。
  5. 不要忘记为期望级联参数的组件提供级联值。 具有[CascadingParameter]的组件如果没有提供CascadingValue,将接收null,这可能在渲染时导致NullReferenceException

参考资料