dotnet-blazor-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

dotnet-blazor-testing

.NET Blazor组件测试

bUnit testing for Blazor components. Covers component rendering and markup assertions, event handling, cascading parameters and cascading values, JavaScript interop mocking, and async component lifecycle testing. bUnit provides an in-memory Blazor renderer that executes components without a browser.
Version assumptions: .NET 8.0+ baseline, bUnit 1.x (stable). Examples use the latest bUnit APIs. bUnit supports both Blazor Server and Blazor WebAssembly components.
使用bUnit测试Blazor组件。涵盖组件渲染与标记断言、事件处理、级联参数与级联值、JavaScript互操作模拟,以及异步组件生命周期测试。bUnit提供一个内存中的Blazor渲染器,无需浏览器即可执行组件。
版本要求: 基于.NET 8.0+,使用bUnit 1.x(稳定版)。示例使用最新的bUnit API。bUnit同时支持Blazor Server和Blazor WebAssembly组件。

Scope

适用范围

  • bUnit component rendering and markup assertions
  • Event handling and user interaction simulation
  • Cascading parameters and cascading values
  • JavaScript interop mocking
  • Async component lifecycle testing
  • bUnit组件渲染与标记断言
  • 事件处理与用户交互模拟
  • 级联参数与级联值
  • JavaScript互操作模拟
  • 异步组件生命周期测试

Out of scope

不适用范围

  • Browser-based E2E testing of Blazor apps -- see [skill:dotnet-playwright]
  • Shared UI testing patterns (page object model, selectors, wait strategies) -- see [skill:dotnet-ui-testing-core]
  • Test project scaffolding -- see [skill:dotnet-add-testing]
Prerequisites: A Blazor test project scaffolded via [skill:dotnet-add-testing] with bUnit packages referenced. The component under test must be in a referenced Blazor project.
Cross-references: [skill:dotnet-ui-testing-core] for shared UI testing patterns (POM, selectors, wait strategies), [skill:dotnet-xunit] for xUnit fixtures and test organization, [skill:dotnet-blazor-patterns] for hosting models and render modes, [skill:dotnet-blazor-components] for component architecture and state management.

  • 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]讲解组件架构与状态管理。

Package Setup

包配置

xml
<PackageReference Include="bunit" Version="1.*" />
<!-- bUnit depends on xunit internally; ensure compatible xUnit version -->
bUnit test classes inherit from
TestContext
(or use it via composition):
csharp
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();
}

xml
<PackageReference Include="bunit" Version="1.*" />
<!-- bUnit内部依赖xunit;请确保xUnit版本兼容 -->
bUnit测试类继承自
TestContext
(或通过组合方式使用它):
csharp
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();
}

Component Rendering

组件渲染

Basic Rendering and Markup Assertions

基础渲染与标记断言

csharp
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());
    }
}
csharp
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.Contains("Order saved successfully", 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, "Info")
            .Add(p => p.IsDismissed, true));

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

Rendering with Child Content

带子内容的渲染

csharp
[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>");
}
csharp
[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>");
}

Rendering with Dependency Injection

依赖注入下的渲染

Register services before rendering components that depend on them:
csharp
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);
    }
}

在渲染依赖服务的组件前,先注册服务:
csharp
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);
    }
}

Event Handling

事件处理

Click Events

点击事件

csharp
[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);
}
csharp
[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);
}

Form Input Events

表单输入事件

csharp
[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("user@example.com");
    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("user@example.com", "P@ssw0rd!");
}
csharp
[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("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 Parameters

EventCallback参数

csharp
[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);
}

csharp
[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);
}

Cascading Parameters

级联参数

CascadingValue Setup

级联值配置

csharp
[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);
}
csharp
[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);
}

Named Cascading Values

命名级联值

csharp
[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);
}

csharp
[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 Interop Mocking

JavaScript互操作模拟

Blazor components that call JavaScript via
IJSRuntime
require mock setup in bUnit. bUnit provides a built-in JS interop mock.
通过
IJSRuntime
调用JavaScript的Blazor组件,在bUnit中需要配置模拟。bUnit提供内置的JS互操作模拟工具。

Basic JSInterop Setup

基础JSInterop配置

csharp
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);
    }
}
csharp
public class ClipboardButtonTests : TestContext
{
    [Fact]
    public void CopyButton_Click_InvokesClipboardAPI()
    {
        // 配置JS互操作模拟——可通过this.JSInterop访问bUnit的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 with Return Values

带返回值的JSInterop

csharp
[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);
}
csharp
[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);
}

Catch-All JSInterop Mode

通配JSInterop模式

For components with many JS calls, use loose mode to avoid setting up every call:
csharp
[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);
}

对于包含大量JS调用的组件,使用宽松模式避免逐个配置调用:
csharp
[Fact]
public void RichEditor_Render_DoesNotThrowJSErrors()
{
    // 宽松模式:未匹配的JS调用返回默认值而非抛出异常
    JSInterop.Mode = JSRuntimeMode.Loose;

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

    // 组件渲染时无JS异常
    Assert.NotEmpty(cut.Markup);
}

Async Component Lifecycle

异步组件生命周期

Testing OnInitializedAsync

测试OnInitializedAsync

csharp
[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']"));
}
csharp
[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 = "Widget", 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']"));
}

Testing Error States

测试错误状态

csharp
[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);
}

csharp
[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);
}

Key Principles

核心原则

  • Render components in isolation. bUnit tests individual components without a browser, making them fast and deterministic. Use this for component logic; use [skill:dotnet-playwright] for full-app E2E flows.
  • Register all dependencies before rendering. Any service the component injects via
    [Inject]
    must be registered in
    Services
    before
    RenderComponent
    is called.
  • Use
    WaitForState
    and
    WaitForAssertion
    for async components.
    Do not use
    Task.Delay
    -- bUnit provides purpose-built waiting mechanisms.
  • Mock JS interop explicitly. Unhandled JS interop calls throw by default in bUnit strict mode. Set up expected calls or switch to loose mode for JS-heavy components.
  • Test the rendered output, not component internals. Assert on markup, text content, and element attributes -- not on private fields or internal state.

  • 隔离渲染组件:bUnit在内存中测试单个组件,无需浏览器,测试快速且确定。使用bUnit测试组件逻辑;使用[skill:dotnet-playwright]进行全应用端到端流程测试。
  • 渲染前注册所有依赖:组件通过
    [Inject]
    注入的任何服务,必须在调用
    RenderComponent
    前注册到
    Services
    中。
  • 异步组件使用
    WaitForState
    WaitForAssertion
    :不要使用
    Task.Delay
    ——bUnit提供专门的等待机制。
  • 显式模拟JS互操作:在bUnit严格模式下,未处理的JS互操作调用会抛出异常。为预期调用配置模拟,或对重度依赖JS的组件切换到宽松模式。
  • 测试渲染输出而非组件内部:断言标记、文本内容和元素属性——而非私有字段或内部状态。

Agent Gotchas

常见陷阱

  1. Do not forget to register services before
    RenderComponent
    .
    bUnit throws at render time if an
    [Inject]
    -ed service is missing. Register mocks or fakes for every injected dependency.
  2. Do not use
    cut.Instance
    to access private members.
    Instance
    exposes the component's public API only. If you need to test internal state, expose it through public properties or test through rendered output.
  3. Do not forget to call
    cut.WaitForState()
    after triggering async operations.
    Without it, assertions run before the component re-renders, causing false failures.
  4. Do not mix bUnit and Playwright in the same test class. bUnit runs components in-memory (no browser); Playwright runs in a real browser. They serve different purposes and have incompatible lifecycles.
  5. Do not forget cascading values for components that expect them. A component with
    [CascadingParameter]
    will receive
    null
    if no
    CascadingValue
    is provided, which may cause
    NullReferenceException
    during rendering.

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

References

参考资料