Blazor UI 组件测试模式

本文档定义了 NextUI Blazor 组件的标准测试模式,使用 bUnit 框架进行组件级集成测试。

测试金字塔

        /\
       /  \        E2E Tests (Playwright)
      /    \       - 最慢,数量最少
     /------\      - 完整用户流程验证
    /        \
   /  bUnit   \    Component Tests (bUnit) <- 本文档重点
  /  Component \   - 中等速度,覆盖 UI 逻辑
 /    Tests     \  - Mock 服务依赖
/----------------\
|   Unit Tests   | Pure Logic Tests (xUnit)
|    (xUnit)     | - 最快,数量最多
|________________| - 无 UI 依赖的纯逻辑

技术栈

工具 版本 用途
bUnit 1.32.7 Blazor 组件测试框架
xUnit 2.9.2 测试运行器
Moq 4.20.72 Mock 框架

测试分类

1. 初始状态测试 (Initial State Tests)

验证组件的默认渲染和初始值。

[Fact]
public void UserEditor_DefaultState_HasEmptyFields()
{
    // Arrange & Act
    var cut = RenderComponent<SxAdminUserEditor>(p => p
        .Add(x => x.IsNewUser, true));

    // Assert
    var editor = cut.Find(".sx-admin-user-editor");
    Assert.NotNull(editor);
}

[Fact]
public void UserEditor_DefaultEnabled_IsChecked()
{
    var cut = RenderComponent<SxAdminUserEditor>(p => p
        .Add(x => x.IsNewUser, true)
        .Add(x => x.User, new AdminUser { Username = "testuser" }));

    var user = cut.Instance.GetUser();
    Assert.True(user?.Enabled);
}

2. 参数测试 (Parameter Tests)

测试不同参数组合的行为。

[Fact]
public void UserEditor_ReadOnly_DisablesInputs()
{
    var cut = RenderComponent<SxAdminUserEditor>(p => p
        .Add(x => x.ReadOnly, true)
        .Add(x => x.User, CreateTestUser()));

    // 按钮不应显示
    Assert.DoesNotContain("Save Changes", cut.Markup);
}

[Theory]
[InlineData(true, "Create User")]
[InlineData(false, "Save Changes")]
public void UserEditor_ButtonText_DependsOnMode(bool isNew, string expectedText)
{
    var cut = RenderComponent<SxAdminUserEditor>(p => p
        .Add(x => x.IsNewUser, isNew)
        .Add(x => x.ShowActions, true)
        .Add(x => x.User, CreateTestUser()));

    Assert.Contains(expectedText, cut.Markup);
}

3. 验证测试 (Validation Tests)

测试表单验证逻辑。

[Fact]
public void UserEditor_EmptyUsername_ValidationFails()
{
    var cut = RenderComponent<SxAdminUserEditor>(p => p
        .Add(x => x.IsNewUser, true));

    Assert.False(cut.Instance.Validate());
    Assert.Equal("Username is required", cut.Instance.GetValidationError());
}

[Fact]
public void UserEditor_PasswordMismatch_ValidationFails()
{
    var cut = RenderComponent<SxAdminUserEditor>(p => p
        .Add(x => x.IsNewUser, true)
        .Add(x => x.User, new AdminUser { Username = "test" }));

    var passwordInputs = cut.FindAll("input[type='password']");
    passwordInputs[0].Change("password123");
    passwordInputs[1].Change("different456");

    Assert.False(cut.Instance.Validate());
}

4. 用户交互测试 (User Interaction Tests)

模拟用户操作。

[Fact]
public void Input_OnChange_UpdatesValue()
{
    string? newValue = null;
    var cut = RenderComponent<SxInput>(p => p
        .Add(x => x.Value, "initial")
        .Add(x => x.Immediate, true)
        .Add(x => x.ValueChanged, v => newValue = v));

    cut.Find("input").Input("changed");

    Assert.Equal("changed", newValue);
}

[Fact]
public void Button_OnClick_InvokesCallback()
{
    bool clicked = false;
    var cut = RenderComponent<SxButton>(p => p
        .Add(x => x.OnClick, () => clicked = true)
        .AddChildContent("Click Me"));

    cut.Find("button").Click();

    Assert.True(clicked);
}

5. 回调测试 (Callback Tests)

验证 EventCallback 正确触发。

[Fact]
public async Task UserEditor_SaveClick_InvokesOnSave()
{
    AdminUser? savedUser = null;
    var cut = RenderComponent<SxAdminUserEditor>(p => p
        .Add(x => x.IsNewUser, true)
        .Add(x => x.ShowActions, true)
        .Add(x => x.User, new AdminUser { Username = "testuser" })
        .Add(x => x.OnSave, EventCallback.Factory.Create<AdminUser>(
            this, u => savedUser = u)));

    var saveButton = cut.FindAll("button")
        .First(b => b.TextContent.Contains("Create User"));
    saveButton.Click();

    Assert.NotNull(savedUser);
    Assert.Equal("testuser", savedUser.Username);
}

6. 模式特定测试 (Mode-Specific Tests)

测试组件在不同模式下的行为差异。

[Fact]
public void UserEditor_NewUser_ShowsPasswordFields()
{
    var cut = RenderComponent<SxAdminUserEditor>(p => p
        .Add(x => x.IsNewUser, true));

    Assert.Contains("Initial Password", cut.Markup);
}

[Fact]
public void UserEditor_EditUser_HidesPasswordFields()
{
    var cut = RenderComponent<SxAdminUserEditor>(p => p
        .Add(x => x.IsNewUser, false)
        .Add(x => x.User, CreateTestUser()));

    Assert.DoesNotContain("Initial Password", cut.Markup);
}

测试基类设置

所有组件测试继承自 TestBase

public abstract class TestBase : TestContext
{
    protected Mock<IMenuService> MockMenuService { get; } = new();
    protected TestLocaleState TestLocale { get; }

    static TestBase()
    {
        // 初始化本地化
        BlazorLocaleExtensions.AddBlazorLocales();
        NextUILocalizer.PreloadAsync("en-US").GetAwaiter().GetResult();
    }

    protected TestBase()
    {
        // 注册必要的服务
        Services.AddSingleton<IMenuService>(MockMenuService.Object);
        Services.AddSingleton<ILocaleState>(new TestLocaleState());
        // ... 其他服务

        // JSInterop 使用 Loose 模式
        JSInterop.Mode = JSRuntimeMode.Loose;
    }
}

依赖 Mock 模式

对于需要服务依赖的组件:

public class SxAdminUsersListTests : TestBase
{
    private readonly Mock<IAdminProvider> _mockProvider = new();

    public SxAdminUsersListTests()
    {
        // 配置 Mock
        _mockProvider.Setup(x => x.GetUsersAsync(It.IsAny<UserSearchRequest>()))
            .ReturnsAsync(new List<AdminUser> { CreateTestUser() });

        // 注册到 DI
        Services.AddSingleton(_mockProvider.Object);
    }

    [Fact]
    public async Task UsersList_OnLoad_ShowsUsers()
    {
        var cut = RenderComponent<SxAdminUsersList>();

        // 等待异步加载
        await cut.WaitForAssertionAsync(() =>
        {
            Assert.Contains("testuser", cut.Markup);
        });
    }
}

命名约定

[组件名]_[条件/场景]_[预期行为]

示例:

  • UserEditor_EmptyUsername_ValidationFails
  • Dialog_CloseButton_InvokesVisibleChanged
  • Input_WithPlaceholder_RendersPlaceholder

测试组织

tests/NextUI.Blazor.Tests/
├── TestBase.cs                    # 测试基类
├── Components/
│   ├── Admin/
│   │   ├── SxAdminUserEditorTests.cs
│   │   └── SxAdminUsersListTests.cs
│   ├── SxButtonTests.cs
│   ├── SxInputTests.cs
│   └── SxDialogTests.cs
└── Services/
    └── IdentityServiceTests.cs

维护成本考量

低维护成本策略

  1. 测试公共 API,而非实现细节

    // Good: 测试公共方法结果
    Assert.True(cut.Instance.Validate());
    
    // Bad: 测试内部字段状态
    Assert.Equal("test", cut.Instance._internalField);
    
  2. 使用语义化选择器

    // Good: 使用 CSS 类名
    cut.Find(".sx-dialog-title");
    
    // Bad: 使用脆弱的路径选择器
    cut.Find("div > div:nth-child(2) > span");
    
  3. 关注行为而非结构

    // Good: 验证用户可见的行为
    Assert.Contains("Error: Username required", cut.Markup);
    
    // Bad: 验证 DOM 结构
    Assert.Equal(3, cut.FindAll("div").Count);
    

何时更新测试

场景 是否需要更新测试
修改组件样式/CSS 一般不需要
修改组件 HTML 结构 可能需要(如选择器变化)
添加新功能 需要添加新测试
修复 Bug 先写失败测试,再修复
重构内部实现 测试应该仍然通过

运行测试

# 运行所有组件测试
dotnet test tests/NextUI.Blazor.Tests

# 运行特定组件测试
dotnet test --filter "FullyQualifiedName~SxAdminUserEditorTests"

# 运行特定类别
dotnet test --filter "Category=Integration"

示例:完整测试文件结构

using Bunit;
using NextUI.Blazor.Components.Admin;
using Xunit;

namespace NextUI.Blazor.Tests.Components.Admin;

public class SxAdminUserEditorTests : TestBase
{
    #region Initial State Tests
    // 初始状态测试...
    #endregion

    #region Parameter Tests
    // 参数测试...
    #endregion

    #region Validation Tests
    // 验证测试...
    #endregion

    #region Callback Tests
    // 回调测试...
    #endregion

    #region Mode-Specific Tests
    // 模式特定测试...
    #endregion

    #region Helper Methods
    private static AdminUser CreateTestUser() => new()
    {
        Id = "user-123",
        Username = "testuser",
        Email = "test@example.com"
    };
    #endregion
}

相关资源