更新日期:2026年5月24日
.NET MAUI测试采用分层策略:用xUnit或NUnit对ViewModel和业务逻辑做单元测试,配合Moq模拟平台依赖,再用Appium 2.x或Maestro 1.39+完成跨iOS与Android的UI端到端自动化。这套"测试金字塔"在2026年的.NET 10生态里能稳定达到85%以上代码覆盖率,并完整接入GitHub Actions的CI/CD流水线。本文会从零搭建讲到生产级实战,把我自己在三个MAUI项目里踩过的坑也一并整理出来。
一份覆盖.NET MAUI 10完整测试体系的实战指南:xUnit + Moq 做ViewModel单元测试,Appium 2.x跨平台UI自动化,Maestro YAML端到端测试,全部接入GitHub Actions流水线。

更新日期:2026年5月24日
.NET MAUI测试采用分层策略:用xUnit或NUnit对ViewModel和业务逻辑做单元测试,配合Moq模拟平台依赖,再用Appium 2.x或Maestro 1.39+完成跨iOS与Android的UI端到端自动化。这套"测试金字塔"在2026年的.NET 10生态里能稳定达到85%以上代码覆盖率,并完整接入GitHub Actions的CI/CD流水线。本文会从零搭建讲到生产级实战,把我自己在三个MAUI项目里踩过的坑也一并整理出来。
Microsoft.Maui.Controls.TestUtils包,支持无UI线程的ViewModel测试,启动比传统方案快3倍。dotnet test --collect:"XPlat Code Coverage"结合ReportGenerator可以生成HTML覆盖率报告,并在PR中通过Codecov自动评论。Verify.Xunit能验证ViewModel输出与XAML渲染结果,是回归测试里特别高效的一招。macos-14运行器上大约8分钟跑完,含编译、单元测试、Android模拟器UI测试与产物归档。测试金字塔是组织MAUI项目自动化测试的基石。底层是数量最多、运行最快的单元测试,覆盖ViewModel、服务、业务逻辑;中间层是集成测试,验证SQLite存储、HTTP客户端、消息总线等组件协作;顶层是数量最少、运行最慢但价值最高的UI端到端测试,模拟真实用户操作。
对于一个典型的.NET MAUI 10项目,我习惯按70%单元 / 20%集成 / 10% UI来安排比例。说实话,很多团队的误区是堆砌大量UI测试,结果流水线动辄跑半小时;正确做法是把可测逻辑下沉到ViewModel层,让UI测试只验证导航与表单提交这类"用户旅程"。这种分层策略和我们在.NET MAUI 10 MVVM架构完全指南里讲到的"瘦UI、胖ViewModel"模式是一脉相承的。
| 测试类型 | 工具 | 执行时间 | 稳定性 | 适用场景 |
|---|---|---|---|---|
| 单元测试 | xUnit + Moq | < 200ms / 用例 | ★★★★★ | ViewModel、服务、命令逻辑 |
| 集成测试 | xUnit + Testcontainers | 1–5s / 用例 | ★★★★☆ | SQLite、API客户端 |
| 快照测试 | Verify.Xunit | < 500ms / 用例 | ★★★★★ | ViewModel输出、JSON序列化 |
| UI测试 (跨平台) | Appium 2.x | 10–60s / 用例 | ★★★☆☆ | 登录流程、关键转化路径 |
| UI测试 (轻量) | Maestro 1.39+ | 5–30s / 用例 | ★★★★☆ | 冒烟测试、屏幕截图回归 |
要给.NET MAUI项目添加可靠的单元测试,关键是把纯逻辑代码(ViewModel、服务、模型)放进独立的类库项目,确保目标框架是net10.0而不是net10.0-android或net10.0-ios。这样测试项目就不会被拉入平台SDK,启动会快得多。
下面这组命令能创建一个测试就绪的解决方案结构:
dotnet new sln -n MyMauiApp
dotnet new maui -n MyMauiApp.App
dotnet new classlib -n MyMauiApp.Core -f net10.0
dotnet new xunit -n MyMauiApp.Core.Tests -f net10.0
dotnet sln add MyMauiApp.App MyMauiApp.Core MyMauiApp.Core.Tests
dotnet add MyMauiApp.App reference MyMauiApp.Core
dotnet add MyMauiApp.Core.Tests reference MyMauiApp.Core
cd MyMauiApp.Core.Tests
dotnet add package Moq --version 4.20.72
dotnet add package FluentAssertions --version 7.0.0
dotnet add package Microsoft.Extensions.DependencyInjection --version 10.0.0
dotnet add package CommunityToolkit.Mvvm --version 8.4.0
关键点:MyMauiApp.Core只引用CommunityToolkit.Mvvm这类平台无关包,避免测试项目里冒出UnsupportedOSPlatformException。如果你必须测试调用FileSystem.Current等平台API的代码,请把它包在接口IFileService后面,在测试中通过Moq注入假实现。我自己之前就栽在这上面:直接调Preferences.Get结果一跑测试就崩,绕进DI之后立刻就好了。
下面是一个完整、可直接运行的LoginViewModel单元测试示例,演示了如何模拟HTTP客户端、验证命令执行结果,以及断言INotifyPropertyChanged通知。
// ===== Core/ViewModels/LoginViewModel.cs =====
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class LoginViewModel : ObservableObject
{
private readonly IAuthService _auth;
[ObservableProperty] private string _email = string.Empty;
[ObservableProperty] private string _password = string.Empty;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _errorMessage;
public LoginViewModel(IAuthService auth) => _auth = auth;
[RelayCommand(CanExecute = nameof(CanLogin))]
private async Task LoginAsync()
{
try
{
IsBusy = true;
ErrorMessage = null;
await _auth.SignInAsync(Email, Password);
}
catch (UnauthorizedAccessException ex)
{
ErrorMessage = ex.Message;
}
finally { IsBusy = false; }
}
private bool CanLogin() =>
!string.IsNullOrWhiteSpace(Email) && Password.Length >= 6;
}
// ===== Tests/LoginViewModelTests.cs =====
using FluentAssertions;
using Moq;
using Xunit;
public class LoginViewModelTests
{
private readonly Mock<IAuthService> _authMock = new();
private readonly LoginViewModel _sut;
public LoginViewModelTests() => _sut = new LoginViewModel(_authMock.Object);
[Theory]
[InlineData("", "secret123", false)]
[InlineData("[email protected]", "short", false)]
[InlineData("[email protected]", "secret123", true)]
public void LoginCommand_RespectsCanExecute(string email, string pwd, bool can)
{
_sut.Email = email;
_sut.Password = pwd;
_sut.LoginCommand.CanExecute(null).Should().Be(can);
}
[Fact]
public async Task LoginAsync_OnUnauthorized_SetsErrorMessage()
{
_authMock.Setup(a => a.SignInAsync("[email protected]", "secret123"))
.ThrowsAsync(new UnauthorizedAccessException("密码错误"));
_sut.Email = "[email protected]"; _sut.Password = "secret123";
await _sut.LoginCommand.ExecuteAsync(null);
_sut.ErrorMessage.Should().Be("密码错误");
_sut.IsBusy.Should().BeFalse();
}
[Fact]
public async Task LoginAsync_RaisesIsBusyPropertyChanged()
{
var changes = new List<string?>();
_sut.PropertyChanged += (_, e) => changes.Add(e.PropertyName);
_sut.Email = "[email protected]"; _sut.Password = "secret123";
await _sut.LoginCommand.ExecuteAsync(null);
changes.Should().Contain("IsBusy");
}
}
这套测试在dotnet test下大约150ms完成,而且不需要启动任何模拟器。[Theory]属性让我们用表驱动方式覆盖CanExecute的多种边界,而PropertyChanged断言验证了UI绑定能正确收到刷新通知。
快照测试会把ViewModel在某状态下的"输出"序列化成文件并提交到Git,下次运行时若输出与文件不一致就报错。它特别适合验证复杂的状态机或DTO映射。
dotnet add package Verify.Xunit --version 28.3.2
using VerifyXunit;
using Xunit;
public class CartViewModelSnapshotTests
{
[Fact]
public Task EmptyCart_HasExpectedShape()
{
var vm = new CartViewModel();
return Verifier.Verify(new
{
vm.ItemCount,
vm.Subtotal,
vm.IsCheckoutEnabled
});
}
}
首次运行会生成CartViewModelSnapshotTests.EmptyCart_HasExpectedShape.received.txt,开发者审阅后重命名为.verified.txt提交即可。后续重构若意外改变了输出,CI会立即失败并展示diff。这种模式在重构MVVM层时,能捕获大约九成的意外回归(我自己的统计)。
Appium是W3C WebDriver协议的实现,可以用同一份C#测试代码同时驱动iOS和Android模拟器。.NET 10生态下,Appium 2.13已经稳定支持MAUI应用,并通过AccessibilityId选择元素,这和无障碍开发指南里介绍的SemanticProperties.Hint同源。
安装Appium 2与驱动:
npm install -g [email protected]
appium driver install uiautomator2
appium driver install xcuitest
appium --port 4723 &
在测试项目中添加WebDriver依赖:
dotnet add package Appium.WebDriver --version 7.0.0
dotnet add package Selenium.WebDriver --version 4.27.0
using OpenQA.Selenium.Appium;
using OpenQA.Selenium.Appium.Android;
using OpenQA.Selenium.Appium.Enums;
using Xunit;
public class LoginFlowTests : IAsyncLifetime
{
private AndroidDriver _driver = null!;
public Task InitializeAsync()
{
var options = new AppiumOptions();
options.PlatformName = "Android";
options.AutomationName = "UiAutomator2";
options.DeviceName = "Pixel_7_API_34";
options.App = "/path/to/com.example.mauiapp-Signed.apk";
options.AddAdditionalAppiumOption("appium:autoGrantPermissions", true);
_driver = new AndroidDriver(new Uri("http://127.0.0.1:4723"), options);
return Task.CompletedTask;
}
[Fact]
public void Login_WithValidCredentials_NavigatesToHome()
{
_driver.FindElement(MobileBy.AccessibilityId("EmailEntry"))
.SendKeys("[email protected]");
_driver.FindElement(MobileBy.AccessibilityId("PasswordEntry"))
.SendKeys("secret123");
_driver.FindElement(MobileBy.AccessibilityId("LoginButton")).Click();
var welcome = _driver.FindElement(MobileBy.AccessibilityId("WelcomeLabel"));
Assert.Contains("欢迎", welcome.Text);
}
public Task DisposeAsync() { _driver.Quit(); return Task.CompletedTask; }
}
关键技巧:在XAML中给可测元素加上AutomationProperties.IsInAccessibleTree="True"和AutomationId="EmailEntry",否则Appium无法稳定定位。对于iOS,把PlatformName改为"iOS",AutomationName改为"XCUITest",并提供.app路径即可。
是的。Maestro用YAML定义测试流程,省去了Appium那套繁琐的WebDriver会话管理。一个完整的登录测试只需10行:
# flows/login.yaml
appId: com.example.mauiapp
---
- launchApp
- tapOn:
id: "EmailEntry"
- inputText: "[email protected]"
- tapOn:
id: "PasswordEntry"
- inputText: "secret123"
- tapOn: "登录"
- assertVisible: "欢迎"
- takeScreenshot: "home-screen"
运行命令:
curl -Ls "https://get.maestro.mobile.dev" | bash
maestro test flows/login.yaml
maestro test flows/ --include-tags=smoke --format=junit --output=results.xml
Maestro在2026年已经支持视觉对比(screenshot diffing)和云端并行,一条命令就能在Maestro Cloud的真实设备阵列上跑完所有冒烟测试。对于初创团队,Maestro的学习曲线比Appium低了一个数量级;对于需要细粒度控制的金融或医疗应用,Appium的灵活性仍然不可替代。两者其实可以共存:核心转化路径用Appium,发版前冒烟用Maestro。
.NET 10内置的coverlet.collector足够大多数团队使用了。生成覆盖率与HTML报告的三步法:
dotnet test --collect:"XPlat Code Coverage" --results-directory ./TestResults
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:"CoverageReport" -reporttypes:"Html;Cobertura;MarkdownSummary"
open CoverageReport/index.html
务必在.csproj中通过<ExcludeFromCodeCoverage>属性排除自动生成的.g.cs文件和迁移脚本,不然覆盖率数字会被稀释。配合GitHub Actions的codecov/codecov-action@v5,可以让每个PR页面直接看到本次改动新增代码的覆盖率,模板可以参考MAUI CI/CD完全指南里的工作流配置。如果你的项目还涉及离线场景,离线优先架构指南里那些SQLite同步逻辑也强烈建议用快照测试覆盖。
下面这份精简工作流在每个Pull Request上运行单元测试与Android模拟器UI测试,并把APK与测试报告作为产物上传:
# .github/workflows/test.yml
name: Test
on: [pull_request]
jobs:
unit-tests:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '10.0.x' }
- run: dotnet test MyMauiApp.Core.Tests --collect:"XPlat Code Coverage"
- uses: codecov/codecov-action@v5
ui-tests-android:
runs-on: macos-14
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '10.0.x' }
- run: dotnet workload install maui-android
- run: dotnet publish -f net10.0-android -c Release
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
script: |
npm install -g [email protected] maestro
appium driver install uiautomator2
appium &
sleep 5
dotnet test MyMauiApp.UiTests --logger trx
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: '**/*.trx'
在macos-14运行器上,整个流水线(编译 + 单元 + UI)通常在7到9分钟内完成。如果你的项目还要覆盖iOS,加一个用xcrun simctl启动模拟器的并行Job就行。
第一类陷阱是异步死锁。在测试里同步调用.Result或.Wait()会与CommunityToolkit.Mvvm的AsyncRelayCommand互锁,请始终用await。第二类是静态状态污染:MAUI的Application.Current.Resources是单例,测试间共享会导致随机失败;在IAsyncLifetime.DisposeAsync里显式重置,或者使用xUnit.net Fixture隔离。
第三类是Appium会话泄漏。要在DisposeAsync中保证_driver.Quit()被调用,否则CI上几次失败后端口被占用,整批测试都会卡死(我上个项目就被这个坑了一整周)。第四类是资源文件路径问题:MAUI的MauiAsset在测试项目里并不存在,应该把固定测试数据放在测试项目的TestData/目录下,并以CopyToOutputDirectory方式打包。
如果你需要更深入地了解.NET 10的测试新特性,Microsoft Learn上的.NET测试文档是权威参考;xUnit官方文档也值得收藏,尤其是Fixture与Theory相关章节。
技术上可行,但强烈不推荐。仅靠UI测试覆盖业务逻辑会让流水线变得脆弱且缓慢,一次模拟器启动就要30秒,而同等逻辑的xUnit单元测试只要0.2秒。正确做法是把可测逻辑下沉到ViewModel,让UI测试只验证用户旅程。
初创团队或只做冒烟测试选Maestro,YAML语法学习曲线低且自带云端并行。需要细粒度断言、复杂手势或与现有Selenium基础设施集成的团队选Appium 2.x。两者并不互斥,许多团队用Appium覆盖核心转化路径,用Maestro做发版前的快速冒烟。
把测试拆分为两类:纯C#的单元测试和快照测试可以在ubuntu-24.04上运行,无需任何模拟器;UI测试必须在macos-14(含iOS模拟器与Android)或带有KVM加速的Linux运行器上执行。GitHub Actions的reactivecircus/android-emulator-runner是Linux下启动Android模拟器的标准方案。
业内推荐ViewModel与Service层达到80%以上,整体项目(含XAML代码后台)65到75%即为优秀。盲目追求100%会让测试维护成本激增。重点关注关键路径,比如支付、登录、数据同步,这些模块的覆盖率应接近100%,其他次要功能保持60%以上即可。
最常见原因是没设置AutomationId。在XAML中给可测元素添加AutomationId="LoginButton",并对自定义控件设置AutomationProperties.IsInAccessibleTree="True"。第二个原因是元素被父容器的InputTransparent遮挡,可以在Appium Inspector里检查元素树确认。