Windows 桌面应用的 UI 自动化测试 ── UI Automation 原理与用 FlaUI 打造不易损坏的测试

· · 测试, UI Automation, FlaUI, WinForms, WPF, C#, .NET, Windows, CI/CD

「每次发布都要花整整两天手动点遍所有界面确认」「上个月修好的地方旁边,另一个界面坏了,结果是客户先发现的」「Web 团队已经用 Selenium 或 Playwright 把回归测试自动化了,桌面应用却完全没人管」。这类咨询,我们经常从长期维护 WinForms / WPF 业务应用的现场听到。

另一方面,满怀热情开始写全界面测试,却扛不住维护负担、一年后放弃的失败案例,我们也听到过同样多。UI 自动化测试如果在工具选择和「做到什么程度」的界限上判断错误,成本会比手动测试还高。反过来,只要理解其原理、设计得不易损坏,并把守护范围收缩到 smoke test,就能把「发布前的两天」换成「每晚无人执行的 20 分钟」,变成一项划算的投入。本文将梳理作为基础的 UI Automation(UIA)原理、工具现状(FlaUI / WinAppDriver / Appium)、用 FlaUI 实现、让测试不易损坏的设计,一直到 CI 无人执行,把我们在实际项目中使用的模式完整整理一遍。

1. 先说结论

  • UI 自动化测试是测试金字塔的最顶层。它慢、容易损坏,定位失败原因也需要更多时间,因此无法替代单元测试和集成测试。逻辑要靠下层守护,UI 测试只负责确认「能启动、主要流程能走通」的 smoke test(第 6 章)。
  • 原理的基础是 Windows UI Automation(UIA)。从以桌面为根节点的自动化树(automation tree)中查找元素,用 AutomationId / Name 等属性加以定位,再通过控件模式(Invoke / Value / SelectionItem 等)进行操作。12
  • 目标应用的元素呈现方式,写代码之前要先用 inspect.exe 或 Accessibility Insights for Windows 确认。inspect 是 Windows SDK 内置的旧版工具,现在官方推荐改用 Accessibility Insights。3
  • 我们推荐的工具是 FlaUI + xUnit / NUnit。FlaUI 是对 UIA 做轻量封装的 MIT 许可 OSS,同时支持 UIA2 / UIA3,目前仍在持续积极维护。4
  • 曾是微软官方选项的 WinAppDriver,最终稳定版停留在 2020 年 11 月的 v1.2.1,而服务器本体是非公开源代码,社区也无法自行修复。不建议新引入(第 3 章)。5
  • 测试是否容易损坏,八成取决于开发端制定的 AutomationId 命名规范。WPF 是 x:NameAutomationProperties.AutomationId,WinForms 是 Name / AccessibleName。禁止依赖显示文本(Name)的搜索、坐标点击、Thread.Sleep,改用条件等待(Retry)与 Page Object 模式编写(第 4~5 章)。67
  • CI 的无人执行必须有交互式桌面会话。以服务方式启动的代理无法运行,屏幕锁定或 RDP 断开连接同样会导致失败。用自动登录(autologon)配置自建 runner,是基本做法(第 7 章)。8

2. 原理 ── UI Automation 的树结构、属性与模式

2.1 自动化树(Automation Tree)

UI 自动化测试工具并不是把界面当成图片来识别。Windows 有一套官方基础架构 UI Automation(UIA),让屏幕阅读器之类的辅助技术能够以编程方式读取、操作应用的 UI,测试自动化也是搭在这同一条轨道上运行的。1

在 UIA 中,以桌面为根节点,所有已打开的窗口及其内部的控件都会以树结构公开出来。按钮、文本框、表格的每一行,全部都是树上的自动化元素。除了包含所有元素的 raw 视图外,树结构还有经过过滤的视图:只保留可操作控件的控件视图(Control View),以及只保留内容的内容视图(Content View)1 测试代码基本上就是沿着控件视图遍历来查找元素。

这里要抓住的重点是,只能操作「树上公开的东西」,而不是「肉眼看到的东西」。用标准控件搭建的界面会完整地出现在树上,但自绘(owner-draw)的列表、用图形库绘制的图纸区域、第三方表格的部分内容等,在树上可能只会被识别成「一个元素」。这种呈现方式,直接决定了 UI 自动化测试能够守护的范围上限。正因如此,写代码之前先确认树结构(2.3 节),才是最先要做的事。

2.2 属性与控件模式

从树结构中定位目标元素的线索是属性。实务中会用到以下 4 种。

属性 内容 在测试中的定位
AutomationId 由开发者指定的标识符,不受语言(locale)影响 搜索键的主力。在同层的兄弟元素中必须唯一 6
Name 源自显示文本的名称(例如按钮的标签) 人类容易理解,但文案变更、多语言化时容易损坏
ControlType Button / Edit / ComboBox 等种类 用于辅助缩小范围
ClassName 实现类的名称(例如 WinForms 的类名) 最后的手段。实现变更时容易损坏

官方定义中,AutomationId「即使 locale 改变也应保持相同的值」「在兄弟元素之间应保持唯一」,是专门让 UI 自动化测试能跨语言、跨版本稳定查找元素的属性。6 反过来说,没有设置 AutomationId 的应用,其 UI 测试就只能依赖显示文本或树结构位置这类容易损坏的线索。这正是第 5 章要讨论的主题。

操作查找到的元素的手段是控件模式(Control Pattern)。UIA 把「可以点击」「持有值」「可以选择」这类控件的功能面,公开成一组独立于控件种类之外的模式集合。文档本身也说明「控件模式与 UI 的关系,就如同 COM 对象与接口的关系」,其设计是向元素询问「它实现了哪个模式」,再通过该模式进行操作。2 熟悉 COM 的读者,可以把它理解成 UI 版的 QueryInterface。主要的模式如下。

模式 能做什么 典型控件
Invoke 执行默认动作(相当于点击) 按钮、菜单项
Value 获取、设置值 文本框
SelectionItem / Selection 选择项目、获取选择状态 列表、下拉框、标签页
Toggle 开/关切换 复选框
ExpandCollapse 展开/收起 下拉框、树形项
Text 读取文本内容 文档、富文本
Window 最大化、最小化、关闭 顶层窗口
Scroll / ScrollItem 滚动、将项目移动到可见位置 列表、表格

「点击按钮」这样的测试代码,内部其实是「获取该元素的 Invoke 模式并调用 Invoke()」的处理。不是计算坐标发送鼠标事件,而是调用控件自身公开出来的操作——这个差异,正是不依赖窗口位置或 DPI、能保持稳定的测试基础。

2.3 用 inspect.exe 与 Accessibility Insights 确认「看得到的东西」

目标应用的元素究竟以怎样的 AutomationId / Name / ControlType / 模式公开出来,最好的方式就是用工具直接查看实际情况。

  • inspect.exe:Windows SDK 内置的经典工具(位于 SDK 安装路径下的 bin\<version>\<platform>)。用鼠标或键盘焦点选中元素后,会列出 UIA 属性和模式,也能验证树结构的导航。不过官方将它定位为「旧版工具」,建议改用 Accessibility Insights。3
  • Accessibility Insights for Windows:微软目前推荐的工具。只需鼠标悬停或移动焦点,就能通过 Live Inspect 方便地查看元素的 UIA 属性,还内置了从无障碍角度出发的自动检查功能(FastPass)。3
  • FlaUInspect:FlaUI 项目自带的查看工具。可以分别用 FlaUI 实际使用的 UIA2 / UIA3 视角查看树结构,如果打算用 FlaUI 写测试,装上它会很方便。4

从经验来说,引入 UI 自动化测试时最先该做的不是「写测试代码」,而是用 inspect 类工具打开主要界面,盘点 AutomationId 到底设置了多少。如果这里空空如也,先从应用端的改造(第 5 章)入手,最终反而会更快。

3. 工具选型 ── 推荐 FlaUI 的理由与 WinAppDriver 的现状

虽然也可以直接通过 COM 调用 UIA,但实务中会使用封装库。下面依据核实过的事实,整理 2026 年时间点的选项与现状。

工具 形态 现状(2026 年时间点) 新引入的判断基准
FlaUI .NET 库(MIT) 积极维护中的 OSS。v5.0.0 于 2025 年 2 月发布 4 ◎ 第一候选
WinAppDriver WebDriver 协议的服务器(微软) 最终稳定版 v1.2.1 为 2020 年 11 月。v1.3 仍停留在 2020 年 7 月的 RC 状态。未解决 issue 超过 1,100 个 5 △ 实质上已停滞。避免新引入
Appium Windows Driver Appium 的 Windows 驱动 内部使用 WinAppDriver,因此直接继承上述限制 9 △ 只在已有 Appium 资产时考虑
Coded UI 测试 Visual Studio 功能 VS 2019 起已标记为弃用,VS 2026 已移除 10 × 属于应迁移的对象

3.1 FlaUI ── 作为 UIA 的轻量封装,目前最为实用

FlaUI 是一个帮助 Windows 应用(Win32 / WinForms / WPF / 应用商店应用)进行 UI 自动化测试的 .NET 库,设计上是对微软原生 UIA 库的封装。4 包由通用部分的 FlaUI.Core,以及按所使用的 UIA 实现区分的 FlaUI.UIA2 / FlaUI.UIA3 组成。

UIA2 与 UIA3 的选用方式,FlaUI 的 FAQ 有明确说明。UIA2 只有托管实现,不支持触摸等新特性,与 WPF 或应用商店应用的兼容性较差;UIA3 是最新实现,最适合 WPF / 应用商店应用,但在 WinForms 应用上有时会遇到 UIA2 没有的缺陷——两者是这样的关系。4 换句话说,实务中的判断基准是:WPF 优先尝试 UIA3,WinForms 优先尝试 UIA2,再根据实际应用的表现,采用更稳定的一个。两者都能从 NuGet 安装并切换尝试,这正是 FlaUI 作为轻量封装库的优点。

由于它本身不含测试框架,因此会搭配 xUnit / NUnit / MSTest,以普通测试项目的方式编写。能放进和单元测试相同的测试运行器、相同的 CI 流水线,在运维上是相当大的优势。

3.2 WinAppDriver ── 说实话,实质上已经停滞

WinAppDriver 是微软出品的 UI 测试服务器,能用和 Selenium 相同的 WebDriver 协议操作 Windows 应用,曾一度被视为主力选项。当 Visual Studio 的 Coded UI 测试被标记为弃用(deprecated)时,微软自己也曾指引迁移方向为「Web 用 Selenium,桌面与 UWP 用 Appium + WinAppDriver」。10

但实际到 GitHub 查看现状会发现,最终稳定版 v1.2.1 是在 2020 年 11 月发布,之后就再没有出过稳定版。v1.3 仍停留在 2020 年 7 月的 Release Candidate(v1.2.99)状态,没能成为正式版,未解决的 issue 超过 1,100 个。5 更麻烦的是,GitHub 仓库里只有文档、示例和 issue 跟踪,服务器本体的源代码并未公开,也就是说社区根本无法自行修复缺陷。我们的判断是:不能因为「官方出品所以放心」,就在 2026 年还把它当作新引入的对象。如果已经有基于 WinAppDriver、正在运行的测试资产,不必立即抛弃,但建议停止继续扩建,新的测试流程改用 FlaUI。

3.3 Appium Windows Driver 及其他选项

Appium Windows Driver(appium-windows-driver)是让 Appium 操作 Windows 应用的驱动,但它的实体其实是「连接到微软提供的 WinAppDriver 的接口」,繁重的处理全部由 WinAppDriver 一侧负责。9 因此它会直接继承 WinAppDriver 的停滞状态。对于在移动端或 Web 上已经统一使用 Appium 的团队、想用 Java 或 Python 编写测试代码的团队,它依然是一个选项,但采用之前务必先了解源自 WinAppDriver 的限制与未来走向。

WinUI 3(Windows App SDK)的应用也支持 UIA,因此可以用 FlaUI(UIA3)作为测试对象。不过相比 WinForms / WPF,积累的案例还比较少,各控件呈现方式的特点,就更需要靠 inspect 类工具事先确认。UI 框架本身的选型,可参考「WinForms/WPF/WinUI 选型指南 - 实务判断表」中的梳理。

4. 用 FlaUI 实现最小示例 ── 启动、搜索、操作、验证

理论说到这里,接下来看真正能运行的代码。这是一个通过 NuGet 安装 FlaUI.UIA3(若在 WinForms 上不稳定则改用 FlaUI.UIA2)与 xUnit 的普通测试项目。

4.1 Smoke Test 的基本形式

「启动应用、打开订单登记对话框、保存一条数据、结果显示在状态栏」这样的流程测试,可以这样写:

using FlaUI.Core;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Tools;
using FlaUI.UIA3;
using Xunit;

public class OrderSmokeTest
{
    [Fact]
    public void 创建订单后主要流程可以走通()
    {
        using var app = Application.Launch(@"C:\App\OrderManager.exe");
        using var automation = new UIA3Automation();
        try
        {
            // 等待主窗口出现
            var window = app.GetMainWindow(automation);

            // 用 AutomationId 查找元素,并作为 Button 进行操作
            window.FindFirstDescendant(cf => cf.ByAutomationId("NewOrderButton"))
                  ?.AsButton().Invoke();

            // 对话框是异步打开的,因此要条件等待「直到它出现」
            var dialog = Retry.WhileNull(
                () => window.FindFirstDescendant(
                          cf => cf.ByAutomationId("OrderDialog"))?.AsWindow(),
                timeout: TimeSpan.FromSeconds(5)).Result;
            Assert.NotNull(dialog);

            // 通过 Value 模式输入(不是模拟键盘输入)
            dialog.FindFirstDescendant(cf => cf.ByAutomationId("CustomerNameBox"))
                  .AsTextBox().Text = "测试商事";
            dialog.FindFirstDescendant(cf => cf.ByAutomationId("SaveButton"))
                  .AsButton().Invoke();

            // 保存完成也通过条件等待状态显示来验证
            var saved = Retry.WhileFalse(
                () => window.FindFirstDescendant(
                          cf => cf.ByAutomationId("StatusLabel"))
                          ?.Name.Contains("已保存") == true,
                timeout: TimeSpan.FromSeconds(10));
            Assert.True(saved.Success);
        }
        finally
        {
            app.Close();
        }
    }
}

组成要素只有 4 个。

  • 启动:用 Application.Launch 启动进程(也可以用 Application.Attach 连接已经在运行的应用)。GetMainWindow 会一直等到能获取主窗口为止。
  • 搜索FindFirstDescendant(cf => cf.ByAutomationId(...)) 就是树搜索。cf 是条件工厂(condition factory),也可以组合 ByName / ByControlType / And 条件。搜索键如前所述,以 AutomationId 为主力。
  • 操作:把找到的元素转换成 AsButton() / AsTextBox() / AsComboBox() 等带类型的包装对象再操作。Invoke() 对应 Invoke 模式,Text 属性对应 Value 模式,2.2 节提到的控件模式就在背后发挥作用。
  • 验证:对显示在 UI 上的结果(标签、列表的行数、窗口标题等)进行 assert。如果想查看数据库内容,也可以直接在测试代码中读取数据库。

4.2 用 Retry 等待 ── 写了 Sleep 就等于认输

UI 测试变得不稳定(也就是所谓的 flaky test)的最大来源是时机(timing)。对话框打开之前、数据加载之前、按钮启用之前——UI 一直都是以异步方式变化的,但如果测试代码武断地认为「现在应该已经显示出来了」,就会做出只在慢速机器上失败的测试。

话虽如此,加入 Thread.Sleep(3000) 是最糟糕的处理方式。在快速机器上会白白等待,在慢速机器上又会因为时间不够而失败,只会不断累积测试整体的运行时间。答案是条件等待,也就是「持续轮询直到条件满足,超时就中止」,FlaUI 为此提供了专用的 Retry 类。4

// 等到不是 null(=元素出现)为止,最多等 5 秒
var element = Retry.WhileNull(
    () => window.FindFirstDescendant(cf => cf.ByAutomationId("ResultGrid")),
    timeout: TimeSpan.FromSeconds(5),
    interval: TimeSpan.FromMilliseconds(200),
    throwOnTimeout: true).Result;

// 等到条件成立(=按钮变为可用)为止
Retry.WhileFalse(
    () => saveButton.IsEnabled,
    timeout: TimeSpan.FromSeconds(5),
    throwOnTimeout: true);

系统提供了 Retry.WhileNull / WhileFalse / WhileTrue / WhileException 等方法,可以指定超时时间、轮询间隔,以及超时时是否抛出异常。4 需要注意的是,FlaUI 从 2.0 版起已经取消了 Find 系列方法的隐式重试,改为「在哪里等待由测试代码明确指定」的方针。4 FindFirstDescendant 只能看到「此刻这一瞬间的树结构」,因此请把「异步出现的元素搜索必须用 Retry 包裹」定为规范。顺便一提,「不靠 Sleep 硬顶、而是建立等待条件」这种思路本身,不限于 UI 测试,是整个 Windows 编程领域的通用做法(「Windows 中为何应优先使用事件等待而非 Sleep(1)」)。

5. 让测试不易损坏的设计 ── 应用端与测试端的规范

UI 测试因「扛不住维护负担而被放弃」的原因,几乎可以归纳为以下 4 点:依赖显示文本的搜索、坐标点击、Sleep,以及结构缺乏共享。下面用规范逐一消除这些问题。

5.1 AutomationId 必须由开发端设置

这是最重要的一点。测试是否容易损坏,比起测试代码怎么写,更取决于应用是否公开了稳定的标识符。AutomationId 正是为此而生的属性,规范要求它不受 locale 影响,且在兄弟元素之间必须唯一。6

WPF 中,加上 x:Name 的元素会直接被当作 UIA 一侧的标识符使用,因此已命名的控件不需要额外处理就能测试。若要在数据模板(DataTemplate)内等场景显式指定,则设置附加属性 AutomationProperties.AutomationId67

<!-- x:Name 直接成为标识符 -->
<Button x:Name="SaveButton" Content="保存" Click="OnSave" />

<!-- 模板内等场景显式指定 AutomationProperties.AutomationId -->
<Button AutomationProperties.AutomationId="DeleteRowButton"
        Content="删除"
        Command="{Binding DeleteCommand}" />

WinForms 中,设计器里设置的 Control.Name(把 button1 改成 saveButton 的那个名字)会被用于 UIA 一侧的识别。依我们的经验,只要按规范命名的表单,几乎都能直接用 AutomationId 搜索,但因框架版本和控件的不同,呈现方式存在差异,请务必先用 inspect 类工具确认实际的 AutomationId,然后才把它用作测试的搜索键。另外,作为屏幕阅读器读出名称的 UIA Name 属性,在很多控件上会直接沿用 Text 属性,但像 TextBox、ListView 这类不会沿用的种类,就必须显式设置 AccessibleName11 整理 AutomationId 不只是为了测试,同时也完全等同于无障碍适配工作,这一点在公司内部申请预算时是很好的论据。

规范可以很简单,我们通常会请团队在编码规范里加入以下两条。

  • 只要控件有可能成为操作或验证的对象,就要在界面上为其设置有意义的 Name(WinForms)/x:NameAutomationProperties.AutomationId(WPF)
  • 一旦被测试引用过的标识符,重命名时要同步修改测试端(把标识符当作公开 API 对待)

5.2 禁止坐标点击

「点击屏幕坐标 (830, 412)」这类操作,只要窗口位置、分辨率、DPI 缩放、主题、字体设置中任何一项改变,就会损坏。特别是 DPI,在开发机 100%/CI 机器 150% 这类环境差异下,会大量制造出「本地能通过、CI 却失败」的情况(DPI 的原理见「WinForms 的高 DPI 适配」)。如第 2 章所述,通过 UIA 控件模式进行的操作不依赖坐标。FlaUI 虽然也提供直接操作鼠标的 API,但我们规定只能用在拖放、绘图画布这类无法用模式表达的操作上。而且即便在这种情况下,也要从元素的 BoundingRectangle 计算相对位置,而不是使用屏幕坐标。

5.3 用 Page Object 模式把结构集中到一处

如果把 FindFirstDescendant(cf => cf.ByAutomationId("CustomerNameBox")) 这类搜索代码直接写死在测试本体里,界面结构一旦变化,就得修改所有测试。经典的解决方案是 Page Object 模式:为「每个界面(或对话框)建立一个类」,把元素搜索与操作全部封闭在里面。

public sealed class OrderDialogPage
{
    private readonly Window _dialog;
    public OrderDialogPage(Window dialog) => _dialog = dialog;

    // 元素搜索只写在这个类里
    private TextBox CustomerName =>
        _dialog.FindFirstDescendant(cf => cf.ByAutomationId("CustomerNameBox")).AsTextBox();
    private Button Save =>
        _dialog.FindFirstDescendant(cf => cf.ByAutomationId("SaveButton")).AsButton();
    private Label Status =>
        _dialog.FindFirstDescendant(cf => cf.ByAutomationId("StatusLabel")).AsLabel();

    // 测试能看到的只有用「业务语言」表达的操作
    public void Register(string customerName)
    {
        CustomerName.Text = customerName;
        Save.Invoke();
        // 元素搜索和 null 判断都放在重试里进行。在保存后才生成、重新绘制状态标签的界面上,
        // 搜索短暂出现 null 或异常属于正常情况
        // (如果不加 ignoreException,第一次异常就会直接导致失败)
        Retry.WhileFalse(() => Status?.Name.Contains("已保存") == true,
            timeout: TimeSpan.FromSeconds(10), throwOnTimeout: true,
            ignoreException: true);
    }
}

测试本体就能用业务语言写成 new OrderDialogPage(dialog).Register("测试商事"),界面变更带来的影响只需修改 Page Object 一处即可。如果应用的界面超过 10 个,还要持续运维 UI 测试,这个模式几乎是必需品。唯一要注意的是,等待条件中引用的元素,请务必像上面的 Status 一样,保持每次都通过属性重新搜索的写法。如果把搜索结果缓存到字段里,就会一直抓着因重新绘制而消失的旧元素,白白等到超时。

5.4 测试的独立性 ── 不要在测试之间传递状态

每一个 UI 测试都要保持独立。如果建立「测试 3 是以测试 2 生成的数据为前提」这类顺序依赖,一个测试的失败就会连锁扩散,也无法调整执行顺序。原则如下。

  • 每个测试(或测试类)自行启动应用,结束后务必关闭(用 finallyIDisposable fixture)
  • 前提数据由测试端自行准备。如果应用端能提供可切换成测试用配置文件/数据库的启动选项,会轻松很多
  • 别忘了失败时的收尾工作。没关干净的进程或残留的 modal 对话框,会成为下一个测试失败的原因(第 7 章)

6. UI 测试该守护到什么范围 ── 以 smoke test 为核心划界

工具齐备之后,总会想测试所有界面,但这正是分水岭所在。相比单元测试,UI 测试慢得多(单条要数秒到数十秒)、容易损坏(UI 一改就要跟着调整)、定位失败原因也要花时间(不知道是应用的缺陷、测试的缺陷,还是环境问题)。测试金字塔的最顶层画得那么窄,正是因为这种成本结构——能靠下层守护的东西,改用 UI 测试守护永远是亏本生意

整理成判断表如下。

想守护的内容 适合的层级 理由
计算、转换、业务规则 单元测试 快速稳定。通过 UI 全面覆盖根本不划算
数据库访问、文件 I/O、外部集成 集成测试 使用真实对象,但不需要 UI(如何划分边界
ViewModel、展示逻辑 单元测试 若采用 MVVM,可在没有 UI 的情况下测试
能启动、主要流程能走通、能保存 UI smoke test 这里才是 UI 测试的主战场
过去手动确认时遗漏的致命流程 UI 测试(回归) 只针对造成过实际损失的部分追加
界面布局、外观错乱 目视、截图比对 用 assert 写会陷入维护地狱,数量要收敛
崩溃、句柄泄漏等异常情况 另一套基础设施 属于 UI 测试守护范围之外(Application Verifier

我们推荐的起步方式是「大约 10 个 smoke test」。「启动后主界面出现」「能打开主要主数据」「能登记、搜索、打印预览一条代表性单据」「退出时不报错」——把发布前一定会手动确认的流程中排名前 10 的直接自动化。这个规模的话,编写大约需要 1~2 周,每晚运行也只要 20~30 分钟,维护负担也比较现实。等体会到效果之后,再针对曾经造成实际损失的回归缺陷,一条一条补上防止再次发生的测试。反之,以「全部界面、全部项目」为目标制定的计划,几乎必然会在中途崩掉。

另一个重要方向,是投资于把逻辑从 UI 中剥离出来,而不是增加 UI 测试。业务逻辑直接写在事件处理程序里的界面,只能靠 UI 测试守护;但只要把逻辑迁移到 ViewModel 或服务类,就能改由单元测试守护,UI 测试只需负责「确认接线」即可。从经验来说,如果感觉需要的 UI 测试数量很多,往往不是测试的问题,而是设计的问题。关于测试整体该如何分层,可参考「如何划分单元测试与集成测试的边界」;下层测试的实务做法,也请一并参考「自制 Logger 的最低要求与集成测试检查清单」。

7. CI、无人执行的陷阱 ── 没有桌面,UI 测试就跑不起来

只要 UI 测试都是开发者亲自动手执行,一切都很平静。陷阱集中出现在「CI 每晚无人执行」这个阶段,这一点和 Web 的 UI 测试(用无头浏览器就能搞定)不同,桌面应用的测试需要真正的交互式桌面会话,这正是所有问题的根源。

7.1 交互式会话是必需品 ── 以服务方式启动的代理无法运行

CI 代理(Azure Pipelines 代理、GitHub Actions 的自建 runner 等)通常会以 Windows 服务的形式常驻运行。但服务没有用户桌面,因此无法操作从那里启动的应用窗口。Azure Pipelines 的官方文档也明确指出,运行桌面应用 UI 测试的代理不能是服务,必须配置为启用自动登录(autologon)的交互式进程8 此外,微软托管代理(云端提供的共享 runner)不支持可见 UI 测试,只能运行无头浏览器测试。8 也就是说,桌面应用的 UI 测试实质上必须有自建机器(物理机或虚拟机均可)

autologon 配置有一项官方文档也提到的安全风险:「能物理接触该机器的人,就能使用自动登录的账号」。8 前提是要用测试专用账号、专用机器(虚拟机),并且不要放置生产环境的凭证。

7.2 屏幕锁定、RDP 断开连接、分辨率 ── 常见的失败模式

即使准备好交互式会话,还是存在陷阱。下面用表格整理症状与对策。

陷阱 症状 对策
代理以服务方式运行 完全找不到元素、应用无法启动 改用交互式进程 + autologon 重新配置 8
屏幕锁定、屏幕保护程序 输入类操作无法传达,导致失败 在 autologon 配置中禁用屏幕保护程序;针对会触发锁定的 GPO 申请例外 8
用「×」断开 RDP 断开连接的瞬间会话会被锁定,之后的测试全部失败 tscon <会话 ID> /dest:console 切回控制台后再断开连接 8
分辨率、DPI 的环境差异 本地能通过的测试只在 CI 上失败 固定分辨率(Azure Pipelines 有对应的设置任务)。缩放统一为 100% 8
测试并行执行 鼠标、键盘、焦点互相抢占,导致相互破坏 UI 测试以一台机器一条、串行方式执行;要并行化就增加机器(虚拟机)数量
上一次失败的残留 残留的进程或 modal 对话框,妨碍下一次启动 测试开始前清理目标进程;务必用 finally 执行结束处理

RDP 的陷阱特别容易踩到,这里补充说明一下。用远程桌面登录测试机进行调整,关闭窗口离开后,该会话就会变成锁定状态,之后的 UI 测试会持续失败。官方文档建议的规避方式,是在断开连接前用管理员命令提示符执行 %windir%\System32\tscon.exe <ID> /dest:console,把会话交还给控制台。8 请务必把这一步写进测试机的运维手册。

DPI、分辨率也需要留意。CI 用的虚拟机停留在 1024×768、缩放默认值的情况并不少见,布局一旦变化,就会出现「开发机上能看到的按钮,不滚动就看不到」之类的差异。只要排除了坐标点击,大部分都能被吸收,但环境固定下来终归更稳妥。包括应用端的 DPI 适配情况,「WinForms 的高 DPI 适配」中写的确认角度可以直接套用。

7.3 保留失败时的证据 ── 截图与日志

无人执行的 UI 测试失败时,如果日志里只留下「找不到元素」,还是无法得知原因。要从一开始就内置失败时保存截图的机制。FlaUI 提供了截取界面或元素的功能,可以从测试框架的失败钩子(hook)中调用,并保存为 CI 的产物(artifact)。

// 从失败钩子等处调用。输出到 CI 的产物保存位置
FlaUI.Core.Capturing.Capture.Screen()
    .ToFile(Path.Combine(artifactDir, $"{testName}_{DateTime.Now:HHmmss}.png"));

除了截图之外,如果能让应用自身的日志(处理进行到了哪一步)和测试端的日志(操作成功到了哪一步)通过时间戳互相对照,就能大幅加快判断「是应用的缺陷、测试的缺陷,还是环境问题」的速度。若用任务计划程序在夜间运行测试时的注意事项(会话类型、以 0x1 结束的问题等),请参考「任务计划程序的任务不执行、以 0x1 结束」一文。

8. 总结

UI 自动化测试并不是「装上工具就能跑」,而是要同时具备原理的理解、应用端的配合、守护范围的界限,以及运行环境的设计,才能持续运转下去。下面浓缩要点。

  • 基础是 UI Automation。从树结构用 AutomationId 查找,通过控件模式操作。先用 inspect / Accessibility Insights 盘点目标应用的呈现方式
  • 工具用 FlaUI + xUnit / NUnit。WinAppDriver 自 2020 年以来再未发行过稳定版,应避免新引入
  • 不易损坏的特性靠规范来建立。由开发端设置 AutomationId、禁止坐标点击、禁止 Sleep 并用 Retry 做条件等待、用 Page Object 把结构集中到一处
  • 守护范围从大约 10 个 smoke test 开始。逻辑尽量交给单元测试和集成测试守护,UI 测试只专注于确认「主要流程能走通」
  • CI 的基本形式是自建机器 + 交互式会话 + autologon。用运维流程消除屏幕锁定、RDP 断开连接、分辨率差异等陷阱,并务必保留失败时的截图

「发布前两天的手动确认」,只要用范围收敛得当的 UI 自动化测试,就能以现实的成本替代。反过来,如果现有应用完全没有设置 AutomationId、逻辑又直接写在界面事件里,那么在写测试之前,先从应用端的小规模改造入手,最终反而是捷径。无论是判断该从哪里下手,还是搭建一整套 smoke test,我们都能从应用现状调研开始提供帮助。

相关文章

相关咨询领域

合同会社小村软件(KomuraSoft)承接 WinForms / WPF 应用的 UI 自动化测试引入(现状调研、AutomationId 整理、搭建一整套 smoke test、CI 环境设计),以及现有测试资产向 FlaUI 的迁移、测试策略整体的设计评审。

参考资料

  1. Microsoft Learn, UI Automation Overview. 关于以桌面为根节点的自动化树、raw / 控件 / 内容视图、元素的属性与控件模式,以及辅助技术与测试自动化使用同一套基础架构。  2 3

  2. Microsoft Learn, UI Automation Control Patterns Overview. 关于控件模式的设计(与 COM 接口的类比)、Invoke / Value / SelectionItem 等模式,以及一个控件可以实现多个模式。  2

  3. Microsoft Learn, Accessibility tools - Inspect. 关于 inspect.exe 随 Windows SDK 内置、可查看 UIA 属性与模式,以及它被定位为旧版工具、建议改用 Accessibility Insights。  2 3

  4. GitHub, FlaUI/FlaUI. 关于它是支持 Win32 / WinForms / WPF / 应用商店应用的 UIA 封装库、FlaUI.Core / UIA2 / UIA3 的包结构、UIA2 与 UIA3 的选用方式(FAQ)、MIT 许可、v5.0.0(2025 年 2 月)的发布与持续开发,以及 Retry 工具类与 2.0 版后取消 Find 系列方法隐式重试。  2 3 4 5 6 7 8

  5. GitHub, microsoft/WinAppDriver. 关于最终稳定版 v1.2.1 于 2020 年 11 月发布、v1.3 停留在 2020 年 7 月的 Release Candidate 未再更新、未解决 issue 超过 1,100 个,以及仓库以文档、示例为主、服务器本体源代码未公开。  2 3

  6. Microsoft Learn, Use the AutomationID Property. 关于 AutomationId 是不受 locale 影响的标识符、应在兄弟元素间保持唯一、在测试脚本中作为搜索键的使用场景,以及 WPF 中没有 ID(x:Name)或 x:Uid 的控件不支持 AutomationId。  2 3 4 5

  7. Microsoft Learn, AutomationProperties.AutomationId Attached Property. 关于在 WPF(System.Windows.Automation 命名空间)中,用来设置唯一标识元素字符串的附加属性定义。  2

  8. Microsoft Learn, UI testing considerations (Azure Pipelines). 关于桌面应用的 UI 测试需要以启用 autologon 的交互式进程来配置代理、微软托管代理不支持可见 UI 测试、RDP 断开连接造成的锁定与通过 tscon 的规避方式、屏幕分辨率设置任务,以及失败时的截图、视频收集。  2 3 4 5 6 7 8 9

  9. GitHub, appium/appium-windows-driver. 关于 Appium Windows Driver 是连接到微软提供的 WinAppDriver 的接口,以及 README 中提醒 WinAppDriver 服务器已长期未获维护。  2

  10. Microsoft Learn, Use Coded UI tests to test your code. 关于 Coded UI 测试已标记为弃用、Visual Studio 2019 是最后完整支持的版本,以及当时指引的迁移方向为桌面 / UWP 应用改用 Appium + WinAppDriver(VS 2026 中的移除记载于同一份 Learn 文档内的迁移指南)。  2

  11. Microsoft Learn, WinForms: Setting the accessible name on a control. 关于 WinForms 控件的 UIA Name 属性在许多种类上会沿用 Text 属性,以及 TextBox / ListBox 等类型需要显式设置 AccessibleName。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

WinForms/WPF 应用的 UI 自动化测试应该用什么?
我们推荐 FlaUI + xUnit/NUnit。FlaUI 是对 Windows UI Automation(UIA)做轻量封装的 MIT 许可 OSS,同时支持 UIA2/UIA3,2025 年 2 月发布了 v5.0.0,目前仍在积极维护。选择的基本原则是:WPF 优先尝试 UIA3,WinForms 优先尝试 UIA2。它本身不含测试框架,因此可以搭配和单元测试相同的测试运行器、放进同一条 CI 流水线。写代码之前,先用 inspect.exe 或 Accessibility Insights for Windows 确认目标应用的元素是如何呈现的,是第一步该做的事。
现在还适合新引入 WinAppDriver 吗?
不建议新引入。微软出品的 WinAppDriver 最终稳定版 v1.2.1 停留在 2020 年 11 月,v1.3 仍处于 2020 年 7 月的 Release Candidate 状态,未解决的 issue 超过 1,100 个。而且 GitHub 仓库里只有文档和示例,服务器本体的源代码并未公开,社区也无法自行修复缺陷。Appium 的 Windows Driver 内部也依赖 WinAppDriver,因此同样继承了这些限制。如果已经有基于 WinAppDriver 的测试资产,不必立即抛弃,但建议新的测试流程改用 FlaUI。
怎样防止 UI 自动化测试容易损坏?
测试是否容易损坏,八成取决于开发端制定的 AutomationId 命名规范。WPF 用 x:Name 或 AutomationProperties.AutomationId,WinForms 用 Control.Name 作为标识符。在此基础上,禁止依赖显示文本(Name)的搜索和坐标点击,用 FlaUI 的 Retry 类做条件等待来取代 Thread.Sleep,并用 Page Object 模式把每个界面的元素搜索与操作都集中到一处。FlaUI 从 2.0 版起已经取消了 Find 系列方法的隐式重试,因此请把「异步出现的元素搜索必须用 Retry 包裹」定为规范。
UI 自动化测试应该写到什么范围?
建议从大约 10 个 smoke test 开始。UI 测试比单元测试慢得多,也更容易损坏,因此计算逻辑、业务规则交给单元测试,数据库访问交给集成测试,UI 测试只专注于确认「能启动、主要流程能走通」。把发布前原本要手动确认的流程中排名前 10 的自动化,编写大约需要 1~2 周,每晚运行也只需 20~30 分钟。反过来,以「全部界面、全部项目」为目标的计划几乎必然会失败。如果感觉需要的 UI 测试数量很多,通常说明该把逻辑抽到 ViewModel、改由单元测试守护,这是一个设计需要改进的信号。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表