名称:dotnet-ui-testing-core 描述:“跨框架测试 UI。页面对象、测试选择器、异步等待、可访问性。” 用户可调用:false
dotnet-ui-testing-core
适用于 .NET UI 框架(Blazor、MAUI、Uno Platform)的核心 UI 测试模式。涵盖用于可维护测试结构的页面对象模型、用于可靠元素识别的测试选择器策略、用于非确定性 UI 的异步等待模式以及可访问性测试方法。
版本假设: .NET 8.0+ 基线。框架特定细节委托给专用技能。
范围
- 用于可维护测试结构的页面对象模型
- 用于可靠元素识别的测试选择器策略
- 用于非确定性 UI 的异步等待模式
- 可访问性测试方法
超出范围
- Blazor 组件测试 (bUnit) – 见 [skill:dotnet-blazor-testing]
- MAUI UI 测试 (Appium/XHarness) – 见 [skill:dotnet-maui-testing]
- Uno Platform WASM 测试 – 见 [skill:dotnet-uno-testing]
- 浏览器自动化详情 – 见 [skill:dotnet-playwright]
- 测试项目脚手架 – 见 [skill:dotnet-add-testing]
先决条件: 通过 [skill:dotnet-add-testing] 搭建的测试项目。熟悉来自 [skill:dotnet-testing-strategy] 的测试策略决策。
交叉引用:用于决定何时适合 UI 测试的 [skill:dotnet-testing-strategy]、用于基于浏览器的 E2E 自动化的 [skill:dotnet-playwright]、用于 Blazor 组件测试的 [skill:dotnet-blazor-testing]、用于移动/桌面 UI 测试的 [skill:dotnet-maui-testing]、用于 Uno Platform 测试的 [skill:dotnet-uno-testing]。
页面对象模型
页面对象模型(POM)将页面结构和交互封装在类后面,隔离测试与 UI 实现细节。当 UI 更改时,只需更新页面对象——而不是每个接触该页面的测试。
结构
PageObjects/
LoginPage.cs -- 登录表单交互
DashboardPage.cs -- 仪表板导航 + 小部件
OrderListPage.cs -- 订单列表过滤 + 选择
Components/
NavigationMenu.cs -- 共享导航组件
ConfirmDialog.cs -- 可重用确认模态框
示例:通用页面对象基类
/// <summary>
/// 页面对象的基类。按框架子类化:
/// Playwright 使用 IPage,bUnit 使用 IRenderedComponent,Appium 使用 AppiumDriver。
/// </summary>
public abstract class PageObjectBase<TDriver>
{
protected TDriver Driver { get; }
protected PageObjectBase(TDriver driver)
{
Driver = driver;
}
/// <summary>
/// 验证页面/组件在导航后处于预期状态。
/// 在构造函数或导航后调用此方法以快速失败在错误页面上。
/// </summary>
protected abstract void VerifyLoaded();
}
示例:Playwright 页面对象
public class LoginPage : PageObjectBase<IPage>
{
public LoginPage(IPage page) : base(page)
{
VerifyLoaded();
}
protected override void VerifyLoaded()
{
// 快速失败,如果不在登录页面上
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() ?? "";
}
}
// 测试中使用
[Fact]
public async Task Login_ValidCredentials_RedirectsToDashboard()
{
var loginPage = new LoginPage(Page);
var dashboard = await loginPage.LoginAsync("user@example.com", "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">下订单</button>
<input data-testid="search-input" @bind="SearchTerm" />
MAUI XAML:
<Button AutomationId="submit-order" Text="下订单" Clicked="OnSubmit" />
<Entry AutomationId="search-input" Text="{Binding SearchTerm}" />
Uno Platform XAML:
<Button AutomationProperties.AutomationId="submit-order" Content="下订单" />
选择器反模式
// 错误:绑定到 CSS 实现
await page.ClickAsync(".MuiButton-root.MuiButton-containedPrimary");
// 错误:绑定到 DOM 结构
await page.ClickAsync("div > form > div:nth-child(3) > button");
// 错误:绑定到动态内容
await page.ClickAsync($"text=Order #{orderId}");
// 好:稳定的测试标识符
await page.ClickAsync("[data-testid='submit-order']");
// 好:可访问性驱动(Playwright)
await page.GetByRole(AriaRole.Button, new() { Name = "下订单" }).ClickAsync();
异步等待策略
UI 测试处理异步渲染、网络请求和动画。硬编码延迟会导致不稳定测试和慢速测试套件。
等待策略决策树
元素是否已在 DOM 中?
|
+-- 是 --> 它是否可见且可操作?
| |
| +-- 是 --> 立即交互
| +-- 否 --> 等待可见/启用状态
|
+-- 否 --> 等待元素出现在 DOM 中
|
是否通过网络请求加载?
|
+-- 是 --> 等待网络空闲或特定 API 响应
+-- 否 --> 等待渲染周期完成
框架特定等待模式
Playwright(基于浏览器):
// 自动等待:Playwright 默认等待可操作性
await page.ClickAsync("[data-testid='submit']"); // 等待直到可见 + 启用
// 显式等待网络加载内容
await page.WaitForResponseAsync(
response => response.Url.Contains("/api/orders") && response.Status == 200);
// 等待元素状态
await page.Locator("[data-testid='results']")
.WaitForAsync(new() { State = WaitForSelectorState.Visible });
// 等待特定文本内容
await Expect(page.Locator("[data-testid='status']")).ToHaveTextAsync("已完成");
bUnit(Blazor 组件测试):
// 等待异步状态更改以渲染
var cut = RenderComponent<OrderList>();
// 等待组件完成异步操作
cut.WaitForState(() => cut.Instance.Orders.Count > 0,
timeout: TimeSpan.FromSeconds(5));
// 等待特定标记
cut.WaitForAssertion(() =>
Assert.NotEmpty(cut.FindAll("[data-testid='order-row']")),
timeout: TimeSpan.FromSeconds(5));
等待反模式
// 错误:硬编码延迟——慢且仍然不稳定
await Task.Delay(3000);
await page.ClickAsync("[data-testid='results']");
// 错误:使用 Thread.Sleep 轮询
while (!element.IsVisible)
{
Thread.Sleep(100); // 阻塞线程,无超时安全性
}
// 好:框架原生等待
await page.Locator("[data-testid='results']")
.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 5000 });
// 好:带重试的断言(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");
// 范围到特定组件
var form = Page.Locator("[data-testid='order-form']");
var results = await Page.RunAxe(new AxeRunOptions
{
// 专注于 WCAG 2.1 AA 规则
RunOnly = new RunOnlyOptions
{
Type = "tag",
Values = ["wcag2a", "wcag2aa", "wcag21aa"]
}
});
// 报告违规细节以调试
foreach (var violation in results.Violations)
{
// 记录:violation.Id、violation.Description、violation.Nodes
}
Assert.Empty(results.Violations);
}
UI 测试的可访问性检查清单
| 检查 | 如何测试 | 工具 |
|---|---|---|
| 颜色对比度 | 自动 axe-core 规则 | Deque.AxeCore.Playwright |
| 键盘导航 | 通过所有交互元素标签 | 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");
// 通过表单字段标签并验证焦点顺序
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();
// 验证 Enter 提交表单
await Page.Keyboard.PressAsync("Tab"); // 焦点提交按钮
await Expect(Page.Locator("[data-testid='submit-order']")).ToBeFocusedAsync();
}
关键原则
- 对任何超过少量测试的 UI 测试套件使用页面对象模型。 前期成本很快在减少维护中回报。
- 优先使用
data-testid或基于可访问性的选择器而非 CSS 或 DOM 结构选择器。 稳定的选择器是对不稳定测试的最有效防御。 - 永远不要使用
Thread.Sleep或Task.Delay作为等待策略。 使用框架原生等待,在超时下轮询条件。 - 将可访问性检查作为标准测试套件的一部分运行, 而不是作为单独审计。 早期捕捉违规防止可访问性债务。
- 在可能的情况下保持页面对象框架无关。 模式(POM、选择器策略、等待模式)是通用的;仅驱动程序 API 在 Playwright、bUnit 和 Appium 之间更改。
代理陷阱
- 未经团队同意,不要在生产代码中添加
data-testid属性。 有些团队在生产构建中移除它们;另一些保留它们。首先检查项目的约定。 - 不要在 Playwright 测试中使用
WaitForTimeout(硬编码延迟)。 它掩盖时机问题并使测试变慢。改用WaitForSelectorAsync、Expect(...).ToBeVisibleAsync()或WaitForResponseAsync。 - 不要断言元素数量而不等待列表加载。
FindAll("[data-testid='row']").Count如果组件未完成渲染,返回零。首先使用WaitForState或WaitForAssertion。 - 不要因为“不是要求”而跳过可访问性测试。 WCAG 合规性日益成为法律要求。自动检查以几乎零成本捕捉低挂果实。
- 不要创建深度嵌套的页面对象。 如果页面对象中有页面对象,请展平层次结构。一级组件组合(页面 -> 组件)足够。