关于 dotnet-maui-testing
面向 .NET MAUI 应用的测试技能,结合 Appium UI 自动化与 XHarness 跨平台测试执行,涵盖设备和模拟器测试、元素定位及平台差异验证。
主要功能
- 01基于 Appium 的 UI 自动化
- 02使用 XHarness 跨平台执行
- 03通过 AutomationId 定位元素
- 04平台特定行为测试
- 05设备与模拟器测试指南
适用场景
- 01在设备上自动化 MAUI 应用流程
- 02验证平台特定的 UI 行为
- 03在模拟器或仿真器上运行移动测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-maui-testing安装后在 Claude Code 中输入/dotnet-maui-testing确认已加载。
详细介绍
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+)。
**不在范围内:**通用 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 构建测试注意事项。
为 MAUI 配置 Appium
包
<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);
// Explicit waits only -- do not set ImplicitWait (it causes
// additive timeout behavior when combined with WebDriverWait)
Driver.Manage().Timeouts().ImplicitWait = TimeSpan.Zero;
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync()
{
Driver?.Quit();
return ValueTask.CompletedTask;
}
}
测试配置
public static class TestConfig
{
// Set via environment variables or test runsettings
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="Username" />
<Entry AutomationId="password-input"
Placeholder="Password"
IsPassword="True" />
<Button AutomationId="login-button"
Text="Log In"
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()
{
// Find by AutomationId (maps to accessibility ID on each platform)
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();
// Wait for navigation
var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10));
var homeTitle = wait.Until(d =>
d.FindElement(MobileBy.AccessibilityId("home-title")));
Assert.Equal("Welcome", 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("Invalid", errorLabel.Text);
}
}
MAUI 的页面对象模型
使用 Appium 驱动程序应用页面对象模型模式(参见 [skill:dotnet-ui-testing-core]):
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")));
}
}
// Usage
[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 native skip support (no SkippableFact package needed)
Assert.SkipWhen(TestConfig.TargetPlatform != "Android",
"Android-only: hardware back button");
// Navigate to details page
_driver.FindElement(MobileBy.AccessibilityId("item-1")).Click();
// Press Android back button
_driver.Navigate().Back();
// Verify we returned to the list
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 native skip support
Assert.SkipWhen(TestConfig.TargetPlatform != "iOS",
"iOS-only: swipe gesture");
var item = _driver.FindElement(MobileBy.AccessibilityId("item-1"));
// Swipe left to reveal delete action
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]);
// Tap the delete button
var deleteBtn = _driver.FindElement(MobileBy.AccessibilityId("delete-action"));
deleteBtn.Click();
// Verify item removed
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()
{
// Rotate to landscape
_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
{
// Restore portrait
_driver.Orientation = ScreenOrientation.Portrait;
}
}
使用 XHarness 执行测试
XHarness 是一个命令行工具,可在不同平台的设备和模拟器上运行测试。它负责安装应用、执行测试和收集结果。
使用 XHarness 运行测试
# Install XHarness
dotnet tool install --global Microsoft.DotNet.XHarness.CLI
# Run on Android emulator
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
# Run on iOS simulator
xharness apple test \
--app bin/Release/net8.0-ios/MyApp.app \
--target ios-simulator-64 \
--output-directory test-results/ios
# Run with specific device
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.*" />
// In the MAUI test app's MauiProgram.cs
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseVisualRunner(); // XHarness visual test runner
return builder.Build();
}
核心原则
- **对所有可测试元素使用
AutomationId。**它是跨平台版本的data-testid,并会映射到每个平台的原生辅助功能标识符。 - **在真实模拟器上运行测试,而不只是运行单元测试。**MAUI 的渲染、导航和平台服务行为与内存中的测试不同。
- **使用显式等待,绝不使用隐式等待或延迟。**带条件的
WebDriverWait可靠;Thread.Sleep和隐式等待会掩盖时序问题。 - **使用
[Trait]和Assert.SkipWhen标记平台特定测试。**xUnit v3 的原生跳过支持可让 CI 按平台运行正确的测试,避免因不支持的功能而失败。 - **应用页面对象模型以便于维护。**MAUI 应用的导航流程较复杂;页面对象可在应用不断扩展时保持测试易读。
Agent 常见问题
- 不要在没有等待策略的情况下使用
FindElement。 导航后,元素可能不会立即出现。对于异步操作或页面切换后才出现的元素,务必使用WebDriverWait。 - 不要硬编码模拟器名称。 使用环境变量或测试配置,以便 CI 指定可用设备。不同的 CI 环境中安装的模拟器各不相同。
- 不要忘记为 MAUI 控件设置
AutomationId。 如果未设置,Appium 会回退到特定于平台的选择器(XPath、类名),而这些选择器在 Android、iOS 和 Windows 上各不相同,导致跨平台测试失败。 - 不要在非 macOS 设备上运行 iOS 测试。 iOS 模拟器需要 Xcode,而 Xcode 仅支持 macOS。请使用基于平台条件跳过测试,或按平台拆分 CI 流水线。
- 不要让 Appium 服务处于无人管理状态。 将 Appium 作为 fixture 或 CI 服务启动,而不是手动启动。忘记关闭的 Appium 进程会导致端口冲突和测试挂起。