简介:面向Windows桌面应用自动化测试场景,基于Python语言与微软WinAppDriver驱动构建了一套可直接落地的UI测试框架。WinAppDriver兼容Selenium WebDriver协议,可驱动UWP与传统Win32桌面应用,框架在此基础上封装了测试基类、运行包装器和VNC查看器等模块,能够识别按钮、文本框、菜单、列表框等常见控件,适合有Python基础的测试工程师或企业级团队快速搭建回归与验收测试体系。
压缩包共10个文件,以6个Python源码文件为主,辅以Markdown说明、Word文档和文本说明;除测试用例示例外,还包含README、使用说明与附赠资料,覆盖环境配置、模块功能解释和脚本写法指引。整套框架从基础封装到具体testcases层均给出示例,结构清晰,便于按业务扩展控件操作或测试流程。
资源整体仅38KB,轻量易用,已有92人学习下载;相比从零构建,直接借鉴其分层思路和样例代码可显著降低自动化测试的入门与落地成本。
1. 我为什么把一个测试框架从 Selenium 迁移到了 WinAppDriver
做过 Windows 桌面应用回归的人大概都有这种经历:脚本写了几百条,真正维护成本全耗在“今天窗口没起来”“这个按钮被遮挡了”“控件还没加载完脚本就点上去”这三件事上。WinAppDriver 的价值在于它给了 Windows 桌面的 UI 自动化一条和 Web 端几乎一样的路——监听一个本地端口,暴露 WebDriver 风格的 JSON 协议,让 Selenium 生态里的等待、定位、断言和 CI 流程直接平移过来。这套基于 Python + WinAppDriver 的框架,把会话管理、元素封装、用例组织都做成了可复制到企业项目的形态,适合正在评估桌面端自动化方案,或者已经写过一批脚本但被稳定性折磨过的测试开发工程师。
2. WinAppDriver 协议桥接与元素识别:从 WebDriver 命令到 UI Automation
2.1 一次 click 背后串起了哪些组件
WinAppDriver 本身不是一个测试框架,它是微软提供的一个驱动进程。它在你的机器上启动一个 HTTP 服务,监听 4723 端口(和 Appium 默认端口一致),接收来自 Selenium Python 客户端的命令,再把命令翻译成对 Windows UI Automation API 的调用。
整个链路是这样的:测试脚本调用element.click(),Python 的 Selenium 绑定把这条命令封装成 JSON Wire Protocol 请求,POST 到http://127.0.0.1:4723/wd/hub,WinAppDriver 收到后查找元素对应的 UI Automation 节点,调用底层接口完成真实鼠标点击,最后把执行结果返回给脚本。这套机制和 ChromeDriver 驱动 Chrome 是同构的,这也是为什么企业内部熟悉 Selenium 的人可以零基础接手这个项目——不用重新学一套脚本语言和对象模型。
启动 WinAppDriver 是第一步,我一般用如下命令:
"C:\Program Files (x86)\Windows Application Driver\WinAppDriver.exe" 127.0.0.1 4723 /log C:\logs\winappdriver.log /verbose参数说明:第一段是 WinAppDriver 的默认安装路径,如果安装在非默认位置需要替换;127.0.0.1是监听地址,只本机访问时不要改成0.0.0.0,避免暴露到局域网;4723是端口,和脚本里command_executor的地址必须一致;/log指定日志输出路径,/verbose打开命令级详细日志。这个进程必须以管理员权限启动,否则无法读取系统级控件信息,问题表现是 session 创建成功后找不到任何元素。
2.2 capabilities 参数与 session 建立的完整代码
会话创建是整个框架的地基,capabilities 配错了,后面所有用例行为都不可信。下面这段是从框架的base_testcase.py里抽出来的核心逻辑:
from selenium import webdriver def create_driver(app_path, attach_hwnd=None): caps = { "app": app_path, "platformName": "Windows", "deviceName": "WindowsPC", "ms:waitForAppLaunch": "5", } if attach_hwnd: caps["appTopLevelWindow"] = str(attach_hwnd) driver = webdriver.Remote( command_executor="http://127.0.0.1:4723/wd/hub", desired_capabilities=caps, ) driver.implicitly_wait(3) return driver逻辑说明:app是被测应用的绝对路径,WinAppDriver 会在 session 启动时拉起这个进程;platformName固定是Windows,deviceName在 Windows 上是个占位参数,写WindowsPC或任意非空字符串都可以;ms:waitForAppLaunch是 WinAppDriver 1.2 之后支持的参数,控制应用启动后的等待秒数,对解决冷启动时控件树未就绪很有效。appTopLevelWindow是另一个思路:传入已打开窗口的 HWND 句柄,WinAppDriver 会直接 attach 到这个窗口而不是重新拉起进程,这在被测应用由安装器或外部进程启动的场景下非常有用。
2.3 定位器与 UI Automation 属性的映射关系
WinAppDriver 没有自己发明一套全新的定位体系,它复用了 Selenium 的By类,但底层映射的是 Windows UI Automation 属性。这个映射关系直接决定了你的定位策略选择:
| Selenium 定位器 | UI Automation 属性 | 获取方式 | 适用场景 | 推荐度 |
|---|---|---|---|---|
By.ACCESSIBILITY_ID | AutomationId | 开发者显式指定或框架推断 | 按钮、输入框、树节点等稳定控件 | 最高,语义稳定不随文案变化 |
By.NAME | Name | 控件文本或 Label 关联 | 菜单项、静态文本、无 AutomationId 的控件 | 高,但界面文案改动会直接挂 |
By.CLASS_NAME | ClassName | Win32 类名或 XAML 控件类型 | 批量处理同一类型的控件列表 | 中,容易误匹配 |
By.XPATH | 属性组合表达式 | 基于 AutomationId、Name、ControlType 组合 | 复杂层级关系定位 | 低,控件树大时性能下降明显 |
经验是:能拿到 AutomationId 就不碰 Name,能用 ID 就不写 XPath。理由很简单,Name 依赖界面显示文本,产品改一个字你的回归就崩,而 AutomationId 是开发在控件上显式打标的,变更频率低得多。XPath 在 WinAppDriver 里虽然能用,但它是靠遍历整棵控件树来求解的,页面控件一多,单次定位经常花掉几百毫秒,一个用例里几十个定位操作,累积延迟非常可观。
3. 框架分层设计:BaseTestCase 会话基座与 Wrapper 操作封装
3.1 BaseTestCase:把会话生命周期钉死在基座里
企业级框架和 demo 脚本最大的区别,是会话的创建和销毁有没有收口。这个项目的base_testcase.py把所有用例共用的 session 初始化、启动参数、关闭逻辑集中在一个基类里,继承它的测试类不再关心 WinAppDriver 连不连得上、应用有没有拉起这类琐事。
import unittest from selenium import webdriver class BaseTestCase(unittest.TestCase): driver = None @classmethod def setUpClass(cls): caps = { "app": r"D:\Program Files\RealVNC\VNC Viewer\vncviewer.exe", "platformName": "Windows", "deviceName": "WindowsPC", "ms:waitForAppLaunch": "5", } cls.driver = webdriver.Remote( command_executor="http://127.0.0.1:4723/wd/hub", desired_capabilities=caps, ) cls.driver.implicitly_wait(3) @classmethod def tearDownClass(cls): if cls.driver: cls.driver.quit()逻辑说明:这里用的是unittest.TestCase风格,setUpClass和tearDownClass是类级别钩子,整个测试类共享一个 driver 实例,避免每条用例都重启应用。implicitly_wait(3)设置的是全局兜底超时,意思是元素查找最多等 3 秒,这个值不是越大越好,设大了会让失败用例的暴露时间拖得很长。注意desired_capabilities的传参方式在 Selenium 4.x 里已经标记为 deprecated,但 WinAppDriver 官方文档至今仍以这种方式为例,因为这个驱动本身对 Options 模型的支持不完整,保持 dict 写法是为了兼容性而不是守旧。如果团队用的是 Selenium 4.6+,也可以把这份 dict 塞进Options,只是要自己测一遍升级路径。
3.2 test_wrapper:让所有元素操作都经过统一出口
utils/test_wrapper.py是这个框架里另一个关键文件,它把click、input_text、is_visible这些高频动作封装成带等待、带日志的方法。直接操作 Selenium 原生的driver.find_element().click()不是不行,但每一条用例里都写find_element会让调试成本和维护成本线性上升,而且异常处理逻辑散落在各处。
import logging from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class TestWrapper: def __init__(self, driver, wait_timeout=10): self.driver = driver self.wait_timeout = wait_timeout def _wait_element(self, by, value, timeout=None): timeout = timeout or self.wait_timeout return WebDriverWait(self.driver, timeout, poll_frequency=0.5).until( EC.presence_of_element_located((by, value)) ) def click(self, by, value, timeout=None): el = self._wait_element(by, value, timeout) logging.info("click [%s] -> %s", by, value) el.click() def input_text(self, by, value, text, timeout=None): el = self._wait_element(by, value, timeout) el.clear() el.send_keys(text)逻辑说明:_wait_element是内部方法,统一走了WebDriverWait的显式等待,poll_frequency=0.5控制轮询间隔,默认 0.5 秒查一次控件,比 Selenium 默认的轮询更能及时发现控件出现;click方法先等元素出现、记录日志、再执行点击,把“定位、等待、操作、留痕”四件事合并到一行调用里。input_text里先clear()再send_keys(),避免输入框里残留上一次的数据。这个封装的实际价值在于:将来如果要在所有操作前加一个统一的截图钩子,或者全局捕获控件不可用异常,只需要改这一个文件,不需要动几十个用例。
3.3 目录结构与文档怎么配合使用
解压后看到的运行库结构是下面这样,值得照着梳理清楚再动手:
WinAppUITest-main/ ├── base_testcase.py ├── testcases/ │ └── vnc_viewer.py ├── utils/ │ ├── test_wrapper.py │ └── __init__.py ├── README.md ├── 说明文件.txt ├── 附赠资源.docx └── .gitattributes逻辑说明:testcases/放被测应用的用例文件,项目自带一个vnc_viewer.py,以 VNC Viewer 这个真实 Windows 客户端为对象写了一套可运行的样例;utils/放工具类,test_wrapper.py是元素操作封装;base_testcase.py在根目录而不是 utils 里,因为它是所有用例的父类,放根目录更容易被 import。.gitattributes保证了仓库文件在 Windows 和 Linux 之间 Checkout/Checkin 时换行符一致,避免 CRLF 干扰 diff。
配套文档的角色也不同:README.md描述安装依赖和框架设计;说明文件.txt更像快速上手指引,告诉你先跑哪条命令、再看哪个文件;附赠资源.docx一般是依赖清单和各模块 API 说明。实际接手时推荐的阅读顺序是先看说明文件.txt,再对照 README 跑一遍样例,最后按需查 docx 里的 API 细节。
4. 控件定位策略与等待参数调试:把 VNC Viewer 用例跑稳
4.1 三种定位方式在真实控件上的差异
拿样例里的 VNC Viewer 来说,连接对话框里的服务器地址输入框、连接按钮、认证弹窗,都能用不同的定位方式命中。下面这段代码示范了三种写法:
from selenium.webdriver.common.by import By # 方式一:AutomationId addr_input = wrapper.click(By.ACCESSIBILITY_ID, "AddressInput") connect_btn = wrapper.click(By.ACCESSIBILITY_ID, "ConnectButton") # 方式二:Name dialog_title = wrapper._wait_element(By.NAME, "VNC Viewer") # 方式三:ClassName 配合层级约束 auth_ok_btn = wrapper._wait_element(By.CLASS_NAME, "Button", timeout=8)逻辑说明:方式一优先使用,AddressInput和ConnectButton是控件上的 AutomationId,只要开发不改控件命名就永远稳定;方式二用来确认窗口是否弹出,靠的是窗口标题文本;方式三要小心,ClassName匹配的是Button这种类型名,页面上可能有多个按钮同时满足条件,find_element默认返回第一个匹配项,一旦按钮顺序变化就定位错对象。给它的建议是只在确信页面只有一个同类控件时使用,或者配合 XPath 加上父容器约束。
表格里已经列过推荐度,这里再给一条现场经验:如果发现某个控件在 Inspect 工具里看到的 AutomationId 是空字符串,不要硬找 ID,直接用它的 Name,或者让开发在代码里补上 AutomationId,不要自己写复杂的 XPath 去绕——那是在透支后续维护成本。
4.2 WebDriverWait 参数调优与真正需要注意的坑
样例用例里大量出现显式等待,这是 UI 自动化稳定性最核心的一层。WebDriverWait的默认参数能用,但没法应对桌面应用常见的“控件出现了但还没就绪”的场景:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import NoSuchElementException wait = WebDriverWait(driver, 15, poll_frequency=0.5, ignored_exceptions=(NoSuchElementException,)) element = wait.until( EC.element_to_be_clickable((By.ACCESSIBILITY_ID, "ConnectButton")) )逻辑说明:WebDriverWait第一个参数是驱动实例,第二个15是最长超时秒数,poll_frequency=0.5是轮询间隔,默认是 0.5 秒,也可以调成 0.3 让控件出现后响应更快,但会略微增加 CPU 占用;ignored_exceptions里的NoSuchElementException意味着元素没找到时继续等,而不是立刻抛异常。EC.element_to_be_clickable比presence_of_element_located更严格,它要求元素不仅存在而且处于可点击状态。桌面应用和 Web 一个显著差异是:窗口可能已经出现,但控件还在初始化过程中,presence能通过而element_to_be_clickable会一直等到真正可交互,这也是稳定性提升的关键。
等待相关的参数可以按下面的基准来配:
| 参数 | 推荐值 | 说明 |
|---|---|---|
timeout(等待超时) | 10~20 秒 | 取决于应用启动和响应速度,冷启动场景给 20 |
poll_frequency(轮询间隔) | 0.3~0.5 秒 | 太密会增加 IPC 开销,太疏会拖慢用例 |
implicitly_wait(全局兜底) | 2~3 秒 | 只做兜底,核心交互全部用显式等待 |
ignored_exceptions | NoSuchElementException | 也可以加ElementNotVisibleException |
这里有一个常见误用值得单独说:不要在一个用例里混用大量time.sleep(2)然后又把implicitly_wait设成 10 秒,这样的结果是每条用例都带固定延迟,跑完一遍要几十分钟,而真正等不到的元素照样等不到。正确思路是固定延迟只加在“已知控件树重建耗时较长”的节点上,比如应用切换主界面之后,其他全部交给显式等待。
4.3 元素找不到时先看现场再改代码
框架里最应该复用却不是每个人都会用的是现场保留机制。WinAppDriver 支持把当前控件树 dump 出来,也支持截图,这两样是排查定位问题的第一手证据。
# 定位失败时保存页面 XML 结构和截图 with open("page_source.xml", "w", encoding="utf-8") as f: f.write(driver.page_source) driver.get_screenshot_as_file("failure.png")逻辑说明:driver.page_source返回当前窗口的 UI Automation 树,格式是 XML,里面能看到每个控件的 AutomationId、Name、ClassName、IsEnabled 等属性;截图则记录了真实画面。排查步骤是:先打开 page_source.xml,搜索用例里定位不到了那个控件的 Name 或 AutomationId,看控件到底在不在。如果不在,说明窗口没切对或应用没加载到那个页面;如果在但找不到,说明定位器写错了,对照 XML 里的实际属性修正即可。这一步能区分出 80% 的问题是环境问题还是定位问题,避免反复改代码盲试。
5. 企业级落地技巧:失败重试、现场保留与 Windows Runner 集成
5.1 给非稳定操作加上重试装饰器
桌面应用的 UI 交互比 Web 多了一层窗口系统的不确定性,偶发性的点击没生效很难完全避免。给高频但偶发失败的操作套一层重试装饰器,是投入产出比最高的稳定性手段。
import functools import time def retry(times=3, interval=1.0): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for i in range(times): try: return func(*args, **kwargs) except AssertionError: if i == times - 1: raise time.sleep(interval * (i + 1)) return wrapper return decorator逻辑说明:times是重试次数,interval是基础重试间隔,这里用了递增退避——第一次失败等 1 秒,第二次等 2 秒,给应用留出恢复时间。装饰器只捕获AssertionError,因为 UI 断言失败往往代表控件态不对,值得重试;而元素找不到这类异常本身已经在等待机制里处理过了,不需要再套一层。使用时直接@retry(times=3, interval=1.0)标记到用例方法上即可。
5.2 失败现场的两种证据要同时保留
截图和 page_source 各有所长,截图反映视觉状态,page_source 反映控件状态,两类证据缺一不可。建议在tearDown里判断测试结果,失败时自动执行上一章那两行保存逻辑,输出路径带上时间戳和用例名,方便 Jenkins 或者 GitLab CI 归档。
5.3 Windows Runner 上的落地边界
CI 里跑 Windows 桌面 UI 自动化有一个绕不开的约束:WinAppDriver 需要访问交互桌面,所以 Runner 必须以交互式会话运行。常见的做法是在Start-Process里拉起 WinAppDriver 和被测应用,保证它们运行在同一个和桌面相连的会话中,而不是挂在某个 Windows 服务进程中。一台 Runner 也不要并行跑多个桌面 UI 任务,两个 session 抢同一个桌面会话会互相干扰,用独占标签或者队列串行是最省心的策略。日志路径我习惯固定到一个独立目录,比如C:\artifacts\wd.log,这样每次构建失败后能快速拉回 WinAppDriver 自己的命令级日志,配合框架里的 page_source 和截图,定位效率会高很多。
本文还有配套的精品资源,点击获取