关于 dotnet-blazor-testing
面向 .NET 8 及更高版本的 Blazor 组件 bUnit 测试技能。它通过内存渲染器在无需浏览器的情况下测试组件,并涵盖事件、依赖、JavaScript 互操作和异步生命周期。
主要功能
- 01内存中渲染组件
- 02标记与输出断言
- 03事件及表单输入测试
- 04级联参数设置
- 05JavaScript 互操作模拟
- 06异步生命周期测试
适用场景
- 01检查组件渲染的标记
- 02测试点击和表单交互
- 03验证使用级联值的组件
- 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 注意事项
- 在
RenderComponent之前,别忘了注册服务。 如果缺少通过[Inject]注入的服务,bUnit 会在渲染时抛出异常。请为每个注入的依赖注册模拟对象或伪对象。 - 不要使用
cut.Instance访问私有成员。Instance仅公开组件的公共 API。如果需要测试内部状态,请通过公共属性公开,或通过渲染结果进行测试。 - 触发异步操作后,别忘了调用
cut.WaitForState()。 否则,断言会在组件重新渲染前运行,导致误报。 - 不要在同一个测试类中混用 bUnit 和 Playwright。 bUnit 在内存中运行组件(不使用浏览器);Playwright 则在真实浏览器中运行。两者用途不同,生命周期也不兼容。
- 别忘了为需要级联值的组件提供级联值。 如果没有提供
CascadingValue,带有[CascadingParameter]的组件将接收到null,这可能导致渲染期间出现NullReferenceException。