基于C# UI Automation的桌面客户端自动化测试实战:零依赖示例工程
简介基于C# UI Automation的自动化测试示例工程为Windows桌面应用开发者、测试工程师及自动化测试初学者提供可直接运行的演示项目解决如何通过代码模拟真实用户操作界面元素的问题。工程包含15个按钮示例覆盖启动和关闭程序、文本框编辑、按钮点击、下拉列表展开、控件遍历等典型交互适合用于回归测试、冒烟测试和持续集成前的功能验证。整个压缩包共29个文件、约71KB以7个C#源文件为核心另含2个可直接运行的exe、2个配置文件、2个资源文件与2个RESX界面定义以及调试所需的PDB文件等目录结构简洁便于按模块对照学习。通过阅读和运行这些源码可掌握UI Automation的核心API调用、控件查找与事件触发思路理解Windows Forms测试工程的搭建方式并学会用自动化脚本替代高频重复的手工点击。已有2498人学习浏览尤其适合刚开始接触桌面UI自动化测试的开发者作为教学演示或练手项目均能快速上手。 第一次被领导安排写桌面客户端自动化测试的时候我下意识想到的是 Python 的 pywinauto结果测试机装不了第三方包方案直接被否。后来翻到 Windows 自带的 UI Automation 库才发现 C# 自己就能完成控件定位、取值、点击这一整套操作而且不需要任何额外运行时。这篇文章就是我沉淀下来的一份基于 C# UI Automation 的自动化测试示例工程从环境准备、核心 API 讲到完整登录用例最后再梳理几个实测才会踩到的坑。适合给 WinForms/WPF 客户端做回归测试、又不想引入 FlaUI 或 WinAppDriver 这类重依赖方案的同学参考。1. 为什么我最终选了 C# UI Automation而不是其他工具先说结论如果被测程序是 WinForms、WPF或者 Microsoft Store 应用C# UI Automation 往往是成本最低、见效最快的一条路。它不是为测试而生的但恰恰因为出身特殊反而很适合做界面自动化。1.1 它解决问题的出发点是“无障碍”UI Automation 是微软从 Windows Vista 开始逐步完善的一套无障碍接口屏幕阅读器、语音助手都是通过它来“看”界面的。换句话说UIA 默认就能读懂标准控件的角色、名称、状态和交互方式不需要被测程序额外开什么测试后门。这个特性搬到自动化测试里就是天然优势界面元素会暴露成一棵控件树每个节点带 AutomationId、Name、ControlType和网页自动化里用 DOM 定位元素非常像。很多人容易把它和老的 Win32 窗口消息混在一起。UIA 拿到的不是句柄和消息而是语义层面的模式比如按钮的 InvokePattern、输入框的 ValuePattern、列表项的 SelectionItemPattern。这样写出来的用例可读性高也不用关心控件内部是用哪个窗口类实现的测试代码和界面实现细节解耦得很干净。1.2 和其他桌面自动化方案的横向对比真正动工时我面前其实摆了好几个候选FlaUI、WinAppDriver、纯 Win32 SendMessage。我当时做了一个对比表选型逻辑一目了然方案底层机制适用场景主要限制C# UI AutomationWindows UIA 接口WinForms/WPF、商店应用自定义控件、WebView 需要额外处理FlaUI包装 UIA / Win32同 UIA但 API 更顺手第三方 NuGet 包需要引入依赖WinAppDriverUIA WebDriver 协议桌面端和移动端统一用例需要常驻服务进程部署链路变长Win32 SendMessage窗口消息老式控件、自绘控件兜底拿不到语义信息维护成本极高最终选 UIA 而不是 FlaUI 的理由很纯粹FlaUI 本质上是对 UIA 的再封装API 确实更简洁但我想做一个零第三方依赖、拿到源码就能编译的示例工程直接用系统库最省心。WinAppDriver 适合需要 WebDriver 风格的场景但对一个登录页测试来说太重了。Win32 那套只适合处理特殊控件不适合当主线。2. 示例工程搭建环境、引用与被测程序这一节先把工程骨架说清楚。如果你已经熟悉 UIA 的基本概念可以直接跳到第三节的 API 拆解。2.1 环境准备和程序集引用Framework 与 .NET 6 两条路示例工程我用的 Visual Studio 2022 .NET Framework 4.8。为什么不用 .NET 6/8因为 UIA 的类型库在 .NET Framework 里直接以程序集引用方式提供勾选就能用在 .NET 6/8 下需要 NuGet 搜索UIAutomationClient包安装也不复杂但作为示例工程Framework 4.8 阻力最小。具体操作很直接创建控制台应用项目名称UiAutoDemo然后在引用管理器里勾选UIAutomationClient和UIAutomationTypes。这里有个容易忽略的点只勾 UIAutomationClient 不够PropertyCondition、Condition这些类型在 UIAutomationTypes 里漏了编译期就会报类型找不到。另外下文示例代码要用剪贴板和截图所以还需要引用System.Windows.Forms和System.Drawing。提示如果你坚持用 .NET 6记得在 csproj 里加上UseWindowsFormstrue/UseWindowsForms否则System.Windows.Forms.Clipboard和SendKeys不可用。2.2 为被测程序提前埋好 AutomationId示例工程里我专门做了一个只有登录页的最小 WPF 程序当被测对象。为什么反复强调给控件起 AutomationId因为 Name 会跟着界面文案变中英文版本切换后“登录”变成“Sign In”按 Name 定位的脚本当场全挂。AutomationId 是给测试用的稳定标识和 x:Name 是两回事在 XAML 里用AutomationProperties.AutomationId单独指定StackPanel Margin20 TextBlock Text用户名 / TextBox x:NametxtUser Width200 Height24 AutomationProperties.AutomationIdtxtUser / TextBlock Text密码 / PasswordBox x:NametxtPwd Width200 Height24 AutomationProperties.AutomationIdtxtPwd / Button x:NamebtnLogin Content登录 Width100 Height28 Margin0,12,0,0 ClickBtnLogin_Click AutomationProperties.AutomationIdbtnLogin / TextBlock x:NamelblMsg ForegroundRed AutomationProperties.AutomationIdlblMsg / /StackPanel如果被测程序是历史遗留产品最好拉着开发把关键控件补上 AutomationId。这一步必须在写脚本前谈拢否则脚本会改到怀疑人生。补不了的情况下再退而求其次用ControlType Name组合定位但要做好被文案变更折腾的心理准备。2.3 项目结构设计整个解决方案按三块组织OrderApp被测 WPF 程序、UiAutoDemo测试工程、Inspect 工具外部验证工具后面会讲。测试工程里不搞过度封装文件按职责拆开UiAutoDemo/ ├── Program.cs // 用例入口Main 函数 ├── UiElementHelper.cs // 封装查找、点击、输入等通用操作 └── TestCase_Login.cs // 登录用例的具体步骤和断言这样拆的好处是先跑通冒烟用例再谈框架。很多初学者一上来就上 Page Object、数据驱动结果被前置复杂度劝退。先把一个用例从启动程序到断言跑通比什么都强。3. 核心对象拆解定位控件、读取状态、触发动作UIA 的 API 说多也多但示例工程里反复用到的就四样东西AutomationElement、Condition、Pattern、TreeScope。把这几个概念吃透剩下的都是组合拳。3.1 从进程到主窗口AutomationElement 的根一切查找都从根开始。根是AutomationElement.RootElement对应整个桌面。最常见的做法是先启动进程、拿到进程 ID再用进程 ID 在根的直接子级里找主窗口var appProcess Process.Start(D:\apps\OrderApp.exe); var windowCondition new PropertyCondition( AutomationElement.ProcessIdProperty, appProcess.Id); var window AutomationElement.RootElement.FindFirst( TreeScope.Children, windowCondition);这里TreeScope.Children表示只找根的直接子级主窗口肯定在这一层。如果用Descendants去搜全桌面性能损耗会明显变大而且可能匹配到其他进程的窗口。3.2 定位控件的条件组合PropertyCondition 与 AutomationId有了窗口容器接下来就是在它的后代节点里找具体控件。最推荐用 AutomationIdvar userBox window.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, txtUser));条件是可以组合的。比如登录按钮初始是禁用状态我想找一个“可用状态下的登录按钮”就用AndConditionvar enabledLoginBtn window.FindFirst(TreeScope.Descendants, new AndCondition( new PropertyCondition(AutomationElement.AutomationIdProperty, btnLogin), new PropertyCondition(AutomationElement.IsEnabledProperty, true)));这里的核心思路是单个属性匹配不够精确时通过AndCondition、OrCondition叠加条件把目标控件圈出来。这和 SQL 查询的 where 条件是一个思维模式。3.3 模式Pattern才是 UI 动作的开关找到控件只是第一步真正做操作靠的是 Pattern。不同控件支持不同模式常见的对应关系如下控件类型常用模式典型操作ButtonInvokePattern点击按钮TextBoxValuePattern获取/设置文本列表项、单选、复选SelectionItemPattern选中某一项ComboBox、TreeViewExpandCollapsePattern展开/收起滚动区域ScrollPattern滚动到指定位置富文本区域TextPattern读取全部文本取模式时不要用GetCurrentPattern直接拿它遇到不支持的控件会抛异常。更稳妥的写法是TryGetCurrentPatternif (element.TryGetCurrentPattern(InvokePattern.Pattern, out object patternObj)) { ((InvokePattern)patternObj).Invoke(); }3.4 没有内置 WaitFor 的应对方案UIA 和 Selenium 不同它没有内置的WebDriverWait控件没出现时直接 FindFirst 返回 null。所以必须自己写轮询等待。这个逻辑几乎每个用例都会用到我统一放在 UiElementHelper 里public static AutomationElement WaitForElement( AutomationElement root, Condition condition, TreeScope scope, TimeSpan timeout) { var deadline DateTime.Now timeout; while (DateTime.Now deadline) { var element root.FindFirst(scope, condition); if (element ! null) { return element; } Thread.Sleep(200); } return null; }轮询间隔 200 毫秒是我实测比较舒服的值太密会频繁扫整棵控件树太疏用例跑得慢。关键场景可以再加一层“元素已存在但尚未可用”的判断把 IsEnabled 条件一并查。4. 完整示例自动登录一个小型 WPF 程序理论讲完直接看一个能跑的完整用例。目标很明确启动 OrderApp输入用户名密码点登录断言界面上出现“登录成功”。4.1 被测程序与测试代码的关系被测程序就是 2.2 节那个 XAML 登录窗口。点击登录后代码里把lblMsg的文本改成“登录成功”或“用户名或密码错误”测试脚本只需要读这个文本就能断言。4.2 测试代码启动、填写、点击、断言using System; using System.Diagnostics; using System.Threading; using System.Windows.Automation; namespace UiAutoDemo { [STAThread] internal static class Program { private static void Main() { // 1. 启动被测程序 var appProcess Process.Start(D:\apps\OrderApp.exe); // 2. 等待主窗口出现 var window WaitForElement( AutomationElement.RootElement, new PropertyCondition(AutomationElement.ProcessIdProperty, appProcess.Id), TreeScope.Children, TimeSpan.FromSeconds(10)); if (window null) { Console.WriteLine(登录窗口未出现测试失败。); return; } // 3. 定位控件 var userBox FindByAutomationId(window, txtUser); var pwdBox FindByAutomationId(window, txtPwd); var loginBtn FindByAutomationId(window, btnLogin); var lblMsg FindByAutomationId(window, lblMsg); // 4. 输入并点击 SetValue(userBox, admin); SetPassword(pwdBox, 123456); Click(loginBtn); // 5. 断言提示文本 var message GetText(lblMsg); Console.WriteLine($界面提示{message}); Console.WriteLine(message 登录成功 ? 测试通过 : 测试失败); } private static AutomationElement WaitForElement( AutomationElement root, Condition condition, TreeScope scope, TimeSpan timeout) { var deadline DateTime.Now timeout; while (DateTime.Now deadline) { var element root.FindFirst(scope, condition); if (element ! null) { return element; } Thread.Sleep(200); } return null; } private static AutomationElement FindByAutomationId(AutomationElement parent, string automationId) { return parent.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, automationId)); } private static void SetValue(AutomationElement element, string value) { var pattern (ValuePattern)element.GetCurrentPattern(ValuePattern.Pattern); pattern.SetValue(value); } private static void SetPassword(AutomationElement element, string value) { // 密码框出于安全考虑不暴露 ValuePattern用剪贴板 快捷键输入 element.SetFocus(); System.Windows.Forms.SendKeys.SendWait(^a); System.Windows.Forms.SendKeys.SendWait({DELETE}); System.Windows.Forms.Clipboard.SetText(value); System.Windows.Forms.SendKeys.SendWait(^v); } private static void Click(AutomationElement element) { if (element.TryGetCurrentPattern(InvokePattern.Pattern, out object invokePatternObj)) { ((InvokePattern)invokePatternObj).Invoke(); return; } if (element.TryGetCurrentPattern(SelectionItemPattern.Pattern, out object selectionPatternObj)) { ((SelectionItemPattern)selectionPatternObj).Select(); return; } throw new InvalidOperationException(该控件不支持 Invoke 或 Select 模式); } private static string GetText(AutomationElement element) { if (element.TryGetCurrentPattern(ValuePattern.Pattern, out object valuePatternObj)) { return ((ValuePattern)valuePatternObj).Current.Value; } if (element.TryGetCurrentPattern(TextPattern.Pattern, out object textPatternObj)) { return ((TextPattern)textPatternObj).DocumentRange.GetText(-1); } return element.Current.Name; } } }4.3 这段代码里值得注意的三个设计第一Main方法标了[STAThread]。剪贴板和 SendKeys 依赖单线程单元模型不加这个属性运行到剪贴板操作时会直接抛ThreadStateException。第二SetValue 直接拿ValuePattern给用户名框赋值走的是 UIA 的语义接口比 SendKeys 稳定得多。密码框则特殊对待因为 WPF 的 PasswordBox 出于安全考虑不对外暴露内容只能通过焦点 快捷键的方式输入。第三Click 做了两层降级先试InvokePattern不行再试SelectionItemPattern。这保证了同一套方法能应对 Button、RadioButton、ListBoxItem 等不同控件是实际项目中非常实用的“通用点击”。这个用例跑起来后控制台输出“界面提示登录成功”和“测试通过”。如果被测程序有 bug登录后提示文案不对断言就会失败整个用例就是标准的红灯。5. 实测中容易翻车的坑以及排查链路这一节是血泪史。示例工程跑通不难难的是换一台机器、换一个版本后脚本开始无缘无故红。5.1 线程模型Main 缺少 STAThread这是我第一次跑挂的原因。当时写了个控制台程序启动后只要一碰剪贴板就崩溃报错信息指向线程模型。排查过程很简单看异常堆栈发现是Clipboard.SetText在 MTA 线程里调用被拒。修复就是给 Main 加[STAThread]顺手把 Wi 上顺带提一句如果你用 .NET 6 的控制台模板默认生成的 Main 是async Task同样要注意线程模型问题。5.2 时序问题控件“假存在”经典场景窗口已经弹出来了FindFirst 也找到了按钮但一执行 Invoke 就抛异常。用 Inspect 工具一看按钮的IsEnabledFalse界面还在做初始化。这就是典型的“元素存在但不可用”状态。我的排查思路是先看异常信息再看控件属性最后才改代码。对应修复就是 2.2 里用过的组合条件把AutomationIdProperty和IsEnabledPropertytrue一起作为等待条件确保拿到的是可用状态的控件。5.3 密码框读不到值点击按钮没反应如果你尝试用ValuePattern读 PasswordBox会发现拿到的永远是空字符串这是 WPF 故意做的安全限制。更隐蔽的问题是密码框没输进去点击登录自然没反应但脚本不会报错只会断言失败。碰到这种“用例全红但找不到原因”的情况优先怀疑输入环节。我建议所有输入操作都留日志至少把“准备给哪个控件输入什么内容”打出来定位会快很多。5.4 使用 Inspect 工具反向确认控件属性写脚本时最常用的外部工具是 Windows SDK 自带的 Inspect路径通常是C:\Program Files (x86)\Windows Kits\10\bin\版本号\x64\inspect.exeInspect 能实时显示鼠标悬停控件的 AutomationId、Name、ControlType、支持的模式列表。它最大的价值是当 FindFirst 返回 null 时帮你确认这个控件到底有没有 AutomationId名字到底是什么。我见过不少人把 Name 当成 AutomationId 用结果怎么查都查不到Inspect 一照就原形毕露。5.5 WebView2 与第三方自绘控件可能是个黑盒如果被测程序里嵌了 WebView2 或某些自绘表格控件UIA 很可能只看到一个大的Pane里面没有可操作的子元素。这不是你代码写错了是 UIA 对这类内容天生支持有限。遇到这种情况我建议分两步先确认该控件是否通过 UIA 暴露了必要的子元素如果没有果断换思路内嵌网页用 Playwright 或 Selenium 走远程调试端口自绘控件则找开发要可测试性支持。死磕 UIA 会浪费大量时间。6. 从示例到可用框架还有几步要走示例工程跑通只是起点。把一段脚本变成能持续跑、出了问题能快速定位的测试资产还需要做几件事。6.1 封装一个轻量的 Page Object我不建议一上来就上完整 Page Object 框架但至少把元素定位和操作封装成方法。示例里的UiElementHelper就是雏形。进一步可以做“页面类”把某一页的元素和操作聚合到一起用例代码就变成“打开登录页、输入账号、提交、断言结果”维护的人不用关心底层是 UIA 还是别的什么。6.2 失败时截图与日志界面自动化最烦的是“跑挂了但现场没了”。我现在的习惯是所有用例在断言失败时自动截屏并保存当时的控件树关键属性。截图用System.Drawing的CopyFromScreen就能实现using var bitmap new Bitmap(Screen.PrimaryScreen.Bounds.Width, Screen.PrimaryScreen.Bounds.Height); using var graphics Graphics.FromImage(bitmap); graphics.CopyFromScreen(0, 0, 0, 0, bitmap.Size); bitmap.Save(failure.png, System.Drawing.Imaging.ImageFormat.Png);这几十行代码能让你排查问题的效率提升一个量级。6.3 接入测试框架与 CI把 Main 函数里的逻辑搬到 NUnit 或 MSTest 里每个[Test]方法就是一个用例然后用测试结果文件驱动 CI。这里有一个必须在环境层面注意的坑桌面 UI 测试需要有可见的交互式会话不能放在 Windows 服务或者 Session 0 里跑否则窗口根本不会正常显示测试必挂。我建议准备一台专用的测试代理机保持登录状态统一跑用例。6.4 何时该放弃 UIA换 FlaUI/WinAppDriver最后说个边界问题。UIA 原生库虽然零依赖但性能一般也没有方便的缓存机制。如果你要做几千个控件的复杂遍历、对执行速度有硬性要求或者需要一套代码同时覆盖桌面和移动端那 FlaUI 和 WinAppDriver 是值得迁移的方向。示例工程的意义在于把底层能力和常见套路讲透迁移到 FlaUI 时你会发现很多概念是通用的只是 API 更好用了。我个人在实际项目里的体会是UI Automation 写出来的用例最大的敌人从来不是 API而是界面的稳定性。控件没有 AutomationId、文案随意改、异步加载导致时序抖动这些才是脚本天天红的真正原因。所以我现在拿到一个项目第一件事不是写用例而是拉着开发把关键控件补上 AutomationId再把启动参数、测试账号、测试环境全部做成配置。工具永远是第二位的被测程序的可测性才是第一位。这个思路比换任何框架都管用。本文还有配套的精品资源点击获取