ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# UI自动化测试:基于UIA框架的WinForms桌面应用回归实践

C# UI自动化测试:基于UIA框架的WinForms桌面应用回归实践

简介:这是一份基于C# UI Automation的Windows桌面应用自动化测试示例工程,面向.NET开发者和测试人员,旨在演示如何通过UI Automation库模拟真实用户操作,覆盖打开关闭程序、文本编辑、按钮点击、列表展开、控件遍历等典型场景,适用于回归测试与持续集成中的界面验证。压缩包共29个文件,约71KB,主要包含7个C#源码文件、解决方案与工程配置、编译生成的exe/pdb、资源文件及测试日志,项目结构精简,便于直接打开研读和运行。工程通过15个按钮示例串联常见自动化操作,既展示控件定位与事件触发的基本方法,也体现测试流程的组织方式,可帮助初学者快速搭建自己的UI自动化测试框架。目前已有2502人学习下载,适合希望提升Windows应用测试效率的开发者参考实践。

1. C# UI自动化测试示例工程:给桌面系统补一套可复跑的回归兜底

某公司一套老旧的 WinForms 客户端,每次发版前测试都得人肉点十几个窗口:登录、录单、审核、导出,一套下来四十多分钟,还总有漏网之鱼。某次上线后线上报了个“下拉框状态没复位”的问题,排查半天发现不是代码逻辑错了,是回归根本没覆盖到那个角落。这个基于 C# UI Automation 的自动化测试示例工程,瞄准的就是这类 Windows 桌面端的 UI 回归场景:用微软官方 UIA 框架去定位窗口和控件,模拟点击、输入、下拉选择,最后自动断言界面状态。适合正在负责桌面端系统测试、想给老项目补一套自动化兜底方案的开发者,拿来改改控件路径就能落到自己的被测系统上。

2. 工程结构与技术选型:一份分三层的解决方案

2.1 先看目录:三层职责各管一摊

示例工程按“底层能力 → 页面对象 → 测试场景”分了三层,目录结构如下:

UiAutomationSample/ ├── src/ │ ├── UiTest.Automation/ │ │ ├── Core/ │ │ │ ├── UiElementFinder.cs // 元素查找:按 AutomationId + 控件类型 │ │ │ ├── UiActionExecutor.cs // 动作执行:点击、输入、选择 │ │ │ ├── WaitHelper.cs // 等待与超时控制 │ │ │ └── UiAssert.cs // 断言与失败截图 │ │ ├── Pages/ │ │ │ ├── LoginPage.cs // 登录窗口的控件封装 │ │ │ └── MainPage.cs // 主窗体的控件封装 │ │ └── UiTest.Automation.csproj │ └── UiTest.Cases/ │ ├── LoginFlowTests.cs // NUnit 测试用例 │ └── OrderFlowTests.cs ├── tools/ │ └── inspect.cmd // 启动 Inspect 工具 └── README.md

每一层的依赖方向是单向的:Core 不引用测试框架,Pages 引用 Core,Cases 引用 Pages。这样分层的直接收益是,被测系统的控件路径一旦调整,需要改动的只有 Pages 目录里面的类,测试方法里的业务步骤可以原样保留。这个结构放在示例工程里看起来有点重,但真正跑到三五十条用例时,它会帮你省掉大量重复的元素定位代码。

2.2 技术依赖:官方 UIA 库够用,不必一上来就引三方包

技术选型上,这个工程用的是 .NET Framework 4.7.2 加 System.Windows.Automation,测试框架选 NUnit 3.x。被测对象是一个 WinForms 写的模拟项目X,工程里包含了它的登录窗口和主窗体,拿来做自动化演示正好。

System.Windows.Automation 是微软官方托管的 UIA 实现,不需要额外装 NuGet 包,也不需要写 COM 互操作代码,直接 using 就能用。FlaUI 是对 UIA2/UIA3 的第三方封装,优点是走最新的 UIA3 通道,跨进程性能和对虚拟化列表的支持更好,但学习和维护成本高一段。我的看法是,这类示例工程用官方库足够;等哪天被测系统里的 DataGridView 或者虚拟化列表把性能逼到墙角,再切 FlaUI 也不迟。

配置方面,常见做法是建一个 app.config 或者 application.json,集中存放被测应用路径、全局超时毫秒数、失败重试次数三个关键参数。核心类的构造函数从配置里读值,比把魔法数字散落在各处要容易维护得多。示例里配置长这样:

<configuration> <appSettings> <add key="TargetAppPath" value="C:\apps\SimulateApp\SimulateApp.exe" /> <add key="GlobalTimeoutMs" value="5000" /> <add key="RetryTimes" value="2" /> </appSettings> </configuration>

参数说明:TargetAppPath 是启动被测程序的绝对路径,GlobalTimeoutMs 是 UiElementFinder 默认的元素查找超时,RetryTimes 是失败后自动重跑次数。这三个值基本就是一个小型 UI 测试工程的全部外部变量了。

2.3 页面对象的最小实现:LoginPage 怎么写

页面对象是这套工程里最值得直接抄走的设计。登录页是每个桌面系统最容易作为落地试点的地方,最简实现长这样:

using System.Windows.Automation; using UiTest.Automation.Core; namespace UiTest.Automation.Pages { public class LoginPage { private readonly AutomationElement _window; public LoginPage(AutomationElement window) { _window = window; } public AutomationElement UserNameInput => UiElementFinder.FindElement(_window, "txtUserName", ControlType.Edit); public AutomationElement PasswordInput => UiElementFinder.FindElement(_window, "txtPassword", ControlType.Edit); public AutomationElement LoginButton => UiElementFinder.FindElement(_window, "btnLogin", ControlType.Button); public AutomationElement ErrorTip => UiElementFinder.FindElement(_window, "lblError", ControlType.Text, 1000); } }

这里有个设计取舍:页面对象的属性返回的是 AutomationElement 而不是直接执行操作。好处是调用方可以按场景决定到底是 Invoke 还是 Select,也让页面对象的职责保持在“元素定位”这一层。查找失败时 FindElement 抛 TimeoutException,上层用例能在等待条件里捕获并决定是否重试。

3. 核心模块拆解:一条从元素查找到状态断言的主链路

3.1 UiElementFinder:带重试与超时的元素定位

元素定位是整个 UIA 测试的地基,这个类解决三个问题:条件组合、超时控制、中间态异常。完整实现如下:

using System; using System.Diagnostics; using System.Threading; using System.Windows.Automation; namespace UiTest.Automation.Core { /// <summary>按 AutomationId 和控件类型查找后代元素,带超时轮询。</summary> public static class UiElementFinder { public static AutomationElement FindElement( AutomationElement root, string automationId, ControlType controlType = null, int timeoutMs = 5000) { if (root == null) throw new ArgumentNullException(nameof(root)); var idCondition = new PropertyCondition( AutomationElement.AutomationIdProperty, automationId); var typeCondition = (controlType != null) ? new PropertyCondition(AutomationElement.ControlTypeProperty, controlType) : Condition.TrueCondition; var condition = new AndCondition(idCondition, typeCondition); var stopwatch = Stopwatch.StartNew(); AutomationElement result = null; while (stopwatch.ElapsedMilliseconds < timeoutMs) { result = root.FindFirst(TreeScope.Descendants, condition); if (result != null) return result; Thread.Sleep(300); } throw new TimeoutException($"未在 {timeoutMs}ms 内找到 AutomationId={automationId} 的元素"); } public static AutomationElement TryFindElement( AutomationElement root, string automationId, ControlType controlType = null, int timeoutMs = 1000) { try { return FindElement(root, automationId, controlType, timeoutMs); } catch (TimeoutException) { return null; } } } }

逻辑说明:FindFirst 配合 TreeScope.Descendants 会在 root 的所有后代节点里做深度优先搜索,这和 Children 只查一层有本质区别。AndCondition 保证 AutomationId 和 ControlType 同时满足,避免出现“ID 对上了但找错控件类型”的误命中。轮询间隔 300 毫秒,既不会让 CPU 空转,又能保证在控件延迟加载的场景里尽快找到目标。

参数说明:root 一般来自 AutomationElement.FromHandle 返回的窗口根元素;automationId 是 Inspect 工具里读到的 AutomationId 属性;controlType 可以传 ControlType.Edit、ControlType.Button 等,拿不准时传 null 只按 ID 查。TryFindElement 是给等待条件用的变体,不抛异常,在轮询场景里反复调用更安全。

设计上刻意让超时抛 TimeoutException 而不是返回 null,是为了让上层用例能明确区分“元素没找到”和“找错了位置”两类失败,前者是时序问题,后者是定位条件问题。踩过 WebDriver 显式等待的人会习惯这套思路。

3.2 UiActionExecutor:点击与输入的真实姿势

UIA 的交互操作不是简单调用控件方法,而是先查询控件支持的 Pattern。按钮不一定支持 Invoke,复选框可能是 TogglePattern,单选按钮则走 SelectionItemPattern。示例工程里的执行器把所有情况兜了一遍:

using System; using System.Windows.Automation; namespace UiTest.Automation.Core { public static class UiActionExecutor { public static void Click(AutomationElement element) { if (element == null) throw new ArgumentNullException(nameof(element)); if (element.TryGetCurrentPattern(InvokePattern.Pattern, out object invokePattern)) { ((InvokePattern)invokePattern).Invoke(); return; } if (element.TryGetCurrentPattern(SelectionItemPattern.Pattern, out object selectionPattern)) { ((SelectionItemPattern)selectionPattern).Select(); return; } if (element.TryGetCurrentPattern(TogglePattern.Pattern, out object togglePattern)) { var toggle = (TogglePattern)togglePattern; if (toggle.Current.ToggleState != ToggleState.On) toggle.Toggle(); return; } throw new NotSupportedException( $"控件无法执行Click,AutomationId={element.Current.AutomationId}"); } public static void SetText(AutomationElement element, string text) { if (element == null) throw new ArgumentNullException(nameof(element)); if (!element.TryGetCurrentPattern(ValuePattern.Pattern, out object valuePattern)) throw new NotSupportedException("控件不支持SetValue"); var value = (ValuePattern)valuePattern; if (value.Current.IsReadOnly) throw new InvalidOperationException("控件是只读状态,不能输入"); value.SetValue(text); } } }

逻辑说明:Click 方法的执行顺序是 Invoke → Select → Toggle 三级兜底。大多数 Button 控件支持 InvokePattern,RadioButton 和 ListItem 支持 SelectionItemPattern,CheckBox 和 ToggleButton 支持 TogglePattern。真实项目里这三种控件长得都像“可以点的东西”,UIA 给它们的语义却不同,所以必须逐个试探。SetText 先检查 ValuePattern 是否存在,再检查 IsReadOnly,最后才 SetValue;这个顺序一旦反过来,只读控件会在 SetValue 时抛出难看的 COM 异常,排查起来多绕一层。

参数说明:Click 和 SetText 都接收 AutomationElement,调用方从页面对象拿元素后传入即可。两个方法都刻意设计成同步阻塞,被测应用界面卡顿时,调用线程会跟着等,这样测试用例天然有了一个隐式的粗粒度超时边界。

3.3 等待策略:为什么 WebDriver 那套显式等待要手工实现

Web 自动化里有 WebDriverWait 可以开箱即用,但桌面 UIA 没有这种东西。示例工程里实现了一个通用的 WaitUntil,核心思路是轮询条件函数并捕获中间态异常:

using System; using System.Threading; namespace UiTest.Automation.Core { public static class WaitHelper { public static void WaitUntil( Func<bool> condition, int timeoutMs = 5000, int intervalMs = 200, string message = "条件等待超时") { var deadline = Environment.TickCount64 + timeoutMs; string lastError = null; while (Environment.TickCount64 < deadline) { try { if (condition()) return; } catch (Exception ex) { // 控件刚出现在UI树但还没就绪,访问属性会抛异常,继续轮询 lastError = ex.Message; } Thread.Sleep(intervalMs); } throw new TimeoutException( string.IsNullOrEmpty(lastError) ? message : $"{message},最后一次异常:{lastError}"); } } }

逻辑说明:循环里对 condition 的调用包了 try/catch,这是被实际项目逼出来的。UIA 的 UI 树是动态构建的,控件刚进入树但还没完全初始化时,访问 Current 属性会抛 ElementNotAvailableException。如果不捕获,等待逻辑会被一次中间态异常直接打穿。最后抛出的异常信息里带上最后一次底层异常,能让定位省一半力气。

实际调用场景是登录后等主面板出现:

WaitHelper.WaitUntil( condition: () => UiElementFinder.TryFindElement(root, "pnlMain", ControlType.Pane) != null, timeoutMs: 8000, message: "登录后主面板未出现");

参数说明:condition 是一个返回布尔值的委托,内部每 200 毫秒评估一次;timeoutMs 是整体超时;intervalMs 是轮询间隔。用在登录跳转这类 2~3 秒窗口期时,8 秒超时是常见做法,轮询间隔 200 毫秒能保证“最多晚 200 毫秒发现界面变化”。

3.4 UiAssert:断言加截图,失败留证比失败本身更重要

自动化用例失败时,最怕的是只看到一行红字,不知道当时界面长什么样。UiAssert 类把元素状态断言和失败截图合并在一起:

using System.Drawing; using System.Windows.Automation; using NUnit.Framework; namespace UiTest.Automation.Core { public static class UiAssert { public static void ElementVisible(AutomationElement element, string message = null) { if (element == null) throw new AssertionException(message ?? "断言失败:元素不存在"); if (element.Current.IsOffscreen) throw new AssertionException(message ?? "断言失败:元素在屏幕外"); if (!element.Current.IsEnabled) throw new AssertionException(message ?? "断言失败:元素处于禁用状态"); } public static void TextEqual(AutomationElement element, string expected, string message = null) { var valuePattern = element.GetCurrentPattern(ValuePattern.Pattern) as ValuePattern; var actual = valuePattern != null ? valuePattern.Current.Value : element.Current.Name; Assert.That(actual, Is.EqualTo(expected), message); } public static void Snapshot(AutomationElement root, string savePath) { var bounds = root.Current.BoundingRectangle; if (bounds.IsEmpty) return; using var bmp = new Bitmap((int)bounds.Width, (int)bounds.Height); using var g = Graphics.FromImage(bmp); g.CopyFromScreen((int)bounds.X, (int)bounds.Y, 0, 0, bmp.Size); bmp.Save(savePath, System.Drawing.Imaging.ImageFormat.Png); } } }

逻辑说明:ElementVisible 的三个检查里,IsOffscreen 是最坑的——窗口最小化或者被遮挡时,元素其实还在 UI 树里,但状态已经是不可见。这种情况必须捕获,否则用例可能在“元素没出现在屏幕上”时依然继续往下跑。TextEqual 做了个双通道兼容:能取到 ValuePattern 的活控件读 Value,取不到的静态文本直接读 Name。Snapshot 通过 BoundingRectangle 拿到窗口位置和尺寸,配合 CopyFromScreen 把整个窗口区域存成 PNG,挂到 NUnit 的 TestContext 或日志目录里。

参数说明:Snapshot 的 root 传窗口根元素即可,保存路径建议带上时间戳,格式类似$"{TestContext.CurrentContext.Test.Name}_{DateTime.Now:HHmmss}.png",避免多用例并发写同一个文件。这段用到 System.Drawing,.NET 6 下要额外装 System.Drawing.Common 包,.NET Framework 4.7.2 则不需要。

3.5 组合出一条完整用例:登录、进主界面、断言用户名

把前面几个类串起来的完整用例长这样:

using System; using System.Diagnostics; using System.Windows.Automation; using NUnit.Framework; using UiTest.Automation.Core; using UiTest.Automation.Pages; namespace UiTest.Cases { [TestFixture] public class LoginFlowTests { private Process _app; private AutomationElement _root; [SetUp] public void SetUp() { var appPath = AppConfig.TargetAppPath; _app = Process.Start(appPath); _app.WaitForInputIdle(5000); _root = AutomationElement.FromHandle(_app.MainWindowHandle); } [TearDown] public void TearDown() { if (_app != null && !_app.HasExited) _app.Kill(); } [Test] public void 管理员登录_进入主界面() { var login = new LoginPage(_root); UiActionExecutor.SetText(login.UserNameInput, "admin"); UiActionExecutor.SetText(login.PasswordInput, "123456"); UiActionExecutor.Click(login.LoginButton); WaitHelper.WaitUntil( () => UiElementFinder.TryFindElement(_root, "pnlMain", ControlType.Pane) != null, timeoutMs: 8000, message: "登录后主面板未出现"); var main = new MainPage(_root); UiAssert.ElementVisible(main.UserLabel); UiAssert.TextEqual(main.UserLabel, "admin"); } } }

逻辑说明:SetUp 里启动被测进程并等待其主窗口就绪,TearDown 里强制 Kill,保证用例结束后不残留进程——这一步是血泪经验,不清理的话,第二次跑用例会因为进程实例冲突直接失败。测试方法本身只描述业务意图:输入账号密码、点登录、等主面板、断言用户名,控件路径完全被页面对象封装掉了。

参数说明:WaitForInputIdle(5000) 是等待进程进入空闲状态,能有效地避免进程还没创建主窗口就继续往后跑的问题;MainWindowHandle 在窗口没出现时是 IntPtr.Zero,这一步最好单独封装一个连接器方法,详见第 4 章。

4. 运行与调试:一个用例从编译到跑通的全流程

4.1 环境准备三步走

环境上只需要 Windows 10 或 11、Visual Studio 2022 和 .NET Framework 4.7.2 开发包。打开解决方案后先还原 NuGet 包,再把 app.config 里的 TargetAppPath 改成被测程序的实际路径,编译一次确认没有引用缺失,基本就可以跑了。

有一点预先提醒:被测应用是 32 位还是 64 位会直接影响 UIA 的跨进程行为。一个 64 位的测试工程去操作 32 位的 WinForms 程序,有可能出现句柄拿到了但后代元素遍历为空的情况。遇到这种翻车现场,先把测试工程的平台目标改成 x86,多数时候问题直接消失。这属于 UIA 的常见坑,后面避坑章节会展开。

编译完成后的运行入口是 NUnit,可以在 VS 的测试资源管理器里直接跑,也可以用命令行:

dotnet test UiTest.Cases/UiTest.Cases.csproj --filter "FullyQualifiedName~LoginFlow"

dotnet test 的好处是能直接接 CI,输出标准化的 trx 结果文件。

4.2 连接被测应用:主窗口句柄是根锚点

所有 UIA 操作都要有一个根元素,最常见的锚点是从进程对象拿主窗口句柄:

public static AutomationElement Connect(Process process) { // 血泪经验:主窗口句柄不是永远非零,必须等进程把窗口创建出来 process.WaitForInputIdle(8000); if (process.MainWindowHandle == IntPtr.Zero) throw new InvalidOperationException("目标进程没有可见主窗口"); return AutomationElement.FromHandle(process.MainWindowHandle); }

逻辑说明:AutomationElement.FromHandle 会把 Win32 窗口句柄包装成 UIA 的根元素,后续所有 FindFirst 都在这个根下面进行。WaitForInputIdle 是专门治“进程启动但窗口还没画出来”的,启动动画复杂的老系统尤其需要这个等待。

参数说明:Connect 接收 Process 对象,返回 AutomationElement。如果被测程序在测试前已经启动,可以用 Process.GetProcessByName 按进程名拿对象再 Connect。还有一种做法是直接传窗口标题,用 AutomationElement.RootElement.FindFirst 去找窗口节点,适合启动方式比较特殊的场景。

4.3 Inspect 工具:先定位属性,再写代码

工程 tools 目录里放了一个 inspect.cmd 启动脚本,底层调用 Windows SDK 自带的 Inspect.exe。用它的目的只有一个:拿到控件的 AutomationId、ControlType、Name 和 BoundingRectangle。

使用顺序是这样的:先启动被测程序,再用 Inspect 的鼠标跟随模式把焦点移到目标控件上,左侧属性栏会实时显示所有 UIA 属性。记录最关键的三项——AutomationId、ControlType、Name,其中 Name 只起辅助验证作用,因为 Name 可能跟随运行时文案动态变化,而 AutomationId 是设计期就定死的。中文系统上 Name 可能是中文或空字符串,所以写页面对象时优先 AutomationId。

把常用属性整理成一张速查表,写代码时直接对照:

Inspect 属性用途对应代码写法
AutomationId元素唯一标识,最稳定new PropertyCondition(AutomationElement.AutomationIdProperty, "txtUserName")
ControlType区分控件种类new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Edit)
Name辅助校验,可能变化只作为附加条件或断言用
BoundingRectangle元素位置尺寸截图定位用,一般不进定位条件

定位时优先只用 AutomationId 加 ControlType,Name 容易因为国际化文案差异导致用例在不同语言环境下失灵。

4.4 断点调试与慢动作模式

UIA 自动化调试的难点在于:代码执行速度太快,界面刚弹出就切换走了,人眼根本来不及观察。我一般会在关键步骤之间插入一个带日志的延时器:

public static void SleepLog(string message, int ms = 500) { TestContext.Progress.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] {message}"); Thread.Sleep(ms); }

逻辑说明:TestContext.Progress 是 NUnit 的输出通道,比 Console.WriteLine 更可靠,因为 VS 测试资源管理器会吞掉控制台输出。每步 500 毫秒的停顿足够肉眼确认界面状态,也让 UIA 树有充足时间完成异步刷新。

调试模式下,我会把这个 SleepLog 插到 SetText 前后、Click 之后、断言之前,三到四个点就够。跑通过一轮之后再把延时全部去掉或者调成 50 毫秒以下,避免无谓拖慢回归时间。

失败场景排查时,先看两样东西:一是断言抛出的异常信息里带没带“最后一次异常”的底层描述,二是失败时自动保存的窗口截图。这两个信息合在一起,九成问题能直接定位到“控件没加载出来”还是“控件加载了但状态不对”。

5. 避坑笔记:UIA 测试里最让人返工的五个场景

5.1 找不到控件:AutomationId 明明存在却返回 null

现象:Inspect 里能看到元素的 AutomationId 和 Name,同一进程里调用 FindFirst 却返回 null,或者间歇性为空。

原因:最常见的是元素还没进 UI 树,窗口在加载动画期间控件尚未创建完成;另一个隐蔽原因是窗口根元素绑定错了进程,比如 TearDown 清理不干净导致拿到的是上一个残留进程的句柄。

解决:把裸的 FindElement 换成 WaitHelper.WaitUntil 轮询调用 TryFindElement,超时放宽到 8~10 秒。如果轮询 20 次依然失败,返回 root.Current.ProcessId 和当前进程比对一下,确认锚点没有串进程。

5.2 Invoke 点击没反应但也不报错

现象:Click 执行后按钮看上去被按下,但业务逻辑没触发,用例后续步骤全部超时。

原因:窗口处于非激活状态时,部分 WinForms 控件不响应 UIA 的 Invoke 消息,它们依赖窗口激活态来同步状态;还有一种情况是目标控件其实是自绘菜单或列表项,InvokePattern 只是形式上有,内部实现是空的。

解决:点击前先调用 element.SetFocus(),让控件拿到键盘焦点,再执行 Invoke。还不行就把 Click 实现改成先取 BoundingRectangle 中心点,用 Win32 的 SetCursorPos 加 mouse_event 模拟真实鼠标点击。真实鼠标是这类“半残控件”的兜底方案,但有副作用——测试执行期间不能再动鼠标,否则会点错位置。

5.3 远程桌面断开或锁屏之后所有元素消失

现象:前一天用例还能跑,第二天早上发现全部 FindElement 超时,窗口截图全黑。

原因:UIA UI 树依赖一个激活且有会话的桌面。远程桌面会话断开后,该会话的桌面进入不可交互状态,UIA 拿不到任何窗口元素。这在自动化团队里属于典型的玄学问题——代码没变,环境变了。

解决:把测试跑在一台不锁屏的实体机或服务器虚拟桌面里,会话长期保活;系统锁屏策略单独豁免这台机器。如果业务上必须用远程机器执行,那只能接受锁屏后失败的现状,在 CI 里把执行机做常驻会话管理。这个坑没有代码级解法,只能从环境上规避。

5.4 64 位测试工程找不到 32 位程序的窗口

现象:从 64 位测试工程启动 32 位 WinForms 程序,MainWindowHandle 拿到非零,但 FromHandle 之后 FindDescendants 结果为空,或者只找到部分元素。

原因:UIA 跨进程通信在 32/64 位边界上有桥接限制,64 位调用方枚举 32 位进程的 UI 树时会丢节点。

解决:测试工程编译平台改成 x86,重新编译后跑同一套用例。改一次 VS 的配置平台即可:解决方案配置里新增 x86,所有测试项目都选这个平台。如果改了 x86 仍然失败,再考虑切 FlaUI 的 UIA3 通道,它针对跨进程管道做了更多优化。

5.5 自定义控件根本没暴露任何属性

现象:第三方图表控件、自绘 DataGridView 在 Inspect 里显示为一片空白,AutomationId 全是问号,手工点也无法定位。

原因:控件绘制时没有实现 UIA 的 provider,UIA 拿不到控件内部结构,只能裸露出一个图形容器。

解决:正规做法是让研发给控件实现 IRawElementProvider 或者继承 WPF 的 AutomationPeer,把关键属性暴露出来,这一步要排进开发迭代。测试软件侧的替身方案是用 BoundingRectangle 加屏幕坐标来做坐标点击,比如把 FindElement 替换成“按 Name 找到外层容器,取矩形中心点,再偏移固定像素点击”。这个方案极易翻车,窗口尺寸一变就要重新调参,只能作为最后手段。

6. 二次开发路线:再往前一步就是一套可维护的 UI 回归框架

6.1 页面对象补全 + 数据驱动

示例工程只演示了登录和主面板两条链路。真正落到自己的系统时,先把所有高频回归窗口都建成页面对象类,每个类只放元素属性,不放业务逻辑。然后给登录这类多账号场景加数据驱动:

[TestCase("admin", "123456", "操作员")] [TestCase("boss", "888888", "管理员")] public void 多账号登录_应进入对应主界面(string user, string pwd, string expectedRole) { var login = new LoginPage(_root); UiActionExecutor.SetText(login.UserNameInput, user); UiActionExecutor.SetText(login.PasswordInput, pwd); UiActionExecutor.Click(login.LoginButton); WaitHelper.WaitUntil( () => UiElementFinder.TryFindElement(_root, "pnlMain", ControlType.Pane) != null, timeoutMs: 8000); var main = new MainPage(_root); UiAssert.TextEqual(main.RoleLabel, expectedRole); }

TestCase 的每行参数一式三份:账号、密码、预期角色。数据驱动的收益是新增账号场景时不用抄用例方法,加一行参数就行。注意被测系统密码加密或验证码逻辑一旦调整,这组数据就得同步维护。

6.2 接进 CI 流水线 + 失败自动重试

UI 测试天然不稳定,一两次偶发失败不能直接判定系统回归。比较成熟的做法是在 CI 里给关键用例设置自动重试两到三次,重试仍失败的才触发告警。NUnit 提供了现成的 Retry 属性,直接标在测试方法上即可,不需要另外造轮子。

报告层面,我一般把 NUnit 的 trx 结果文件配合 ReportUnit 转成 HTML,页面上能直观看到哪些用例在哪个步骤失败。关键用例的失败截图路径写进 TestContext,这样打开报告的人不用翻日志就能看到当时的界面现场。示例工程里已经把 UiAssert.Snapshot 挂进断言失败分支,CI 里只需要把截图输出目录和报告目录关联起来。

6.3 验证方法:先跑两条链路再全面铺开

不要一上来就想着把所有模块都自动化。我的习惯是先挑两条链路:一条是登录进主界面,一条是涉及表单提交的回写流程。这两条跑通,说明元素定位、等待、断言、进程清理四个基础能力都过关了,再往订单、审核、导出这些模块铺开。铺开时严格控制页面对象的增长速度,一周内如果新增页面类超过五个,就要回头审视是不是页面对象粒度太粗了。

从那以后,我每次接手桌面端 UI 测试,都强制走一遍“Inspect 先读 AutomationId,再写页面对象,最后才写用例”的流程。这套顺序看着慢,实际是最省返工的路:属性没确认就写代码,等于在赌控件状态,等到跑挂再回来查,时间已经搭进去一倍。UIA 的坑每个控件都有自己的脾气,按流程走稳比快速堆用例重要得多,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进