.NETMAUI自动化测试 dotnet-maui-testing

这个技能专注于使用 Appium 进行 UI 自动化和 XHarness 进行跨平台测试,来测试 .NET MAUI 应用程序。它涵盖了设备测试、平台特定行为验证、元素定位策略和测试基础设施,适用于移动开发和软件测试,关键词包括 .NET MAUI 测试、Appium、XHarness、跨平台自动化、UI 测试、移动应用测试和 DevOps。

测试 0 次安装 0 次浏览 更新于 3/6/2026

name: dotnet-maui-testing description: “测试 .NET MAUI 应用程序。使用 Appium 进行设备自动化、XHarness 进行跨平台测试和平台验证。” user-invocable: false

dotnet-maui-testing

使用 Appium 进行 UI 自动化和 XHarness 进行跨平台测试执行来测试 .NET MAUI 应用程序。涵盖设备和模拟器测试、平台特定行为验证、MAUI 控件的元素定位策略以及移动/桌面应用的测试基础设施。

版本假设: .NET 8.0+ 基线,Appium 2.x 使用 UIAutomator2(Android)和 XCUITest(iOS)驱动程序,XHarness 1.x。示例使用最新的 Appium .NET 客户端(5.x+)。

范围

  • Appium 2.x UI 自动化用于 Android、iOS 和 Windows
  • XHarness 跨平台测试执行
  • 平台特定行为验证
  • MAUI 控件的元素定位策略
  • 移动/桌面应用的测试基础设施

超出范围

  • 共享 UI 测试模式(页面对象模型、等待策略)-- 参见 [skill:dotnet-ui-testing-core]
  • 基于浏览器的测试 – 参见 [skill:dotnet-playwright]
  • 测试项目脚手架 – 参见 [skill:dotnet-add-testing]

先决条件: 通过 [skill:dotnet-add-testing] 搭建 MAUI 测试项目。安装 Appium 服务器(npm install -g appium)。对于 Android:配置 Android SDK 和模拟器。对于 iOS:Xcode 和模拟器(仅限 macOS)。对于 Windows:安装 WinAppDriver。

交叉引用: [skill:dotnet-ui-testing-core] 用于页面对象模型、测试选择器和异步等待模式, [skill:dotnet-xunit] 用于 xUnit 夹具和测试组织, [skill:dotnet-maui-development] 用于 MAUI 项目结构、XAML/MVVM 模式和平台服务, [skill:dotnet-maui-aot] 用于 iOS/Mac Catalyst 的 Native AOT 和 AOT 构建测试考虑。


Appium 设置用于 MAUI

<PackageReference Include="Appium.WebDriver" Version="5.*" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />

驱动程序初始化

public class AppiumFixture : IAsyncLifetime
{
    public AppiumDriver Driver { get; private set; } = null!;

    public ValueTask InitializeAsync()
    {
        var options = new AppiumOptions();

        if (OperatingSystem.IsAndroid() || TestConfig.TargetPlatform == "Android")
        {
            options.PlatformName = "Android";
            options.AutomationName = "UiAutomator2";
            options.App = TestConfig.AndroidApkPath;
            options.AddAdditionalAppiumOption("deviceName", "Pixel_7_API_34");
            options.AddAdditionalAppiumOption("avd", "Pixel_7_API_34");
        }
        else if (OperatingSystem.IsIOS() || TestConfig.TargetPlatform == "iOS")
        {
            options.PlatformName = "iOS";
            options.AutomationName = "XCUITest";
            options.App = TestConfig.iOSAppPath;
            options.AddAdditionalAppiumOption("deviceName", "iPhone 15");
            options.AddAdditionalAppiumOption("platformVersion", "17.2");
        }
        else if (OperatingSystem.IsWindows() || TestConfig.TargetPlatform == "Windows")
        {
            options.PlatformName = "Windows";
            options.AutomationName = "Windows";
            options.App = TestConfig.WindowsAppPath;
        }

        Driver = new AppiumDriver(
            new Uri("http://localhost:4723"), options);

        // 仅使用显式等待 -- 不要设置隐式等待(当与 WebDriverWait 结合时会导致累加超时行为)
        Driver.Manage().Timeouts().ImplicitWait = TimeSpan.Zero;

        return ValueTask.CompletedTask;
    }

    public ValueTask DisposeAsync()
    {
        Driver?.Quit();
        return ValueTask.CompletedTask;
    }
}

测试配置

public static class TestConfig
{
    // 通过环境变量或测试运行设置设置
    public static string TargetPlatform =>
        Environment.GetEnvironmentVariable("TEST_PLATFORM") ?? "Android";

    public static string AndroidApkPath =>
        Environment.GetEnvironmentVariable("ANDROID_APK_PATH")
        ?? Path.Combine(SolutionDir, "bin", "Release", "net8.0-android", "com.myapp-Signed.apk");

    public static string iOSAppPath =>
        Environment.GetEnvironmentVariable("IOS_APP_PATH")
        ?? Path.Combine(SolutionDir, "bin", "Release", "net8.0-ios", "MyApp.app");

    public static string WindowsAppPath =>
        Environment.GetEnvironmentVariable("WINDOWS_APP_PATH")
        ?? "com.mycompany.myapp_1.0.0.0_x64__9a0dh7ch11qe4!App";

    private static string SolutionDir =>
        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
}

使用 AutomationId 进行元素定位

MAUI 的 AutomationId 属性映射到平台原生的无障碍标识符。这是最可靠的跨平台测试选择器。

在 XAML 中设置 AutomationId

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">

    <VerticalStackLayout>
        <Entry AutomationId="username-input"
               Placeholder="用户名" />

        <Entry AutomationId="password-input"
               Placeholder="密码"
               IsPassword="True" />

        <Button AutomationId="login-button"
                Text="登录"
                Clicked="OnLoginClicked" />

        <Label AutomationId="error-message"
               TextColor="Red" />
    </VerticalStackLayout>
</ContentPage>

在测试中查找元素

public class LoginTests : IClassFixture<AppiumFixture>
{
    private readonly AppiumDriver _driver;

    public LoginTests(AppiumFixture fixture)
    {
        _driver = fixture.Driver;
    }

    [Fact]
    public void Login_ValidCredentials_NavigatesToHome()
    {
        // 通过 AutomationId 查找(映射到每个平台的无障碍 ID)
        var usernameField = _driver.FindElement(MobileBy.AccessibilityId("username-input"));
        var passwordField = _driver.FindElement(MobileBy.AccessibilityId("password-input"));
        var loginButton = _driver.FindElement(MobileBy.AccessibilityId("login-button"));

        usernameField.Clear();
        usernameField.SendKeys("testuser");
        passwordField.Clear();
        passwordField.SendKeys("P@ssw0rd!");
        loginButton.Click();

        // 等待导航
        var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10));
        var homeTitle = wait.Until(d =>
            d.FindElement(MobileBy.AccessibilityId("home-title")));

        Assert.Equal("欢迎", homeTitle.Text);
    }

    [Fact]
    public void Login_InvalidCredentials_ShowsError()
    {
        var usernameField = _driver.FindElement(MobileBy.AccessibilityId("username-input"));
        var passwordField = _driver.FindElement(MobileBy.AccessibilityId("password-input"));
        var loginButton = _driver.FindElement(MobileBy.AccessibilityId("login-button"));

        usernameField.Clear();
        usernameField.SendKeys("wrong");
        passwordField.Clear();
        passwordField.SendKeys("wrong");
        loginButton.Click();

        var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(5));
        var errorLabel = wait.Until(d =>
            d.FindElement(MobileBy.AccessibilityId("error-message")));

        Assert.Contains("无效", errorLabel.Text);
    }
}

MAUI 的页面对象模型

应用页面对象模型模式(参见 [skill:dotnet-ui-testing-core])与 Appium 的驱动程序:

public class LoginPage
{
    private readonly AppiumDriver _driver;
    private readonly WebDriverWait _wait;

    public LoginPage(AppiumDriver driver)
    {
        _driver = driver;
        _wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
        WaitForPageLoaded();
    }

    private AppiumElement UsernameField =>
        _driver.FindElement(MobileBy.AccessibilityId("username-input"));
    private AppiumElement PasswordField =>
        _driver.FindElement(MobileBy.AccessibilityId("password-input"));
    private AppiumElement LoginButton =>
        _driver.FindElement(MobileBy.AccessibilityId("login-button"));
    private AppiumElement ErrorMessage =>
        _driver.FindElement(MobileBy.AccessibilityId("error-message"));

    public HomePage Login(string username, string password)
    {
        UsernameField.Clear();
        UsernameField.SendKeys(username);
        PasswordField.Clear();
        PasswordField.SendKeys(password);
        LoginButton.Click();

        return new HomePage(_driver);
    }

    public string GetErrorText()
    {
        _wait.Until(d =>
        {
            var el = d.FindElement(MobileBy.AccessibilityId("error-message"));
            return !string.IsNullOrEmpty(el.Text);
        });
        return ErrorMessage.Text;
    }

    private void WaitForPageLoaded()
    {
        _wait.Until(d => d.FindElement(MobileBy.AccessibilityId("login-button")));
    }
}

// 使用
[Fact]
public void Login_ValidUser_ReachesHomePage()
{
    var loginPage = new LoginPage(_driver);
    var homePage = loginPage.Login("alice", "P@ssw0rd!");

    Assert.True(homePage.IsLoaded);
}

平台特定行为测试

按平台条件测试

public class PlatformTests : IClassFixture<AppiumFixture>
{
    private readonly AppiumDriver _driver;

    public PlatformTests(AppiumFixture fixture)
    {
        _driver = fixture.Driver;
    }

    [Fact]
    [Trait("Platform", "Android")]
    public void BackButton_Android_NavigatesBack()
    {
        // xUnit v3 原生跳过支持(不需要 SkippableFact 包)
        Assert.SkipWhen(TestConfig.TargetPlatform != "Android",
            "仅限 Android:硬件后退按钮");

        // 导航到详情页
        _driver.FindElement(MobileBy.AccessibilityId("item-1")).Click();

        // 按下 Android 后退按钮
        _driver.Navigate().Back();

        // 验证返回列表
        var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(5));
        wait.Until(d => d.FindElement(MobileBy.AccessibilityId("item-list")));
    }

    [Fact]
    [Trait("Platform", "iOS")]
    public void SwipeToDelete_iOS_RemovesItem()
    {
        // xUnit v3 原生跳过支持
        Assert.SkipWhen(TestConfig.TargetPlatform != "iOS",
            "仅限 iOS:滑动手势");

        var item = _driver.FindElement(MobileBy.AccessibilityId("item-1"));

        // 向左滑动以显示删除操作
        var swipe = new PointerInputDevice(PointerKind.Touch, "finger");
        var sequence = new ActionSequence(swipe);
        var itemLocation = item.Location;
        var itemSize = item.Size;

        sequence.AddAction(swipe.CreatePointerMove(
            item, itemSize.Width - 10, itemSize.Height / 2,
            TimeSpan.FromMilliseconds(0)));
        sequence.AddAction(swipe.CreatePointerDown(MouseButton.Left));
        sequence.AddAction(swipe.CreatePointerMove(
            item, 10, itemSize.Height / 2,
            TimeSpan.FromMilliseconds(300)));
        sequence.AddAction(swipe.CreatePointerUp(MouseButton.Left));

        _driver.PerformActions([sequence]);

        // 点击删除按钮
        var deleteBtn = _driver.FindElement(MobileBy.AccessibilityId("delete-action"));
        deleteBtn.Click();

        // 验证项目移除
        var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(5));
        wait.Until(d =>
        {
            var items = d.FindElements(MobileBy.AccessibilityId("item-1"));
            return items.Count == 0;
        });
    }
}

屏幕尺寸和方向

[Fact]
public void Dashboard_LandscapeMode_ShowsSidePanel()
{
    // 旋转到横向
    _driver.Orientation = ScreenOrientation.Landscape;

    try
    {
        var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(5));
        var sidePanel = wait.Until(d =>
            d.FindElement(MobileBy.AccessibilityId("side-panel")));

        Assert.True(sidePanel.Displayed);
    }
    finally
    {
        // 恢复纵向
        _driver.Orientation = ScreenOrientation.Portrait;
    }
}

XHarness 测试执行

XHarness 是一个命令行工具,用于在设备和模拟器上跨平台运行测试。它处理应用安装、测试执行和结果收集。

使用 XHarness 运行测试

# 安装 XHarness
dotnet tool install --global Microsoft.DotNet.XHarness.CLI

# 在 Android 模拟器上运行
xharness android test \
    --app bin/Release/net8.0-android/com.myapp-Signed.apk \
    --package-name com.myapp \
    --instrumentation devicerunner.AndroidInstrumentation \
    --output-directory test-results/android

# 在 iOS 模拟器上运行
xharness apple test \
    --app bin/Release/net8.0-ios/MyApp.app \
    --target ios-simulator-64 \
    --output-directory test-results/ios

# 使用特定设备运行
xharness android test \
    --app app.apk \
    --package-name com.myapp \
    --device-id emulator-5554 \
    --output-directory test-results

使用设备运行器的 XHarness

对于直接在设备上运行的 xUnit 测试,添加设备运行器 NuGet 包:

<PackageReference Include="Microsoft.DotNet.XHarness.TestRunners.Xunit" Version="1.*" />
// 在 MAUI 测试应用的 MauiProgram.cs 中
public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder.UseVisualRunner(); // XHarness 可视化测试运行器
    return builder.Build();
}

关键原则

  • 对所有可测试元素使用 AutomationId 它是 data-testid 的跨平台等效物,并映射到每个平台的原生无障碍标识符。
  • 针对真实模拟器/模拟器运行测试,而不仅仅是单元测试。 MAUI 渲染、导航和平台服务的行为与内存测试不同。
  • 使用显式等待,永远不要使用隐式等待或延迟。 带有条件的 WebDriverWait 可靠;Thread.Sleep 和隐式等待隐藏时序问题。
  • 使用 [Trait]Assert.SkipWhen 标记平台特定测试。 xUnit v3 的原生跳过支持允许在 CI 中为每个平台运行正确的测试,而不会因不支持的功能失败。
  • 应用页面对象模型以提高可维护性。 MAUI 应用具有复杂的导航流程;页面对象保持测试的可读性随着应用增长。

代理陷阱

  1. 不要使用没有等待策略的 FindElement 元素在导航后可能无法立即可用。始终使用 WebDriverWait 用于在异步操作或页面转换后出现的元素。
  2. 不要硬编码模拟器/模拟器名称。 使用环境变量或测试配置,以便 CI 可以指定可用设备。不同的 CI 环境安装了不同的模拟器。
  3. 不要忘记在 MAUI 控件上设置 AutomationId 没有它,Appium 会回退到平台特定的选择器(XPath、类名),这些在 Android、iOS 和 Windows 之间不同 – 破坏跨平台测试。
  4. 不要在非 macOS 机器上运行 iOS 测试。 iOS 模拟器需要 Xcode,这仅限 macOS。使用平台条件测试跳过或为每个平台分开 CI 管道。
  5. 不要留下未管理的 Appium 服务器。 启动 Appium 作为夹具或 CI 服务,而不是手动。忘记的 Appium 进程会导致端口冲突和测试挂起。

参考