简介:面向C#开发与测试人员的UI Automation自动化测试示例工程,以Windows Forms桌面程序为载体,演示如何通过.NET UI Automation库实现界面元素的自动定位与操控。压缩包共29个文件,大小71KB,包含cs源码、sln/csproj工程文件、exe可执行程序、config配置及pdb调试符号等,其中源码与工程文件可完整还原项目结构,便于直接编译学习。工程内置15个操作示例,覆盖打开/关闭程序、编辑文本、点击按钮、展开列表、遍历控件等高频场景,适合自动化测试入门者理解控件查找、属性读取与事件触发的基本原理,也可作为回归测试与持续集成脚本的参考模板。已有2498人学习下载,资源体量紧凑、示例集中,能够帮助读者快速上手C# UI自动化测试的编写与调试。 做Windows桌面客户端自动化测试,绕不开的一个技术栈就是C# + UI Automation。这个示例工程是我在实际项目里沉淀下来的一个最小可运行框架,专门处理WinForms、WPF这类原生Windows应用的UI自动化测试需求。如果你正在搭自动化测试框架,或者想给老旧的桌面程序补一层回归测试,这篇文章应该能帮你少走不少弯路。
我先说一下这套东西到底能干什么。UI Automation是Windows系统提供的一套UI控件访问框架,它能让我们通过代码去“看”到界面上的窗口、按钮、输入框,并且模拟用户去点击、输入、读取属性。和坐标点鼠标那种脆弱的脚本方式不同,它是基于控件语义去定位的,窗口挪个位置、DPI变一下,脚本照样能跑。C#在这块有天然优势,System.Windows.Automation命名空间直接封装好了全套API,不需要额外引入第三方依赖。这个示例工程就是把最核心的封装逻辑、查找策略、操作模板、等待机制都整理成了可以直接用的代码,照着改就能接入你自己的被测程序。
1. 先把需求讲清楚:UI Automation到底能做什么
1.1 什么时候才值得用UI Automation
很多初学者会陷入一个误区,觉得自动化测试就是用脚本去模拟鼠标键盘,实际上UI Automation的定位要更精准一些。它在Windows系统里建立了一层“辅助功能桥”,所有符合规范的控件都会暴露自己的角色、名称、值、状态等属性,测试代码通过这些属性去识别和操作控件,而不是靠像素坐标。
我自己总结下来,适合用UI Automation的场景主要有三类。第一类是WinForms和WPF开发的传统桌面应用,这类老系统没有接口测试入口,也没有内建的自动化测试框架,UI Automation几乎是唯一不侵入源码的路径。第二类是混合型客户端,比如C#上位机软件同时对接硬件设备,界面操作逻辑和通讯逻辑混在一起,用UI自动化可以从用户视角做端到端的回归验证。第三类是内部工具类软件的冒烟测试,不需要搭建重型测试平台,一个控制台程序就能跑完核心流程检查。
这里要特别提醒一点:UI Automation不是万能的。对于自绘控件(比如某些游戏界面、自定义绘制的图表控件)、WebView内嵌页面、DirectX渲染区域,它经常拿不到有效的控件信息。遇到这种情况就需要结合图像识别或者其他方案,别死磕UI Automation一个路子。
1.2 示例工程覆盖的典型场景
这个示例工程里,我覆盖了桌面应用自动化最常用的几个操作:窗口查找与激活、控件遍历与定位、按钮点击、文本输入、属性断言、异常截图。这些操作组合起来,基本能覆盖日常回归测试80%以上的需求。
举个例子,我们在做C#上位机软件测试时,经常要验证“点击启动按钮后,状态栏文字是否从就绪变成运行中”。这个case用UI Automation做起来就是两行核心逻辑:先Invoke启动按钮,再轮询查找状态栏控件并获取Name属性,然后断言文本是否符合预期。整个过程不依赖于界面坐标,控件在屏幕哪个位置都无所谓。
还有一个高频场景是菜单遍历。很多桌面客户端的深层功能藏在菜单栏里,传统坐标点击脚本一旦菜单折叠或者窗口大小变化就全盘崩溃。用UI Automation可以通过MenuItem控件的ExpandCollapsePattern展开菜单,再通过Name属性精确点击目标项,稳定性和可读性都提升了一个量级。
2. 示例工程的整体设计思路
2.1 目录结构与模块划分
这个工程我刻意保持了轻量,没有引入大型测试框架,核心就是三个模块:AutomationHelper(底层封装)、PageObject(页面对象层)、TestCase(用例入口)。
AutomationHelper里封装的是对System.Windows.Automation API的二次包装,包括元素查找、操作执行、等待轮询、日志记录。PageObject层把被测应用的每个页面抽象成一个类,页面上的按钮、输入框、文本框都定义成属性,操作行为定义成方法。TestCase层就是纯粹的测试场景脚本,描述“用户做了什么,系统应该给出什么反馈”。
AutomationDemo/ ├── Helper/ │ ├── AutomationHelper.cs // 查找、操作、等待的通用封装 │ ├── WaitHelper.cs // 轮询等待控件出现/消失 │ └── LogHelper.cs // 日志与截图 ├── Pages/ │ ├── MainWindowPage.cs // 主窗口页面对象 │ └── LoginWindowPage.cs // 登录窗口页面对象 └── TestCases/ └── SmokeTests.cs // 冒烟测试用例为什么要把页面对象单独拆一层?这个设计借鉴了Web测试里的PageObject模式。桌面应用的UI变更很频繁,如果测试脚本里到处都是查找控件的细节,一旦控件名称调整,改动会散落到几十处。有了页面对象层,所有控件定位逻辑收敛到一个地方,UI变动时只需要改对应的Page类,对测试用例层完全透明。
2.2 为什么选择原生API而不是第三方框架
现在市面上也有一些封装好的UI自动化库,比如FlaUI、White,它们确实能在某些场景下减少代码量。但在这个示例工程里,我坚持用System.Windows.Automation原生API,理由有三个。
第一是零依赖。原生API随.NET框架一起发布,不需要额外安装NuGet包,部署到CI环境或者客户现场都省心。第二是API的稳定性和覆盖面。系统级的UIAutomation是Windows操作系统的组件,微软自己维护,兼容性有保障。第三是排查问题方便。封装库一旦出问题,你很难判断是库的bug还是控件本身不支持,用原生API至少能基于系统级的行为去分析。
当然,原生API也有它的缺点,最明显的是代码写起来啰嗦,查找控件需要手动拼Condition。不过这个问题通过封装可以解决,我在AutomationHelper里提供了几个重载方法,传控件类型和名称就能返回元素,使用体验和第三方库差不多。
3. 核心代码实现与实操细节
3.1 初始化AutomationElement与窗口查找
一切操作的前提是先拿到目标窗口的AutomationElement。我推荐通过进程ID去定位,而不是直接用窗口标题。因为进程ID唯一且稳定,标题可能重复或动态变化。
public static AutomationElement GetWindowByProcessId(int processId) { var condition = new PropertyCondition( AutomationElement.ProcessIdProperty, processId); return AutomationElement.RootElement.FindFirst( TreeScope.Children, condition); }这段代码的逻辑是从桌面根节点往下找一层,把进程ID匹配的顶层窗口拿出来。这里有几个细节值得注意。
TreeScope.Children很关键。如果这里误用TreeScope.Descendants,会在全控件树里递归查找,性能开销巨大,而且很可能找到隐藏的子窗口,导致后续操作定位到错误的元素上。我自己就踩过这个坑,用Descendants查找记事本窗口,结果拿到的不是主窗口,而是一个隐藏的辅助窗口,点击操作全部失效。
还有一点,如果目标程序是多窗口应用,同一个进程下可能同时存在主窗口和弹窗,这时候建议再加一个辅助条件,用窗口标题一起过滤。标题最好匹配固定不变的部分,例如窗口的主标题“设备控制台 - 连接正常”变化的部分是“连接正常”,那就用“设备控制台”作为前缀匹配。
3.2 控件定位与操作的核心封装
拿到窗口元素之后,接下来的核心需求就是在窗口内部查找特定控件。最基础也是最常用的方式是用ControlType加Name属性组合定位。
public static AutomationElement FindElement( AutomationElement parent, ControlType controlType, string name, int timeoutSeconds = 10) { var condition = new AndCondition( new PropertyCondition(AutomationElement.ControlTypeProperty, controlType), new PropertyCondition(AutomationElement.NameProperty, name)); return WaitForElement(parent, condition, timeoutSeconds); }ControlType可以理解为控件的“身份证类型”。如果一个按钮的ControlType是Button,你按ControlType.Button去查绝对不会错,哪怕它的Name属性为空。Name属性则是控件的“可读名称”,大部分情况下等同于用户在界面上看到的文本。这里有个重要经验:Name属性并不总是等于显示文本。有些控件的Name由代码动态拼接,还可能包含快捷键标记(如下划线符号),实际运行时要学会用条件组合或者正则去处理,不要一上来就做全等匹配。
等一等机制的实现,我这里重点说一下。UI自动化测试最忌讳的就是死等。控件加载需要时间,响应速度不稳定,直接Thread.Sleep固定等待几秒钟是初学者最容易犯的错。我的做法是封装一个轮询等待,循环尝试查找,直到超时。
private static AutomationElement WaitForElement( AutomationElement parent, Condition condition, int timeoutSeconds) { var stopwatch = System.Diagnostics.Stopwatch.StartNew(); while (stopwatch.Elapsed.TotalSeconds < timeoutSeconds) { AutomationElement element = parent.FindFirst(TreeScope.Descendants, condition); if (element != null) { return element; } System.Threading.Thread.Sleep(200); } return null; }每200毫秒轮询一次,这种频次在性能和实时性之间比较平衡。整套机制的核心思想是“轮询直到出现”,而不是“等待固定时间再查找”。前者是反应式的,界面什么时候出现就什么时处理完;后者是呆板式的,即使控件200毫秒就出现了,也要傻傻等满3秒。
3.3 按钮点击、文本输入与属性断言
控件的操作是通过对应的Pattern接口完成的。按钮类控件支持InvokePattern,输入框类控件支持ValuePattern,列表类控件支持SelectionItemPattern。这些Pattern是UI Automation里最核心的概念,可以理解为不同类型的控件对外暴露的“能力接口”。
public static void ClickButton(AutomationElement button) { var invokePattern = button.GetCurrentPattern(InvokePattern.Pattern) as InvokePattern; invokePattern.Invoke(); } public static void SetText(AutomationElement textBox, string text) { var valuePattern = textBox.GetCurrentPattern(ValuePattern.Pattern) as ValuePattern; valuePattern.SetValue(text); }实际操作中,按钮的点击还有一个更稳妥的备选方案。个别第三方控件实现了InvokePattern但Invoke方法没有任何响应,这时候可以退一步用LegacyIAccessiblePattern的DoDefaultAction,这个模式是为了兼容旧控件保留的。
文本输入看起来简单,但自动化脚本处理中文输入时偶尔会遇到尴尬:SetValue能设进去,但界面上显示的却是乱码。这个问题的根源往往不在UIAutomation,而是目标程序的输入框对自动化输入的编码支持有问题。遇到这种情况,一个临时方案是直接用Windows消息发送WM_SETTEXT,另一个方案是改用剪贴板粘贴的方式,先把文本复制到剪贴板再模拟Ctrl+V。
属性断言的核心方法是通过GetCurrentPropertyValue获取控件的属性值,包括Name、HelpText、IsEnabled、IsOffscreen等。比如验证一个按钮是否置灰:
bool isEnabled = button.GetCurrentPropertyValue( AutomationElement.IsEnabledProperty) as bool? ?? false;断言这一块要记住,UI自动化框架本身不擅长做断言,不要把复杂的业务规则校验写进测试脚本里。它能验证的是“按钮可点击”“界面出现提示”“状态栏文本变化”这类表现层的东西,真正的数据正确性校验还是应该交给接口或单元测试去解决。
4. 实操过程与核心环节实现
4.1 一个完整的冒烟测试用例实现
纸上谈兵没什么用,我直接拿Windows自带的记事本程序做个完整的示例,这个例子任何人都能本地跑起来复现,也方便验证自己环境是否正常。
public void NotepadSmokeTest() { // 启动记事本 Process.Start("notepad.exe"); Thread.Sleep(1000); // 步骤1:通过进程ID获取主窗口 AutomationElement mainWindow = null; foreach (Process process in Process.GetProcessesByName("notepad")) { mainWindow = AutomationHelper.GetWindowByProcessId(process.Id); if (mainWindow != null) break; } // 步骤2:在编辑区输入文本 AutomationElement editArea = AutomationHelper.FindElement( mainWindow, ControlType.Document, "文本编辑器"); AutomationHelper.SetText(editArea, "hello ui automation"); // 步骤3:打开“文件”菜单 AutomationElement fileMenu = AutomationHelper.FindElement( mainWindow, ControlType.MenuItem, "文件(F)"); AutomationHelper.ClickButton(fileMenu); // 步骤4:点击“另存为” AutomationElement saveAsItem = AutomationHelper.FindElement( mainWindow, ControlType.MenuItem, "另存为"); AutomationHelper.ClickButton(saveAsItem); // 步骤5:验证另存为对话框出现 AutomationElement dialog = AutomationHelper.FindWindowByTitle("另存为"); if (dialog == null) throw new Exception("另存为对话框未出现"); }这个用例的核心思路是“一步步引导控件状态变化,然后在关键节点做验证”。这里我用了Thread.Sleep(1000)仅用于进程启动后的初始等待,业内推荐的做法是在获取窗口的封装里加内置轮询。实际工程里,进程启动后根本没窗口的情况很常见,几种情况要分开分析:程序在启动画面停留、程序遇到crash弹窗、程序以托盘方式运行而不显示主窗口,用的处理策略是完全不同的。
我写这个冒烟用例时,实际调试过程中真正花时间的是菜单项的查找。记事本的高版本菜单名称和Win7、Win10系统语言版本有关,中英文环境的控件名称完全不同。脚本若在CI的英文环境跑,中文环境跑,控件查找都会失败消失。能写进工程里的,我会把控件名的差异统一都写进配置表。
4.2 日志与失败截图的设计
自动化测试跑起来之后会产生大量的执行记录,日志设计的好坏直接影响排查效率。我在这套工程里设了三个级别的日志,Info记录常规操作,Warn记录非致命异常,Error记录用例失败。每条日志必须带时间戳和操作对象名称。
失败截图是另一个必备能力。UI自动化用例一挂就是找不到元素,但口说无凭,需要截图留下证据。截图可以通过System.Drawing和System.Windows.Forms的CopyFromScreen实现:
public static void CaptureScreen(string filePath) { Rectangle bounds = Screen.PrimaryScreen.Bounds; using (Bitmap bitmap = new Bitmap(bounds.Width, bounds.Height)) { using (Graphics g = Graphics.FromImage(bitmap)) { g.CopyFromScreen(Point.Empty, Point.Empty, bounds.Size); } bitmap.Save(filePath, ImageFormat.Png); } }截图时还应该把当前自动化元素树一起dump出来。有时候从截图看界面一切正常,就是找不到对应元素,这种情况往往是元素不在屏幕可视区域、或者窗口最小化、再或者被其他窗口遮挡。元素树dump能直接列出当前窗口下所有控件的类型和Name,定位问题效率极高。
4.3 工程落地时的执行配置
示例工程跑通之后,做正式项目的自动化框架还得考虑执行配置问题。最基本的一个是要支持从配置文件读取被测程序的路径、启动参数、主要控件的Name属性。这样做的好处是测试脚本和应用代码解耦,程序改个显示名称不用动编译过的脚本。
另外执行环境也要标准化。建议用一个专门的自动化测试虚拟机跑用例,系统版本、屏幕分辨率、DPI缩放比例固定下来。桌面应用自动化最坑的就是开发机屏幕是4K 150%缩放,CI机器却是1080P 100%缩放,控件布局完全变化,查找和点击行为都会异常。
我在这套工程里还加了一个“自愈”机制:主窗口查找失败时,自动尝试通过窗口标题模糊匹配找到目标进程,并激活窗口到前台再执行后续操作。这个看起来不那么“优雅”的兜底策略,实测能解决很多Windows窗口Z序变化导致的找不到控件的诡异问题。
5. 常见问题与排查技巧实录
5.1 元素找不到,问题到底出在哪里
“明明界面上有这个按钮,为什么脚本就是找不到”这是群里问得最多的问题。经验表明,绝大多数情况集中在下面几个根源上。
一是控件类型判断错误。很多人想当然地以为界面上看起来像按钮的控件ControlType一定是Button,但现实中很多应用用了自定义样式,比如WPF里的Label加上鼠标点击事件,或者ToolStripStatusLabel显示文字后模拟点击。这类控件在UI Automation树里可能呈现为Text或Custom类型,按Button去找当然找不到。排查技巧是写一个遍历函数把目标窗口下所有控件都打印出来,看到真实类型再修改查找条件。
二是Name属性有动态变化。部分应用为了区分不同实例,会在控件名前拼接ID或时间戳,例如“用户18645”这样。解决办法是用PropertyCondition加正则,或者自己遍历父节点控件再筛选。
三是窗口没有激活。很多桌面程序在窗口处于后台或者最小化状态时,会停止渲染控件,UI Automation拿到的元素树是空的。处理方式是在查找前先调用SetForegroundWindow激活窗口,再重新获取元素。
5.2 64位和32位进程带来的兼容性陷阱
这一条特别值得注意。UI Automation本身是跨位数工作的,64位的测试程序能访问32位的目标程序。但是,如果测试程序集目标平台设置成了x86,在某些系统配置下会导致对64位程序的操作失败甚至崩溃。
我的经验是:测试项目建议把目标平台设为AnyCPU或x64,不要为了方便在x86环境开发就锁死x86。如果项目里混用了某些老旧的32位原生DLL导致必须编译成x86,那就要用独立的进程方式去启动和访问目标程序,不要尝试用in-proc方式做。
5.3 慢加载控件与后台线程阻塞问题
桌面应用最常见的自动化失败场景之一是控件响应慢。原因是很多WinForms程序把耗时操作直接放在UI线程上,主界面卡住无法继续响应新消息,此时UI Automation查询被挂起。遇到这种情况不要盲目加长等待时间,要区分是“加载慢”还是“程序卡死”两种不同情况。
程序卡死怎么判断?可以用异步方式多开一个监控线程,定时检查目标进程的响应状态。如果进程已进入“未响应”状态,立刻放弃等待,截图打日志并把故障句柄记录下来,这样至少能保留现场。如果只是加载慢,那监控线程会看到进程正常响应,主线程继续等待元素出现即可——保证用例挂掉时能自动留存可靠的诊断证据。
5.4 控件类型、模式和属性的速查经验
日常编码时可以准备一个简单的速查表,快速对照常见操作和控件模式。这里放一份我常用的参考。
| 控件类型 | 常见模式 | 典型操作 |
|---|---|---|
| Button | InvokePattern | Invoke() |
| Edit / Document | ValuePattern | SetValue() |
| ComboBox | ExpandCollapsePattern, SelectionPattern | 展开、选中项 |
| DataGrid | TablePattern | 读取单元格值 |
| MenuItem | ExpandCollapsePattern, InvokePattern | 展开、点击 |
| ListItem | SelectionItemPattern | Select() |
这份表的本质逻辑是“控件类型决定了它支持哪些模式”,但WPF程序中控件模式可能会被自渲染覆盖,遇到查找失败先打印控件支持的模式再做判断。还有一个实用技巧:调试时可以用系统自带的Inspect工具观察目标程序的控件树,确认控件类型、Name、支持的Pattern。遇到元素定位问题先看Inspect结果再改代码,比反复试错高效非常多。
6. 从示例工程走向正式测试架构
6.1 与CI/CD流水线的集成方式
示例工程跑通之后,下一步就是无缝接入持续集成。桌面应用不像Web前端那样天然适合无头执行,需要一个真实可交互的Windows会话来跑UI自动化用例。最常见的做法是使用Windows自带的计划任务在CI机器上启动测试程序,或用服务账号登录后自动运行。
这里有一个很关键的运维细节:不要让CI Agent和自动化测试共用同一个桌面会话。Agent进程如果以服务方式运行在Session 0,UI Automation根本访问不到用户桌面的控件。正确做法是让测试工程作为一个独立进程,在用户会话中由计划任务触发启动,执行完再把结果推送给CI服务。
6.2 测试数据管理与用例稳定性
桌面应用测试的另一个挑战是环境状态管理。每次执行前必须保证被测程序处于干净的初始状态,包括清理本地配置文件、重置数据库连接、清空缓存目录。这些小动作看起来不起眼,但直接影响用例的可重复性。
还有一点,测试脚本里尽量不要依赖具体时间等待。不要用固定Sleep等待日志文件生成完毕,而是封装一个等待文件出现的轮询方法。这个思路和等待控件出现完全一致,核心是“以目标状态为基准,不机械化等待时长”。
6.3 与C#上位机、扫码枪这类硬件场景的结合
关于相关热词里C#上位机和扫码枪的内容,这跟UI自动化确实有真实的结合点。上位机软件大多需要进行UI自动化测试,因为它的主要交互模式就是界面监控和数据展示。手工测试要反复验证通讯正常、数据更新及时,而UI自动化脚本可以自动检查状态栏、数据网格控件的内容变化。
扫码枪场景的自动化稍微复杂一些,因为扫码输入不同于键盘输入,有一个快速的字符流输入过程。如果目标程序用文本框接收扫码结果,那么可以直接用SetValue设置完整文本,再触发TextChanged事件,模拟扫码完成后的数据到达状态。如果程序靠键盘钩子中间层解析扫码输入,则需发送模拟键盘消息完成全流程。写完上位机UI自动化脚本后,至少能让冒烟测试在每次发版前自动跑一遍,硬件通讯和界面展示两方面的问题都能提前暴露。
我个人的体会是,C# + UI Automation这套组合,真正的价值不是帮你“代替测试人员”,而是把团队从重复性极高的回归劳动里解放出来。越是老项目、越是逻辑复杂的桌面应用,越值得扎扎实实把核心流程的自动化用例沉淀下来。刚开始搭建时花的时间不短,但跑通之后每次发版前按一下按钮的安心感,确实值得前期投入的成本。
本文还有配套的精品资源,点击获取