.NET MAUI 测试完全指南 (2026):xUnit、Appium 与 Maestro 实战

一份覆盖.NET MAUI 10完整测试体系的实战指南:xUnit + Moq 做ViewModel单元测试,Appium 2.x跨平台UI自动化,Maestro YAML端到端测试,全部接入GitHub Actions流水线。

.NET MAUI 测试指南:xUnit+Appium (2026)
On this page
  1. .NET MAUI测试金字塔与策略选型
  2. 如何为.NET MAUI项目搭建单元测试?
  3. 用xUnit和Moq测试ViewModel实战
  4. 用Verify做快照测试
  5. Appium 2.x跨平台UI测试完整流程
  6. 用Maestro写端到端测试更简单吗?
  7. 代码覆盖率统计与报告生成
  8. 在GitHub Actions中集成测试
  9. 常见测试陷阱与排查思路

更新日期: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测试金字塔与策略选型

测试金字塔是组织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 + Testcontainers1–5s / 用例★★★★☆SQLite、API客户端
快照测试Verify.Xunit< 500ms / 用例★★★★★ViewModel输出、JSON序列化
UI测试 (跨平台)Appium 2.x10–60s / 用例★★★☆☆登录流程、关键转化路径
UI测试 (轻量)Maestro 1.39+5–30s / 用例★★★★☆冒烟测试、屏幕截图回归

如何为.NET MAUI项目搭建单元测试?

要给.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之后立刻就好了。

用xUnit和Moq测试ViewModel实战

下面是一个完整、可直接运行的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("a@b.com", "short", false)]
    [InlineData("a@b.com", "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("a@b.com", "secret123"))
                 .ThrowsAsync(new UnauthorizedAccessException("密码错误"));
        _sut.Email = "a@b.com"; _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 = "a@b.com"; _sut.Password = "secret123";

        await _sut.LoginCommand.ExecuteAsync(null);

        changes.Should().Contain("IsBusy");
    }
}

这套测试在dotnet test下大约150ms完成,而且不需要启动任何模拟器。[Theory]属性让我们用表驱动方式覆盖CanExecute的多种边界,而PropertyChanged断言验证了UI绑定能正确收到刷新通知。

用Verify做快照测试

快照测试会把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 2.x跨平台UI测试完整流程

Appium是W3C WebDriver协议的实现,可以用同一份C#测试代码同时驱动iOS和Android模拟器。.NET 10生态下,Appium 2.13已经稳定支持MAUI应用,并通过AccessibilityId选择元素,这和无障碍开发指南里介绍的SemanticProperties.Hint同源。

安装Appium 2与驱动:

npm install -g appium@2.13.0
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("test@example.com");
        _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写端到端测试更简单吗?

是的。Maestro用YAML定义测试流程,省去了Appium那套繁琐的WebDriver会话管理。一个完整的登录测试只需10行:

# flows/login.yaml
appId: com.example.mauiapp
---
- launchApp
- tapOn:
    id: "EmailEntry"
- inputText: "test@example.com"
- 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同步逻辑也强烈建议用快照测试覆盖。

在GitHub Actions中集成测试

下面这份精简工作流在每个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 appium@2.13.0 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相关章节。

常见问题解答

.NET MAUI项目可以不写单元测试只做UI测试吗?

技术上可行,但强烈不推荐。仅靠UI测试覆盖业务逻辑会让流水线变得脆弱且缓慢,一次模拟器启动就要30秒,而同等逻辑的xUnit单元测试只要0.2秒。正确做法是把可测逻辑下沉到ViewModel,让UI测试只验证用户旅程。

Appium和Maestro应该选哪一个?

初创团队或只做冒烟测试选Maestro,YAML语法学习曲线低且自带云端并行。需要细粒度断言、复杂手势或与现有Selenium基础设施集成的团队选Appium 2.x。两者并不互斥,许多团队用Appium覆盖核心转化路径,用Maestro做发版前的快速冒烟。

如何在没有模拟器的Linux CI上运行MAUI测试?

把测试拆分为两类:纯C#的单元测试和快照测试可以在ubuntu-24.04上运行,无需任何模拟器;UI测试必须在macos-14(含iOS模拟器与Android)或带有KVM加速的Linux运行器上执行。GitHub Actions的reactivecircus/android-emulator-runner是Linux下启动Android模拟器的标准方案。

MAUI测试的代码覆盖率达到多少算合格?

业内推荐ViewModel与Service层达到80%以上,整体项目(含XAML代码后台)65到75%即为优秀。盲目追求100%会让测试维护成本激增。重点关注关键路径,比如支付、登录、数据同步,这些模块的覆盖率应接近100%,其他次要功能保持60%以上即可。

为什么我的Appium测试找不到XAML元素?

最常见原因是没设置AutomationId。在XAML中给可测元素添加AutomationId="LoginButton",并对自定义控件设置AutomationProperties.IsInAccessibleTree="True"。第二个原因是元素被父容器的InputTransparent遮挡,可以在Appium Inspector里检查元素树确认。