ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

pywinauto菜单控件操作:定位、点击与状态校验实战

pywinauto菜单控件操作:定位、点击与状态校验实战 1. 从“点击坐标”到“看懂界面结构”我为什么开始死磕菜单控件先聊个背景。我做GUI自动化测试差不多五年早期项目里最常用的手段就是坐标点击——拿到控件坐标click(coords)完事。单看一个用例确实快但项目一迭代界面布局稍微变一下、DPI缩放调一档整条用例直接崩掉还特别难排查。后来切到pywinauto开始正儿八经按控件类型去定位、操作才真正感受到什么叫“自动化测试的稳定性”。在pywinauto里控件类型分得很细按钮是Button编辑框是Edit下拉框是ComboBox而菜单就是Menu和MenuItem。很多人写GUI自动化按钮、输入框用得很溜一碰到菜单就傻眼。为什么因为菜单这东西在Windows应用里太“特殊”了它不是一开始就挂在界面上的常驻控件而是点击菜单栏之后才动态弹出来的。窗口有没有展开菜单、菜单项有没有子菜单、某个菜单项当前是不是可用状态这些都会影响你的操作方式。今天这篇就专门写pywinauto里的菜单控件操作从最基础的如何拿到菜单对象开始到点开菜单、选中菜单项、处理子菜单、校验菜单状态最后聊几个我实际踩过的坑和外部扩展思路。如果你正在做Windows桌面应用的GUI自动化而且遇到了“明明菜单就在那儿脚本却老是定位不到”的鬼问题这篇应该能帮你省不少时间。先说清楚这篇针对的是原生Windows Win32应用不是WPF也不是Qt自绘界面。虽然pywinauto也支持UIA但菜单这部分Win32原生菜单用Menu类处理最顺手。文末我会单独说UIA模式下菜单控件的差异。2. 操作前的“地基”搞清楚Menu和MenuItem长什么样2.1 菜单栏、弹出菜单、子菜单在pywinauto里各自对应什么很多初学者拿到pywinauto的文档看到Menu和MenuItem两个类就开始混。这里用一个生活化的例子解释清楚菜单栏就是窗口标题栏下面那一行“文件”“编辑”“帮助”这些对应pywinauto里的Menu。弹出菜单你点击“文件”之后往下拉出来的那个列表在pywinauto里其实也是一个Menu对象只不过它的父级不是一个窗口而是代表整个菜单体系的Menu的子项。菜单项弹出菜单里“新建”“打开”“退出”这些具体的条目对应MenuItem。MenuItem还分两种带子菜单的项右边有黑色小三角箭头鼠标悬停会再弹一层比如“文件 - 最近打开的文件 - xxx.txt”。点击直接触发动作的项比如“退出”。pywinauto里这两者的区别就是带子菜单的MenuItem可以继续向下menu()、sub_menu()或者items()去拿下一层而普通项一般直接click()即可。2.2 拿到菜单对象的那条“最短路径”menu()方法一切操作的前提是先拿到菜单对象。常规做法是这样的from pywinauto import Application app Application(backendwin32).connect(process_id12345) main_window app.window(title记事本) # 拿到菜单栏 menu_bar main_window.menu()有些老教程会教你用main_window.MenuBar这种属性方式但我的建议是优先用menu()方法。原因后面讲兼容性时会细说。拿到menu_bar之后你可以打印一下它是谁print(menu_bar)输出大概长这样Menu - 系统菜单 (L0) | MenuItem - 文件(F) (L1) | MenuItem - 编辑(E) (L1) | MenuItem - 查看(V) (L1) | MenuItem - 帮助(H) (L1)你会看到菜单栏的下级直接就是MenuItem层级用L0、L1标识。有了这棵结构树定位菜单项就是顺着层级往下走。2.3 为什么用print去看菜单结构是排错的第一步很多项目里自动化脚本出问题不是操作代码写错了而是你以为菜单长这样实际程序里菜单长那样——比如“新建”藏在“文件”里没错但它前面多了个“新建窗口”你把索引当成了“新建”结果点错了。所以接到一个陌生被测程序我建议先干这件事def dump_menu_structure(menu, level0): indent * level for item in menu.items(): print(f{indent}{item.text()}) if item.has_submenu(): dump_menu_structure(item.sub_menu(), level 1)这样把整个菜单树打出来人工确认一遍再写定位逻辑比反复跑脚本猜要快得多。后面第4节我会给一个完整的“先dump再写操作”的模板那个在维护阶段特别好用。3. 核心操作拆解选中菜单项、触发菜单动作全流程复现菜单操作最核心的一件事就是点开菜单栏里的某个入口然后在弹出的菜单中逐级找到目标项再执行点击。这个流程可以写成一套非常清晰的操作序列。3.1 用menu_select()一步到位但要注意它的“副作用”pywinauto把“点开菜单栏入口 - 在弹出菜单中定位 - 点击目标项”封装成了一个方法叫menu_select()。你在窗口级别直接调用它就行main_window.menu_select(文件-打开)上面这行代码等价于手动点了“文件”再点“打开”。如果“打开”还有子菜单就继续用-连接main_window.menu_select(文件-最近打开的文件-test.txt)这种方法非常快而且可读性极佳。但是有个陷阱menu_select只是把整个路径从头到尾走完它不会检查每一步菜单项是否存在中间任何一层名字对不上直接抛异常。异常信息通常长这样pywinauto.findwindows.ElementNotFoundError: Menu item 打开2 is not found in menu 文件所以生产脚本里建议先用menu_select去执行遇到找不到菜单的情况再结合前面的dump菜单树脚本做排查。还有一点菜单路径分隔符是-不是也不是/。别问我怎么知道的问就是踩过坑。3.2 手动展开菜单的玩法什么时候不该用menu_selectmenu_select很省事但有个局限性它没法处理需要先进行某些前置操作才能点的菜单项。举个例子。记账软件里“删除当前账本”这个菜单项只有在当前选中了一个账本时才是可用的没有选中任何账本时这个菜单项是灰色的。如果直接用menu_select(文件-删除当前账本)pywinauto会尝试点击它但实际点击无效甚至可能在按钮不可用时直接抛异常。这时候就得手动展开菜单先看一眼“删除当前账本”当前是什么状态file_menu_item main_window.menu().item(文件) file_menu_item.click() # 拿到弹出的菜单 popup_menu file_menu_item.sub_menu() delete_item popup_menu.item(删除当前账本) if delete_item.is_enabled(): delete_item.click() else: print(当前未选中账本删除项不可用按业务逻辑跳过)menu_select适合“目标明确、路径已知、肯定能点”的用例手动展开菜单的方式适合“需要加业务判断、需要校验状态”的场景。两者结合能覆盖绝大多数菜单操作。3.3 子菜单层级的定位差异index、text、auto_id在实际项目里菜单项定位会遇到很多特殊情况。比如同一个菜单下有两个“新建”一个叫“新建”一个叫“新建窗口”再比如菜单项文字每台机器上因为语言环境不一样中文系统显示“文件”英文系统显示“File”。pywinauto里定位菜单项有三种方式按优先级我这样安排方式一按显示文本定位menu.item(文件)这是最直观的方式。只要界面文案稳定首选这种。缺点就是程序一旦改了菜单文案脚本就得跟着改。方式二按索引定位menu.item(0)这种方式适用于“菜单项顺序固定但文案可能变化”的情况。但索引从0开始这个细节特别容易错并且如果产品在“文件”前面加了一个“系统”菜单索引全乱脚本无声失败的风险很大。方式三按auto_id定位menu.item(auto_idFileMenu_New)这个只对部分UI框架有效Win32原生菜单很少有auto_id但UIA模式下很常见。如果你在用pywinauto的UIA后端做WPF或Qt的自动化测试auto_id会是你最好的朋友。综合来看能按文本定位优先按文本文本不稳定再用索引auto_id作为补充方案。我个人的经验是真实项目里90%的情况按文本就够了剩下10%是用“文本父级路径组合”来避免歧义。3.4 菜单项点击的三种写法和它们的等价逻辑在pywinauto里你可以用下面这几种方式触发同一个菜单动作# 写法一从菜单栏一路点下去 main_window.menu().item(文件).click() main_window.menu().item(文件).sub_menu().item(退出).click() # 写法二封装好的menu_select一键直达 main_window.menu_select(文件-退出) # 写法三用按键模拟 main_window.type_keys(%{F4})三种写法执行结果是等价的但适用场景完全不同写法一适合展示“过程”比如你要在点击“文件”的瞬间截图或者验证点击“文件”后菜单项是否都弹出来了。写法二适合“只关心结果”的用例比如我要打开一个文件中间经过什么菜单不管。写法三适合键盘快捷键存在的情况这通常是最稳定的——它不依赖菜单坐标也不依赖菜单项的文字只要快捷键没改脚本就不会断。我的日常习惯是回归测试主路径用写法二加状态校验的场景用写法一界面文案经常动的系统我会盯着快捷键方案看能不能替代。4. 别让脚本“瞎点”菜单状态校验从层级到可用性实际操作中光是“能点到”还不够。菜单项有时是灰色的、有时被隐藏了、有时点了没反应。这些都需要在测试里校验。我总结了几种常见的菜单状态校验场景和对应写法。4.1 checked、enabled、visible一个都不能少pywinauto的MenuItem提供了三个常用的状态属性is_checked()菜单项前面有没有勾选标记常用于“视图 - 状态栏”这类切换型菜单。is_enabled()菜单项当前是否可点击。is_visible()菜单项当前是否可见。不过Windows原生菜单里隐藏项比较少见更多出现在自绘菜单里。我用一个切换型菜单的测试用例来演示view_menu main_window.menu().item(视图) view_menu.click() popup view_menu.sub_menu() status_bar_item popup.item(状态栏) # 记录操作前状态 before_status status_bar_item.is_checked() print(f操作前状态栏显示: {before_status}) # 点击切换 status_bar_item.click() # 重新展开菜单再次校验 view_menu.click() popup view_menu.sub_menu() after_status popup.item(状态栏).is_checked() print(f操作后状态栏显示: {after_status}) assert before_status ! after_status, 状态栏切换无效注意一个细节点击状态栏切换之后菜单会收起来之前拿到的popup对象可能已经失效所以校验前必须重新展开菜单、重新获取popup。这是新手最容易忽略的。4.2 校验菜单项是否存在先get再来个try/exceptis_enabled()和is_visible()都要求菜单项“先存在”如果菜单项本身不存在你连item()都拿不到。所以项目里我一般维护一个菜单项是否存在的辅助函数def menu_item_exists(menu, item_text): try: menu.item(item_text) return True except (ElementNotFoundError, IndexError): return False这个函数配合case使用能帮你在“这个菜单项在当前条件下不该出现”的场景下写断言file_menu main_window.menu().item(文件).sub_menu() assert not menu_item_exists(file_menu, 导出PDF), 没有安装付费模块时不该出现导出PDF菜单项工业级测试用例的写法气质一下子就不一样了。4.3 配合截图状态校验失败时自动保存菜单树和菜单项截图不知道你们有没有这种经历测试失败之后一看报告只有一行AssertionError完全不记得当时界面长什么样。我一个很管用的补救手段就是在校验失败时除了打印信息顺手把整个菜单结构和窗口截图保存下来def dump_menu_on_failure(window, save_dirfailure_artifacts): import os, datetime os.makedirs(save_dir, exist_okTrue) timestamp datetime.datetime.now().strftime(%Y%m%d_%H%M%S) tree_path os.path.join(save_dir, fmenu_tree_{timestamp}.txt) img_path os.path.join(save_dir, fmenu_screen_{timestamp}.png) with open(tree_path, w, encodingutf-8) as f: dump_menu_structure(window.menu(), f) window.capture_as_image().save(img_path) print(f失败信息已保存: {tree_path}) print(f失败截图已保存: {img_path})这样回头排查问题时至少能知道当时菜单栏是怎么排列的、界面什么状态定位问题的效率高很多。5. 真实踩坑实录菜单找不到、状态失效、弹出菜单错位再完美的理论都比不上一个真实的坑。这里分享三个我实际遇到的菜单操作问题都是特别典型的场景大概率你们也会碰到。5.1 坑一菜单栏被折叠直接menu_select报“找不到”有一次我测一个工具类软件在1920x1080分辨率窗口下菜单栏正常显示“文件、编辑、查看、工具、帮助”。但当我把窗口宽度缩到1000px时菜单栏变得很挤这种软件通常会触发“折叠菜单”机制——多出来的菜单项收进最右侧的溢出按钮里。然后脚本就跑挂了。排查过程用dump_menu_structure()打印菜单树发现“工具”菜单不见了。但是用鼠标手动点界面发现“工具”其实还在只是藏在“扩展”按钮下面。这里没法用menu_select(工具-xxx)直接解决因为菜单的物理结构变了。我当时直接用menu_bar.item(扩展).click()然后sub_menu().item(工具).click()再往下找目标菜单项这才把问题绕过去。这种情况在真实场景里不算罕见。如果你在跑自动化时遇到“菜单明明能看到但脚本找不到”优先怀疑是不是窗口尺寸变化导致菜单布局变了。5.2 坑二弹出菜单是“临时对象”旧引用秒变废铁我在一个绘图软件里遇到过一次特别诡异的问题file_menu main_window.menu().item(文件) file_menu.click() dropdown file_menu.sub_menu() # 第一次拿到弹出菜单 print(dropdown.items()) # 正常输出 # 假设中间我打开了一个对话框又关掉 app.dialog.close() # 接着再用原来的dropdown对象去点菜单项 dropdown.item(页面设置).click() # 这里报错报错信息是“element not found”之类的。原因很简单Win32的弹出菜单在失去焦点、菜单关闭、或者打开新对话框时会被销毁重建。你之前拿到的dropdown对象实际指向一个已经销毁的菜单设备。解决办法也简单不要再复用旧对象每次操作前重新获取def safe_click_menu_item(window, path): parts path.split(-) current_menu window.menu() for part in parts: item current_menu.item(part) item.click() if item.has_submenu(): current_menu item.sub_menu() else: break这样每次展开菜单之后重新获取子菜单对象就完全规避了“旧引用失效”的问题。5.3 坑三Windows激活窗口的焦点问题点击菜单项没反应还有一类问题是脚本用menu_select()点绝对路径没问题但手动先展开菜单再用type_keys({DOWN})去控制菜单项选择时很容易出现“按键没有作用”的现象。原因往往是应用窗口没有获得键盘焦点。Windows原生菜单在弹出后焦点会自动移到菜单上但如果你在点击菜单之前不小心切换过窗口或者别的窗口抢了焦点那么type_keys就往其他窗口发了。我习惯在点击菜单之前先激活目标窗口main_window.set_focus() main_window.menu().item(文件).click()实测下来“先set_focus再操作”能大幅降低这类偶发性失败。另外如果你有多个显示器窗口不在主屏上也可能导致某些点击和按键事件异常能固定在主屏跑测试就固定主屏。6. 扩展思路菜单控件和快捷键、全局热键的组合测试菜单操作看似只是点来点去但实际和Windows消息循环有着紧密关系。这里从原理层面给想深入的朋友再聊几个扩展场景。6.1 通过Alt键序列触发菜单可以绕开很多渲染问题Windows原生菜单有个内置逻辑按Alt可以激活菜单栏然后连续按菜单项的热键字母可以快速定位。pywinauto里可以用type_keys模拟这一过程# 按下AltF再按O相当于打开文件菜单并选择“打开” main_window.type_keys(%FO)很多老派程序比如控制台工具、老版本的IDE菜单项文本里会有一个带下划线的字母那就是快捷键字母。这种方式不依赖控件坐标、不依赖菜单项全文本匹配只要快捷键没改脚本稳定性极高。不过它有个前提菜单项本身必须支持键盘导航。有些自绘菜单并不响应Alt字母这种原生逻辑那就不能用这个方案。6.2 自定义菜单类把“菜单状态校验”包装成业务断言项目做到后面测试代码里大量直接调用menu().item()会很冗余。我习惯在项目里做一层轻量封装class MainMenuHelper: def __init__(self, window): self.window window def click(self, path): self.window.menu_select(path) def is_checked(self, path): parts path.split(-) current_menu self.window.menu() for part in parts: item current_menu.item(part) if item.has_submenu(): current_menu item.sub_menu() else: return item.is_checked() return None def is_enabled(self, path): parts path.split(-) current_menu self.window.menu() for part in parts: item current_menu.item(part) if item.has_submenu(): current_menu item.sub_menu() else: return item.is_enabled() return None然后测试用例变成helper MainMenuHelper(main_window) assert helper.is_checked(视图-状态栏) True assert helper.is_enabled(文件-打开) True helper.click(文件-保存)这样业务语义清晰团队成员接手也容易。无论你项目用pytest、unittest还是nose都建议把菜单操作用类似的Helper类包起来别让原始API散落在用例里。6.3 结合截图智能断言菜单切换后UI状态是否符合预期有些菜单操作不只改变菜单项本身的勾选状态还会改变整个应用界面的布局。比如“视图-全屏”点了之后窗口变成全屏状态栏消失。针对这种场景我一般不只是校验菜单项的checked还会做“全窗口像素对比”或者“关键控件是否可见”的断言。pywinauto的window.capture_as_image()会按当前窗口大小截图你可以把截图和黄金样本做对比容差设置在像素级别的差异范围内。这套思路在扁平化设计的应用里很有效因为菜单操作通常会带来明显的视觉变化断言成功率很高。7. 后端差异避坑win32与uia模式下的菜单处理策略最后必须提一嘴后端差异。pywinauto支持两种backendwin32和uia。二者的菜单处理逻辑差别很大我花了很长时间才从坑里爬出来。7.1 win32后端原生菜单用起来最顺手全覆盖总结一下如果你的被测程序是C、Delphi、VB6写的原生Win32程序直接用backendwin32。这种模式底层调用的就是Windows原生菜单API速度极快menu_select()、sub_menu()、has_submenu()这些方法都非常稳定。7.2 uia后端自绘菜单绕不开的路径但如果是WPF、Qt或者某些自绘UI框架原生菜单API拿不到菜单项这时候就必须切换到backenduiaapp Application(backenduia).connect(pathyour_app.exe) main_window app.window()在UIA模式下菜单控件可能不叫Menu而是一堆MenuItem直接挂在某个容器下面结构比Win32菜单更分散更容易出现“点击某个菜单项后整个控件树刷新”的情况。我拿Qt做的应用举例打印UIA树经常能看到Pane - MenuBar - menuBar MenuItem - 文件 MenuItem - 打开 MenuItem - 退出这种情况下仍然可以用menu_select(文件-打开)但有一种情况例外Qt的某些子菜单是延迟加载的也就是说菜单项里面的子项要等到鼠标悬停或点击时才创建。menu_select在快速递归时可能拿不到尚未创建的子菜单内容导致偶发失败。我遇到过几次之后养成了一个习惯在UIA模式下如果遇到“第一次点击失败第二次才成功”基本就是延迟加载的问题。7.3 统一封装不管win32还是uia外部调用保持一致为了不让后端差异蔓延到用例代码里我在项目里会做一个适配层。用一个AppDriver来封装对外暴露的方法始终如一open_menu(path)click_menu_item(path)get_menu_item_state(path)内部再根据backend类型选择对应的实现。这样不管被测程序是Win32老应用还是Qt奇奇怪怪的版本用例层永远只调用统一的业务方法迁移成本低很多。8. 写在最后一段能直接抄走的菜单操作模板这篇已经很长了最后分享一个可以直接放到项目里的模板。它不依赖任何外部框架仅使用pywinauto本身我目前的主要项目就在用这个结构。import os import datetime from pywinauto import Application, ElementNotFoundError def dump_menu_structure(menu, outputNone, level0): indent * level for item in menu.items(): text item.text() print(f{indent}{text}) if output is not None: output.write(f{indent}{text}\n) if item.has_submenu(): dump_menu_structure(item.sub_menu(), output, level 1) class MenuHelper: def __init__(self, window, backendwin32): self.window window self.backend backend def click(self, path): self.window.menu_select(path) def is_checked(self, path): return self._get_last_item(path).is_checked() def is_enabled(self, path): return self._get_last_item(path).is_enabled() def _get_last_item(self, path): parts path.split(-) current_menu self.window.menu() for part in parts: item current_menu.item(part) if item.has_submenu(): current_menu item.sub_menu() else: return item return None def main(): app Application(backendwin32).connect(pathnotepad.exe) win app.window(title无标题 - 记事本) helper MenuHelper(win) helper.click(文件-打开) # 断言打开对话框出现 dlg app.window(title打开) assert dlg.exists(timeout5), 打开对话框未出现 dlg.close() # 校验某个菜单项状态并恢复 if helper.is_checked(查看-状态栏): helper.click(查看-状态栏) print(状态栏已关闭) else: helper.click(查看-状态栏) print(状态栏已开启) if __name__ __main__: main()这套结构的优点在于不管是你自己维护的脚本还是交给团队其他人接手一眼就能看懂核心业务逻辑。排错的时候看_get_last_item的路径解析就够了。最后再分享一个经验密度很高的小技巧给菜单项加日志每次都打印当前路径和操作状态。一开始你可能会觉得日志很啰嗦但一旦某个菜单操作导致后续控件找不到这些日志能直接告诉你操作到哪一步出错了省下来的排查时间远超过写日志的时间。GUI自动化这条路上菜单控件只是其中一个阶段。但如果你真的把菜单控件的“结构理解、状态校验、后端适配”这三件事做透了往后遇到树控件、列表控件、自定义控件你会有一种“基本面通了”的踏实感。希望这篇能给你省点弯路。
返回列表