关于 dotnet-ui-testing-core
面向 .NET UI 测试开发者的跨框架测试模式指南,适用于 Blazor、MAUI 和 Uno Platform。内容聚焦可维护的页面对象、稳定的元素选择器、异步等待和无障碍检查。
主要功能
- 01页面对象模式
- 02可靠的元素选择策略
- 03基于条件的异步等待
- 04自动化无障碍检查
- 05适用于多种 .NET UI 框架
适用场景
- 01组织可维护的 UI 测试套件
- 02减少因选择器不稳定导致的测试波动
- 03等待异步渲染和网络响应
- 04在测试中检查键盘导航与 ARIA
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-ui-testing-core安装后在 Claude Code 中输入/dotnet-ui-testing-core确认已加载。
详细介绍
dotnet-ui-testing-core
适用于各种 .NET UI 框架(Blazor、MAUI、Uno Platform)的核心 UI 测试模式。涵盖可维护测试结构的页面对象模型、可靠识别元素的测试选择器策略、处理非确定性 UI 的异步等待模式,以及无障碍测试方法。
**版本假设:**基线为 .NET 8.0+。特定框架的详细内容由专门的技能提供。
**范围之外:**特定框架的测试 API 由相应技能负责:[skill:dotnet-blazor-testing] 负责 bUnit,[skill:dotnet-maui-testing] 负责 Appium/XHarness,[skill:dotnet-uno-testing] 负责 Uno WASM 测试。浏览器自动化的具体内容由 [skill:dotnet-playwright] 负责。测试项目脚手架由 [skill:dotnet-add-testing] 负责。
**前置条件:**已通过 [skill:dotnet-add-testing] 搭建测试项目。熟悉 [skill:dotnet-testing-strategy] 中的测试策略决策。
交叉参考:[skill:dotnet-testing-strategy] 用于判断何时适合使用 UI 测试,[skill:dotnet-playwright] 用于基于浏览器的 E2E 自动化,[skill:dotnet-blazor-testing] 用于 Blazor 组件测试,[skill:dotnet-maui-testing] 用于移动端/桌面端 UI 测试,[skill:dotnet-uno-testing] 用于 Uno Platform 测试。
页面对象模型
页面对象模型(POM)通过类封装页面结构和交互,将测试与 UI 实现细节隔离。当 UI 发生变化时,只需更新页面对象,而不必修改所有涉及该页面的测试。
结构
PageObjects/
LoginPage.cs -- login form interactions
DashboardPage.cs -- dashboard navigation + widgets
OrderListPage.cs -- order list filtering + selection
Components/
NavigationMenu.cs -- shared nav component
ConfirmDialog.cs -- reusable confirmation modal
示例:通用页面对象基类
/// <summary>
/// Base class for page objects. Subclass per framework:
/// Playwright uses IPage, bUnit uses IRenderedComponent, Appium uses AppiumDriver.
/// </summary>
public abstract class PageObjectBase<TDriver>
{
protected TDriver Driver { get; }
protected PageObjectBase(TDriver driver)
{
Driver = driver;
}
/// <summary>
/// Verifies the page/component is in the expected state after navigation.
/// Call this in the constructor or after navigation to fail fast on wrong pages.
/// </summary>
protected abstract void VerifyLoaded();
}
示例:Playwright 页面对象
public class LoginPage : PageObjectBase<IPage>
{
public LoginPage(IPage page) : base(page)
{
VerifyLoaded();
}
protected override void VerifyLoaded()
{
// Fail fast if not on the login page
Driver.WaitForSelectorAsync("[data-testid='login-form']")
.GetAwaiter().GetResult();
}
public async Task<DashboardPage> LoginAsync(string email, string password)
{
await Driver.FillAsync("[data-testid='email-input']", email);
await Driver.FillAsync("[data-testid='password-input']", password);
await Driver.ClickAsync("[data-testid='login-button']");
await Driver.WaitForURLAsync("**/dashboard");
return new DashboardPage(Driver);
}
public async Task<string> GetErrorMessageAsync()
{
var error = Driver.Locator("[data-testid='login-error']");
return await error.TextContentAsync() ?? "";
}
}
// Usage in test
[Fact]
public async Task Login_ValidCredentials_RedirectsToDashboard()
{
var loginPage = new LoginPage(Page);
var dashboard = await loginPage.LoginAsync("[email protected]", "P@ssw0rd!");
Assert.NotNull(dashboard);
}
页面对象原则
- 导航操作应返回下一个页面对象。
LoginAsync返回DashboardPage,引导测试编写者依照应用流程进行操作。 - 切勿从页面对象中公开原始选择器。 测试应调用
LoginAsync(),而不是ClickAsync("#submit")。 - 断言应放在测试中,而非页面对象中。 页面对象提供数据(例如,
GetErrorMessageAsync());测试根据这些数据进行断言。 - 通过可复用组件组合页面对象。 每个包含导航栏的页面都可以嵌入
NavigationMenu组件对象。
测试选择器策略
选择器决定测试如何查找 UI 元素。脆弱的选择器是导致 UI 测试不稳定的首要原因。
选择器优先级(从高到低)
| 优先级 | 选择器类型 | 示例 | 可靠性 |
|---|---|---|---|
| 1 | data-testid | [data-testid='submit-btn'] | 最高——可经受 CSS/布局变更 |
| 2 | 无障碍角色 + 名称 | GetByRole(AriaRole.Button, new() { Name = "Submit" }) | 高——与可见行为相关 |
| 3 | 标签文本 | GetByLabel("Email address") | 高——文案变更时也会变化 |
| 4 | 占位文本 | GetByPlaceholder("Enter email") | 中——通常会本地化 |
| 5 | CSS 类 | .btn-primary | 低——样式变更时会变化 |
| 6 | XPath / DOM 结构 | //div[3]/button[1] | 最低——任何布局变更都可能导致失效 |
添加测试 ID
为测试交互的元素添加 data-testid 属性。这些属性对用户不可见,且在重构后保持稳定:
Blazor:
<button data-testid="submit-order" @onclick="SubmitOrder">Place Order</button>
<input data-testid="search-input" @bind="SearchTerm" />
MAUI XAML:
<Button AutomationId="submit-order" Text="Place Order" Clicked="OnSubmit" />
<Entry AutomationId="search-input" Text="{Binding SearchTerm}" />
Uno Platform XAML:
<Button AutomationProperties.AutomationId="submit-order" Content="Place Order" />
选择器反模式
// BAD: Tied to CSS implementation
await page.ClickAsync(".MuiButton-root.MuiButton-containedPrimary");
// BAD: Tied to DOM structure
await page.ClickAsync("div > form > div:nth-child(3) > button");
// BAD: Tied to dynamic content
await page.ClickAsync($"text=Order #{orderId}");
// GOOD: Stable test identifier
await page.ClickAsync("[data-testid='submit-order']");
// GOOD: Accessibility-driven (Playwright)
await page.GetByRole(AriaRole.Button, new() { Name = "Place Order" }).ClickAsync();
异步等待策略
UI 测试需要处理异步渲染、网络请求和动画。硬编码延迟会导致测试不稳定,并拖慢测试套件。
等待策略决策树
Is the element already in the DOM?
|
+-- YES --> Is it visible and actionable?
| |
| +-- YES --> Interact immediately
| +-- NO --> Wait for visibility/enabled state
|
+-- NO --> Wait for element to appear in DOM
|
Is it loaded via network request?
|
+-- YES --> Wait for network idle or specific API response
+-- NO --> Wait for render cycle to complete
特定框架的等待模式
Playwright(基于浏览器):
// Auto-waiting: Playwright waits for actionability by default
await page.ClickAsync("[data-testid='submit']"); // waits until visible + enabled
// Explicit wait for network-loaded content
await page.WaitForResponseAsync(
response => response.Url.Contains("/api/orders") && response.Status == 200);
// Wait for element state
await page.Locator("[data-testid='results']")
.WaitForAsync(new() { State = WaitForSelectorState.Visible });
// Wait for specific text content
await Expect(page.Locator("[data-testid='status']")).ToHaveTextAsync("Completed");
bUnit(Blazor 组件测试):
// Wait for async state changes to render
var cut = RenderComponent<OrderList>();
// Wait for component to finish async operations
cut.WaitForState(() => cut.Instance.Orders.Count > 0,
timeout: TimeSpan.FromSeconds(5));
// Wait for specific markup
cut.WaitForAssertion(() =>
Assert.NotEmpty(cut.FindAll("[data-testid='order-row']")),
timeout: TimeSpan.FromSeconds(5));
等待反模式
// BAD: Hardcoded delay -- slow and still flaky
await Task.Delay(3000);
await page.ClickAsync("[data-testid='results']");
// BAD: Polling with Thread.Sleep
while (!element.IsVisible)
{
Thread.Sleep(100); // blocks thread, no timeout safety
}
// GOOD: Framework-native wait
await page.Locator("[data-testid='results']")
.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 5000 });
// GOOD: Assertion with retry (Playwright)
await Expect(page.Locator("[data-testid='count']")).ToHaveTextAsync("5");
无障碍测试
无障碍测试用于验证 UI 组件是否便于残障人士使用,以及是否兼容辅助技术。自动化检查可以发现常见问题;对于主观标准,仍需人工审核。
使用 Playwright 进行自动化无障碍检查
// NuGet: Deque.AxeCore.Playwright
[Fact]
public async Task HomePage_PassesAccessibilityAudit()
{
await Page.GotoAsync("/");
var results = await Page.RunAxe();
Assert.Empty(results.Violations);
}
[Fact]
public async Task OrderForm_NoAccessibilityViolations()
{
await Page.GotoAsync("/orders/new");
// Scope to specific component
var form = Page.Locator("[data-testid='order-form']");
var results = await Page.RunAxe(new AxeRunOptions
{
// Focus on WCAG 2.1 AA rules
RunOnly = new RunOnlyOptions
{
Type = "tag",
Values = ["wcag2a", "wcag2aa", "wcag21aa"]
}
});
// Report violations with details for debugging
foreach (var violation in results.Violations)
{
// Log: violation.Id, violation.Description, violation.Nodes
}
Assert.Empty(results.Violations);
}
UI 测试无障碍检查清单
| 检查项 | 测试方法 | 工具 |
|---|---|---|
| 颜色对比度 | 自动化 axe-core 规则 | Deque.AxeCore.Playwright |
| 键盘导航 | 使用 Tab 键遍历所有交互元素 | Playwright page.Keyboard |
| ARIA 标签 | 验证是否存在 aria-label / aria-labelledby | Playwright 定位器 + 断言 |
| 焦点管理 | 验证焦点是否移动到对话框/模态框 | Playwright page.Locator(':focus') |
| 屏幕阅读器文本 | 验证 aria-live 区域是否更新 | 手动检查 + 对 ARIA 属性进行断言 |
键盘导航测试示例
[Fact]
public async Task OrderForm_TabOrder_FollowsLogicalSequence()
{
await Page.GotoAsync("/orders/new");
// Tab through form fields and verify focus order
await Page.Keyboard.PressAsync("Tab");
await Expect(Page.Locator("[data-testid='customer-name']")).ToBeFocusedAsync();
await Page.Keyboard.PressAsync("Tab");
await Expect(Page.Locator("[data-testid='customer-email']")).ToBeFocusedAsync();
await Page.Keyboard.PressAsync("Tab");
await Expect(Page.Locator("[data-testid='order-items']")).ToBeFocusedAsync();
// Verify Enter submits the form
await Page.Keyboard.PressAsync("Tab"); // focus submit button
await Expect(Page.Locator("[data-testid='submit-order']")).ToBeFocusedAsync();
}
关键原则
- 对于包含多个测试的 UI 测试套件,使用页面对象模型。 前期投入的成本很快就能通过减少维护工作收回。
- 优先使用
data-testid或基于无障碍特性的选择器,而不是 CSS 或 DOM 结构选择器。 稳定的选择器是避免测试不稳定的最有效方法。 - 绝不要使用
Thread.Sleep或Task.Delay作为等待策略。 使用框架原生等待,在超时时间内轮询条件。 - 将无障碍检查纳入标准测试套件, 不要将其作为单独的审计。及早发现违规问题可避免积累无障碍技术债。
- 尽可能让页面对象与框架无关。 这些模式(POM、选择器策略、等待模式)具有通用性;Playwright、bUnit 和 Appium 之间只有驱动程序 API 不同。
Agent 常见注意事项
- 未经团队同意,不要在生产代码中添加
data-testid属性。 有些团队会在生产构建中移除这些属性;有些团队则会保留。请先确认项目约定。 - 不要在 Playwright 测试中使用
WaitForTimeout(硬编码延迟)。 它会掩盖时序问题,并拖慢测试。请改用WaitForSelectorAsync、Expect(...).ToBeVisibleAsync()或WaitForResponseAsync。 - 在列表加载完成前,不要断言元素数量。 如果组件尚未完成渲染,
FindAll("[data-testid='row']").Count会返回 0。请先使用WaitForState或WaitForAssertion。 - 不要以“这不是必需项”为由跳过无障碍测试。 WCAG 合规性日益成为法律要求。自动化检查几乎无需成本,就能发现容易解决的问题。
- 不要创建深度嵌套的页面对象。 如果页面对象中还包含页面对象,请扁平化层级。一级组件组合(页面 -> 组件)就足够了。