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:Name或AutomationProperties.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.AutomationId。67
<!-- 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 这类不会沿用的种类,就必须显式设置 AccessibleName。11 整理 AutomationId 不只是为了测试,同时也完全等同于无障碍适配工作,这一点在公司内部申请预算时是很好的论据。
规范可以很简单,我们通常会请团队在编码规范里加入以下两条。
- 只要控件有可能成为操作或验证的对象,就要在界面上为其设置有意义的
Name(WinForms)/x:Name或AutomationProperties.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 生成的数据为前提」这类顺序依赖,一个测试的失败就会连锁扩散,也无法调整执行顺序。原则如下。
- 每个测试(或测试类)自行启动应用,结束后务必关闭(用
finally或IDisposablefixture) - 前提数据由测试端自行准备。如果应用端能提供可切换成测试用配置文件/数据库的启动选项,会轻松很多
- 别忘了失败时的收尾工作。没关干净的进程或残留的 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,我们都能从应用现状调研开始提供帮助。
相关文章
- 如何划分单元测试与集成测试的边界
- 用 Application Verifier 打造 Windows 异常情况测试基础设施
- 自制 Logger 的最低要求与集成测试检查清单
- 用 Pester 整理 PowerShell 测试 ── 让运维脚本不易损坏的实务模式
- WinForms 的高 DPI 适配 ── 4K 显示器下模糊、错位的原因与现实对策
相关咨询领域
合同会社小村软件(KomuraSoft)承接 WinForms / WPF 应用的 UI 自动化测试引入(现状调研、AutomationId 整理、搭建一整套 smoke test、CI 环境设计),以及现有测试资产向 FlaUI 的迁移、测试策略整体的设计评审。
参考资料
-
Microsoft Learn, UI Automation Overview. 关于以桌面为根节点的自动化树、raw / 控件 / 内容视图、元素的属性与控件模式,以及辅助技术与测试自动化使用同一套基础架构。 ↩ ↩2 ↩3
-
Microsoft Learn, UI Automation Control Patterns Overview. 关于控件模式的设计(与 COM 接口的类比)、Invoke / Value / SelectionItem 等模式,以及一个控件可以实现多个模式。 ↩ ↩2
-
Microsoft Learn, Accessibility tools - Inspect. 关于 inspect.exe 随 Windows SDK 内置、可查看 UIA 属性与模式,以及它被定位为旧版工具、建议改用 Accessibility Insights。 ↩ ↩2 ↩3
-
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
-
GitHub, microsoft/WinAppDriver. 关于最终稳定版 v1.2.1 于 2020 年 11 月发布、v1.3 停留在 2020 年 7 月的 Release Candidate 未再更新、未解决 issue 超过 1,100 个,以及仓库以文档、示例为主、服务器本体源代码未公开。 ↩ ↩2 ↩3
-
Microsoft Learn, Use the AutomationID Property. 关于 AutomationId 是不受 locale 影响的标识符、应在兄弟元素间保持唯一、在测试脚本中作为搜索键的使用场景,以及 WPF 中没有 ID(x:Name)或 x:Uid 的控件不支持 AutomationId。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, AutomationProperties.AutomationId Attached Property. 关于在 WPF(System.Windows.Automation 命名空间)中,用来设置唯一标识元素字符串的附加属性定义。 ↩ ↩2
-
Microsoft Learn, UI testing considerations (Azure Pipelines). 关于桌面应用的 UI 测试需要以启用 autologon 的交互式进程来配置代理、微软托管代理不支持可见 UI 测试、RDP 断开连接造成的锁定与通过 tscon 的规避方式、屏幕分辨率设置任务,以及失败时的截图、视频收集。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9
-
GitHub, appium/appium-windows-driver. 关于 Appium Windows Driver 是连接到微软提供的 WinAppDriver 的接口,以及 README 中提醒 WinAppDriver 服务器已长期未获维护。 ↩ ↩2
-
Microsoft Learn, Use Coded UI tests to test your code. 关于 Coded UI 测试已标记为弃用、Visual Studio 2019 是最后完整支持的版本,以及当时指引的迁移方向为桌面 / UWP 应用改用 Appium + WinAppDriver(VS 2026 中的移除记载于同一份 Learn 文档内的迁移指南)。 ↩ ↩2
-
Microsoft Learn, WinForms: Setting the accessible name on a control. 关于 WinForms 控件的 UIA Name 属性在许多种类上会沿用 Text 属性,以及 TextBox / ListBox 等类型需要显式设置 AccessibleName。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
在 WinForms/WPF 应用中集成 Entra ID 认证 —— MSAL.NET 与 WAM Broker 的实务架构
本文以实务视角整理在 WinForms/WPF 桌面应用中集成 Entra ID(原 Azure AD)认证的步骤:公共客户端的思路、ROPC 被弃用的现状、应用注册、MSAL.NET 的 AcquireTokenSilent 模式、WAM Broker、令牌缓存的持久化,...
委托开发 Windows 应用程序前该梳理的事项
在委托外包开发 Windows 应用程序之前,梳理现有软件改造、设备联动、COM/ActiveX、发布与更新、维护等需要注意的要点。
业务应用的日期时间与时区 ── 从 DateTime 的陷阱到 UTC 存储原则、测试设计
服务器迁移后时间整整差了 9 个小时、只有海外分支机构的日期会变成前一天——本文从 DateTime 的 Kind 与隐式转换梳理日期时间事故的根本原因。内容涵盖与 DateTimeOffset 的取舍、UTC 存储与 ISO 8601 原则、TimeZoneInfo 与夏...
WPF 高 DPI 支持──「不是应该对 DPI 很强吗」却仍模糊、渗色的原因与对策
WPF 以 DIP(1/96 英寸)进行布局,从一开始就是 System DPI Aware,但移动到 DPI 不同的显示器时整体会渗色,位图与细线也会模糊。本文从实务角度整理原因判断、.NET Framework 4.6.2/.NET 上的 Per-Monitor DPI...
WinForms 的高DPI支持 - 4K显示器下模糊、错位的原因与实用对策
本文从 DPI 虚拟化与 DPI 感知模式(System Aware / Per-Monitor V2)的角度,整理 WinForms 应用在4K显示器、150%缩放环境下显示模糊、布局错乱的原因,并说明 .NET 与 .NET Framework 各自的配置方法、Auto...
常见问题
汇总了咨询这一主题时常见的问题。
- 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。