name: dotnet-uno-testing description: “测试Uno Platform应用程序。用于WASM的Playwright,平台特定模式,运行时头部。” user-invocable: false
dotnet-uno-testing
跨目标头部(WASM、桌面、移动)测试Uno Platform应用程序。涵盖基于Playwright的浏览器自动化用于Uno WASM应用程序、不同运行时头部的平台特定测试模式,以及跨平台Uno项目的测试基础设施。
版本假设: .NET 8.0+ 基线,Uno Platform 5.x+,用于WASM测试的Playwright 1.40+。Uno Platform使用具有多个目标框架的单项目结构。
范围
- 用于Uno WASM应用程序的基于Playwright的浏览器自动化
- 不同运行时头部的平台特定测试模式
- 跨平台Uno项目的测试基础设施
范围外
- 共享UI测试模式(页面对象模型、选择器、等待策略) – 参见 [skill:dotnet-ui-testing-core]
- Playwright基础(安装、CI缓存、跟踪查看器) – 参见 [skill:dotnet-playwright]
- 测试项目脚手架 – 参见 [skill:dotnet-add-testing]
先决条件: 配置了WASM头部的Uno Platform应用程序。对于WASM测试:安装了Playwright浏览器(参见 [skill:dotnet-playwright])。对于移动测试:配置了平台SDK(Android SDK、Xcode)。
交叉引用: [skill:dotnet-ui-testing-core] 用于页面对象模型和选择器策略, [skill:dotnet-playwright] 用于Playwright安装、CI缓存和跟踪查看器, [skill:dotnet-uno-platform] 用于Uno扩展、MVUX、工具包和主题指导, [skill:dotnet-uno-targets] 用于每个目标的部署和平台特定陷阱。
按头部划分的Uno测试策略
Uno Platform应用程序在多个头部上运行(WASM、桌面/Skia、iOS、Android、Windows)。每个头部有不同的测试工具和权衡。
| 头部 | 测试方法 | 工具 | 速度 | 保真度 |
|---|---|---|---|---|
| WASM | 浏览器自动化 | Playwright | 中等 | 高 – 真实浏览器渲染 |
| 桌面 (Skia/GTK, WPF) | UI自动化 | Appium / WinAppDriver | 中等 | 高 – 真实桌面渲染 |
| iOS | 模拟器自动化 | Appium + XCUITest | 慢 | 最高 – 真实iOS渲染 |
| Android | 模拟器自动化 | Appium + UIAutomator2 | 慢 | 最高 – 真实Android渲染 |
| 单元 (共享逻辑) | 内存中 | xUnit (无UI) | 快 | N/A – 仅逻辑 |
推荐优先级: 首先用单元测试测试共享业务逻辑。使用Playwright针对WASM头部进行UI验证 – 这是最快的UI测试路径,覆盖最广。仅对头部之间不同的行为添加平台特定的Appium测试。
用于Uno WASM的Playwright
WASM头部在浏览器中渲染Uno应用程序,使Playwright成为UI测试的自然选择。
测试基础设施
// NuGet: Microsoft.Playwright
public class UnoWasmFixture : IAsyncLifetime
{
public IPlaywright Playwright { get; private set; } = null!;
public IBrowser Browser { get; private set; } = null!;
public IPage Page { get; private set; } = null!;
private Process? _serverProcess;
public async ValueTask InitializeAsync()
{
// 启动WASM应用程序(dotnet run或服务发布的输出)
_serverProcess = await StartWasmServerAsync();
Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
Page = await Browser.NewPageAsync();
// 等待Uno WASM应用程序完全加载
await Page.GotoAsync("http://localhost:5000");
await WaitForUnoAppReadyAsync();
}
public async ValueTask DisposeAsync()
{
await Page.CloseAsync();
await Browser.CloseAsync();
Playwright.Dispose();
_serverProcess?.Kill(entireProcessTree: true);
_serverProcess?.Dispose();
}
private async Task WaitForUnoAppReadyAsync()
{
// Uno WASM应用程序显示加载启动画面;等待应用程序根元素出现
await Page.WaitForSelectorAsync(
"[data-testid='app-root'], #uno-body",
new() { State = WaitForSelectorState.Visible, Timeout = 30_000 });
// 额外等待Uno运行时初始化
await Page.WaitForFunctionAsync(
"() => document.querySelector('#uno-body')?.children.length > 0",
null,
new() { Timeout = 15_000 });
}
private static async Task<Process> StartWasmServerAsync()
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "run --project src/MyApp/MyApp.Wasm.csproj --no-build",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
}
};
process.Start();
// 通过探测健康端点等待服务器准备就绪
using var httpClient = new HttpClient();
var deadline = DateTime.UtcNow.AddSeconds(30);
while (DateTime.UtcNow < deadline)
{
try
{
var resp = await httpClient.GetAsync("http://localhost:5000");
if (resp.IsSuccessStatusCode) break;
}
catch (HttpRequestException)
{
// 服务器尚未准备就绪
}
await Task.Delay(500);
}
return process;
}
}
WASM UI测试
public class UnoNavigationTests : IClassFixture<UnoWasmFixture>
{
private readonly IPage _page;
public UnoNavigationTests(UnoWasmFixture fixture)
{
_page = fixture.Page;
}
[Fact]
public async Task MainPage_LoadsSuccessfully()
{
// Uno WASM将XAML控件渲染为HTML元素
// 使用AutomationProperties.AutomationId进行选择器
var title = _page.Locator("[data-testid='main-title']");
await Expect(title).ToBeVisibleAsync();
await Expect(title).ToHaveTextAsync("Welcome");
}
[Fact]
public async Task Navigation_ClickSettings_ShowsSettingsPage()
{
await _page.ClickAsync("[data-testid='nav-settings']");
var settingsHeader = _page.Locator("[data-testid='settings-header']");
await Expect(settingsHeader).ToBeVisibleAsync();
await Expect(settingsHeader).ToHaveTextAsync("Settings");
}
}
表单交互测试
[Fact]
public async Task LoginForm_SubmitValid_NavigatesToDashboard()
{
await _page.FillAsync("[data-testid='username-input']", "testuser");
await _page.FillAsync("[data-testid='password-input']", "P@ssw0rd!");
await _page.ClickAsync("[data-testid='login-button']");
// 登录后等待导航
var dashboard = _page.Locator("[data-testid='dashboard-title']");
await Expect(dashboard).ToBeVisibleAsync(
new() { Timeout = 10_000 });
}
[Fact]
public async Task TodoList_AddItem_AppearsInList()
{
await _page.FillAsync("[data-testid='todo-input']", "Buy groceries");
await _page.ClickAsync("[data-testid='add-todo-btn']");
var items = _page.Locator("[data-testid='todo-item']");
await Expect(items).ToHaveCountAsync(1);
await Expect(items.First).ToContainTextAsync("Buy groceries");
}
平台特定测试
用于跨平台选择器的AutomationProperties
Uno将 AutomationProperties.AutomationId 映射到每个平台的原生标识符:
<!-- Uno XAML -- 适用于所有头部 -->
<Page x:Class="MyApp.Views.LoginPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel>
<TextBox AutomationProperties.AutomationId="username-input"
PlaceholderText="Username" />
<PasswordBox AutomationProperties.AutomationId="password-input"
PlaceholderText="Password" />
<Button AutomationProperties.AutomationId="login-button"
Content="Log In" />
<TextBlock AutomationProperties.AutomationId="error-message"
Foreground="Red" />
</StackPanel>
</Page>
平台映射:
- WASM: 渲染为
data-testid属性(Playwright选择器) - Android: 映射到
content-desc(AppiumAccessibilityId) - iOS: 映射到
accessibilityIdentifier(AppiumAccessibilityId) - Windows: 映射到
AutomationId(WinAppDriverAccessibilityId)
测试平台特定代码
对于因平台而异的代码,使用条件编译和单独的测试类:
// 共享测试 -- 在所有平台上运行
[Fact]
public async Task Settings_ChangeTheme_UpdatesUI()
{
await _page.ClickAsync("[data-testid='theme-toggle']");
var body = _page.Locator("[data-testid='app-root']");
await Expect(body).ToHaveAttributeAsync("data-theme", "dark");
}
// 平台特定测试
[Fact]
[Trait("Platform", "WASM")]
public async Task FileUpload_BrowserDialog_AcceptsFiles()
{
// WASM使用浏览器文件选择器 -- 使用Playwright文件选择器测试
var fileChooserTask = _page.WaitForFileChooserAsync();
await _page.ClickAsync("[data-testid='upload-btn']");
var fileChooser = await fileChooserTask;
await fileChooser.SetFilesAsync("testdata/sample.pdf");
var fileName = _page.Locator("[data-testid='file-name']");
await Expect(fileName).ToHaveTextAsync("sample.pdf");
}
运行时头部验证
使用共享测试逻辑和平台特定驱动验证相同的UI逻辑在不同Uno运行时头部上正确工作。
共享测试逻辑模式
/// <summary>
/// 定义UI测试一次的抽象基类。具体子类提供
/// 每个平台的驱动(WASM的Playwright,移动的Appium)。
/// </summary>
public abstract class LoginTestsBase
{
protected abstract Task FillFieldAsync(string automationId, string value);
protected abstract Task ClickAsync(string automationId);
protected abstract Task<string> GetTextAsync(string automationId);
protected abstract Task WaitForElementAsync(string automationId, int timeoutMs = 5000);
[Fact]
public async Task Login_ValidCredentials_ShowsDashboard()
{
await FillFieldAsync("username-input", "alice");
await FillFieldAsync("password-input", "P@ssw0rd!");
await ClickAsync("login-button");
await WaitForElementAsync("dashboard-title");
var title = await GetTextAsync("dashboard-title");
Assert.Equal("Dashboard", title);
}
[Fact]
public async Task Login_EmptyPassword_ShowsValidationError()
{
await FillFieldAsync("username-input", "alice");
await ClickAsync("login-button");
await WaitForElementAsync("error-message");
var error = await GetTextAsync("error-message");
Assert.Contains("required", error, StringComparison.OrdinalIgnoreCase);
}
}
// WASM实现
public class LoginTestsWasm : LoginTestsBase, IClassFixture<UnoWasmFixture>
{
private readonly IPage _page;
public LoginTestsWasm(UnoWasmFixture fixture) => _page = fixture.Page;
protected override async Task FillFieldAsync(string automationId, string value) =>
await _page.FillAsync($"[data-testid='{automationId}']", value);
protected override async Task ClickAsync(string automationId) =>
await _page.ClickAsync($"[data-testid='{automationId}']");
protected override async Task<string> GetTextAsync(string automationId) =>
await _page.Locator($"[data-testid='{automationId}']").TextContentAsync() ?? "";
protected override async Task WaitForElementAsync(string automationId, int timeoutMs = 5000) =>
await _page.WaitForSelectorAsync(
$"[data-testid='{automationId}']",
new() { Timeout = timeoutMs });
}
关键原则
- 首先用单元测试测试共享逻辑。 Uno的MVVM模式意味着大多数业务逻辑可以在没有任何UI框架的情况下测试。
- 使用Playwright + WASM作为主要UI测试路径。 它比移动模拟器更快,并在浏览器中提供真实的渲染保真度。
- 在所有可测试控件上使用
AutomationProperties.AutomationId。 这是唯一在所有Uno头部上相同工作的选择器策略。 - 将共享测试与平台特定测试分开。 使用抽象基类进行共享测试逻辑,每个平台的具体子类。
- 仅对平台分歧行为添加平台特定测试。 文件选择器、硬件按钮、手势和通知在不同平台上不同;单独测试这些。
代理陷阱
- 不要假设Uno WASM应用程序立即加载。 Uno运行时必须初始化(mono WASM引导),这需要几秒钟。在交互之前始终等待应用程序根元素。
- 不要对Uno WASM元素使用CSS选择器。 Uno生成自己的DOM结构;CSS类是内部且不稳定的。专门使用
data-testid(来自AutomationProperties.AutomationId)。 - 不要忘记在运行Playwright测试之前构建WASM头部。
dotnet run按需构建,但dotnet publish需要用于类似生产的测试。过时的构建会导致混淆的测试失败。 - 不要在WASM头部测试移动特定功能。 文件系统访问、推送通知、生物识别和NFC在浏览器中不可用。在WASM测试中跳过或模拟这些。
- 不要在所有平台测试中运行单个CI作业。 每个平台需要自己的SDK(Android SDK、Xcode、WinAppDriver)。使用每个平台的单独CI作业与适当的运行器。