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_ValidationFailsDialog_CloseButton_InvokesVisibleChangedInput_WithPlaceholder_RendersPlaceholder
测试组织
tests/NextUI.Blazor.Tests/
├── TestBase.cs # 测试基类
├── Components/
│ ├── Admin/
│ │ ├── SxAdminUserEditorTests.cs
│ │ └── SxAdminUsersListTests.cs
│ ├── SxButtonTests.cs
│ ├── SxInputTests.cs
│ └── SxDialogTests.cs
└── Services/
└── IdentityServiceTests.cs
维护成本考量
低维护成本策略
测试公共 API,而非实现细节
// Good: 测试公共方法结果 Assert.True(cut.Instance.Validate()); // Bad: 测试内部字段状态 Assert.Equal("test", cut.Instance._internalField);使用语义化选择器
// Good: 使用 CSS 类名 cut.Find(".sx-dialog-title"); // Bad: 使用脆弱的路径选择器 cut.Find("div > div:nth-child(2) > span");关注行为而非结构
// 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
}