关于 dotnet-uno-testing
面向 Uno Platform 开发者的跨平台测试技能,涵盖 WASM、桌面和移动端测试策略。重点介绍 Playwright 浏览器测试、跨平台标识符及共享测试逻辑。
主要功能
- 01按运行目标选择测试方案
- 02使用 Playwright 测试 WASM
- 03通过 AutomationId 定位控件
- 04复用多平台测试逻辑
- 05区分共享与平台专属测试
适用场景
- 01验证 Uno WASM 界面交互
- 02检查跨平台一致的控件行为
- 03测试仅在特定平台出现的功能
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-uno-testing安装后在 Claude Code 中输入/dotnet-uno-testing确认已加载。
详细介绍
dotnet-uno-testing
在不同目标 head(WASM、Desktop、Mobile)上测试 Uno Platform 应用。涵盖基于 Playwright 的 Uno WASM 应用浏览器自动化、不同运行时 head 的平台专属测试模式,以及跨平台 Uno 项目的测试基础设施。
**版本假设:**基于 .NET 8.0+,Uno Platform 5.x+,WASM 测试使用 Playwright 1.40+。Uno Platform 采用包含多个目标框架的单项目结构。
**不在范围内:**通用 UI 测试模式(页面对象模型、选择器、等待策略)见 [skill:dotnet-ui-testing-core]。Playwright 基础知识(安装、CI 缓存、跟踪查看器)见 [skill:dotnet-playwright]。测试项目脚手架由 [skill:dotnet-add-testing] 负责。
**前置条件:**已配置 WASM head 的 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 Extensions、MVUX、Toolkit 和主题指南;[skill:dotnet-uno-targets] 介绍各目标的部署和平台专属注意事项。
Uno 各 Head 的测试策略
Uno Platform 应用可在多个 head 上运行(WASM、Desktop/Skia、iOS、Android、Windows)。每个 head 使用的测试工具和权衡各不相同。
| Head | 测试方式 | 工具 | 速度 | 保真度 |
|---|---|---|---|---|
| WASM | 浏览器自动化 | Playwright | 中 | 高 -- 真实浏览器渲染 |
| Desktop (Skia/GTK, WPF) | UI 自动化 | Appium / WinAppDriver | 中 | 高 -- 真实桌面渲染 |
| iOS | 模拟器自动化 | Appium + XCUITest | 慢 | 最高 -- 真实 iOS 渲染 |
| Android | 模拟器自动化 | Appium + UIAutomator2 | 慢 | 最高 -- 真实 Android 渲染 |
| Unit (shared logic) | 内存中 | xUnit(无 UI) | 快 | 不适用 -- 仅测试逻辑 |
**建议优先级:**首先使用单元测试检查共享业务逻辑。使用 Playwright 对 WASM head 进行 UI 验证,这是速度最快、覆盖面最广的 UI 测试途径。仅针对各 head 之间存在差异的行为添加平台专属 Appium 测试。
Uno WASM 的 Playwright 测试
WASM head 会在浏览器中渲染 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()
{
// Start the WASM app (dotnet run or serve the published output)
_serverProcess = await StartWasmServerAsync();
Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
Page = await Browser.NewPageAsync();
// Wait for Uno WASM app to fully load
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 apps show a loading splash; wait for the app root to appear
await Page.WaitForSelectorAsync(
"[data-testid='app-root'], #uno-body",
new() { State = WaitForSelectorState.Visible, Timeout = 30_000 });
// Additional wait for Uno runtime initialization
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();
// Wait for server to be ready by probing the health endpoint
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)
{
// Server not ready yet
}
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 renders XAML controls as HTML elements
// Use AutomationProperties.AutomationId for selectors
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']");
// Wait for navigation after login
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 映射到各平台的原生标识符:
<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)
测试平台专属代码
对于因平台而异的代码,请使用条件编译和单独的测试类:
// Shared test -- runs on all platforms
[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");
}
// Platform-specific test
[Fact]
[Trait("Platform", "WASM")]
public async Task FileUpload_BrowserDialog_AcceptsFiles()
{
// WASM uses browser file picker -- test with Playwright file chooser
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");
}
运行时 Head 验证
使用共享测试逻辑和平台专属驱动程序,验证相同的 UI 逻辑在不同 Uno 运行时 head 上能否正常工作。
共享测试逻辑模式
/// <summary>
/// Abstract base that defines UI tests once. Concrete subclasses provide
/// the driver for each platform (Playwright for WASM, Appium for mobile).
/// </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 implementation
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 平台端保持一致的选择器策略。 - 将共享测试与特定平台的测试分开。 使用抽象基类编写共享测试逻辑,并为每个平台创建具体子类。
- 仅针对平台间行为差异添加特定平台的测试。 文件选择器、硬件按钮、手势和通知在不同平台上各不相同;应分别测试。
Agent 注意事项
- 不要假设 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 作业。