mcpField
D
dotnet-uno-testing

dotnet-uno-testing

github.com/wshaddix测试与质量8666 次安装35 周前更新
代码仓库

关于 dotnet-uno-testing

面向 Uno Platform 开发者的跨平台测试技能,涵盖 WASM、桌面和移动端测试策略。重点介绍 Playwright 浏览器测试、跨平台标识符及共享测试逻辑。

主要功能

  1. 01按运行目标选择测试方案
  2. 02使用 Playwright 测试 WASM
  3. 03通过 AutomationId 定位控件
  4. 04复用多平台测试逻辑
  5. 05区分共享与平台专属测试

适用场景

  1. 01验证 Uno WASM 界面交互
  2. 02检查跨平台一致的控件行为
  3. 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(Appium AccessibilityId)
  • **iOS:**映射到 accessibilityIdentifier(Appium AccessibilityId)
  • **Windows:**映射到 AutomationId(WinAppDriver AccessibilityId)
测试平台专属代码

对于因平台而异的代码,请使用条件编译和单独的测试类:

// 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 注意事项

  1. 不要假设 Uno WASM 应用会立即加载。 Uno 运行时必须先初始化(mono WASM 引导),这需要几秒钟。交互前务必等待应用根元素出现。
  2. 不要对 Uno WASM 元素使用 CSS 选择器。 Uno 会生成自己的 DOM 结构;CSS 类属于内部实现,且不稳定。请仅使用 data-testid(来自 AutomationProperties.AutomationId)。
  3. 运行 Playwright 测试前不要忘记构建 WASM 平台端。 dotnet run 会按需构建,但生产环境测试需要使用 dotnet publish。过期的构建会导致难以理解的测试失败。
  4. 不要在 WASM 平台端测试移动设备专属功能。 浏览器不支持文件系统访问、推送通知、生物识别和 NFC。请在 WASM 测试中跳过或模拟这些功能。
  5. 不要在单个 CI 作业中运行所有平台测试。 每个平台都需要自己的 SDK(Android SDK、Xcode、WinAppDriver)。请使用适当的运行器,为每个平台设置单独的 CI 作业。

参考资料

评论

支持 Markdown