ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于PyAutoGUI与OpenCV的QQ音乐桌面客户端GUI自动化测试实战

基于PyAutoGUI与OpenCV的QQ音乐桌面客户端GUI自动化测试实战 1. 为什么要做QQ音乐GUI自动化测试做GUI自动化测试的人最清楚一个尴尬接口测试已经自动化了回归测试也自动化了唯独桌面客户端的“点开界面、点击按钮、验证结果”这套流程还停留在手工点点点。尤其是QQ音乐这种重度桌面应用动辄几十个核心功能点每次版本更新都要反复验证播放、搜索、切歌、歌词、VIP入口这些链路光靠人肉回归既慢又容易漏。我这次的项目目标很明确把QQ音乐的Windows桌面客户端主要使用路径自动化起来覆盖启动登录、搜索播放、音量调节、切歌模式切换这几个高频场景做到“脚本一跑、报告一出一分钟知道有没有回归问题”。这套东西适合谁看如果你正在做桌面客户端自动化测试或者想了解PyAutoGUI、Airtest这类图像识别方案在真实项目里怎么落地这篇文章应该能帮你省不少试错时间。选QQ音乐当靶子并不是因为它简单恰恰是因为它够复杂。QQ音乐客户端有几个让自动化测试头疼的典型特征窗口元素部分是自绘的、播放页有动态滚动效果、有各种弹窗会员引导、广告、更新提示、还有登录态和网络状态的不确定性。把这些问题一个一个趟平你再去测其他桌面客户端基本就是降维打击。2. 技术选型对比了一圈最后还是PyAutoGUI最稳2.1 主流GUI自动化方案横向对比桌面GUI自动化的路子其实就那么几条我在动手前把主流的都盘了一遍列个表你看一眼就明白为什么最后选PyAutoGUI。方案定位方式优点缺点适合场景WinAppDriver AppiumUI Automation树控件识别稳定、跨平台对自绘控件无能为力、环境配置繁琐Windows原生控件应用FlaUIUI Automation树基于C#、性能好需要.NET环境、同样怕自绘控件Windows原生控件应用Airtest图像识别 UI树录屏可视、报告友好对复杂界面识别较慢、有时误识别Android/iOS优先、桌面端次之PyAutoGUI图像识别 坐标轻量、跨平台、上手快纯图像识别受分辨率/缩放影响自绘控件多、无UI树可用的场景SikuliX图像识别脚本可读性好依赖Java、运行慢、维护停滞教学验证类项目QQ音乐的主界面大量使用自绘控件尤其是播放页、歌词页、榜单卡片这些区域用WinAppDriver抓到的控件树经常是残缺的根本无法稳定定位。Airtest虽然也能识别但跑起来相对笨重图像匹配的置信度调来调去容易出问题。PyAutoGUI的优势在于轻量直接配合OpenCV的模板匹配做二次校验能解决大部分自绘控件的问题。它的缺点是纯坐标和图像匹配受屏幕环境影响大但这个坑是可以通过规范化环境来规避的具体怎么做我后面细说。注意如果你的被测客户端是标准原生控件优先用WinAppDriver或FlaUI控件级定位的稳定性远超图像识别。只有在控件树不可用或大量自绘场景下才建议走图像识别路线。2.2 最终方案PyAutoGUI pytest OpenCV我的最终技术栈定为Python 3.10 pytest测试框架负责用例组织、断言、报告PyAutoGUI核心交互库负责鼠标点击、键盘输入、截图OpenCVcv2图像匹配的兜底方案用来做更精确的图标定位和存在性判断allure-pytest测试报告图文并茂地展示每步操作和失败截图loguru操作日志方便排查脚本执行到哪一步出了问题这套组合的好处是每个组件都尽量轻不引入重量级依赖遇到问题可以直接看源码解决。而且pytest的fixture机制非常适合做用例间的前置条件复用比如“已登录状态”作为一个fixture登录一次然后所有用例都复用能省下大量启动登录的时间。3. 环境准备与前期配置这一步能省你一半的坑3.1 安装依赖和基础代码骨架环境这块我直接用虚拟环境避免污染系统Python。依赖安装命令如下python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install pyautogui opencv-python pytest allure-pytest loguru装完之后先写一个最基础的“打开QQ音乐”脚本验证环境通不通import pyautogui import subprocess import time # 启动QQ音乐路径以你自己的安装位置为准 subprocess.Popen(rC:\Program Files\Tencent\QQMusic\QQMusic.exe) time.sleep(5) # 截个图看当前界面状态 pyautogui.screenshot(startup.png)这里有个细节启动后至少要等5秒以上再截图QQ音乐启动有闪屏和加载动画等太短会拍到不完整的界面。如果你机器性能一般建议把等待时间调到8秒。3.2 屏幕分辨率、缩放比例和主题统一图像识别方案最大的敌人是环境不一致。同一套脚本在1920x1080、100%缩放的机器上跑得好好的换到2880x1800、150%缩放的笔记本上所有图片匹配全部失败。原因很简单PyAutoGUI截到的图是物理像素而模板截图是对应分辨率下的图像两者不匹配就找不到目标。我的处理方案是所有执行测试的机器统一设置为1920x1080分辨率、100%缩放、Windows经典主题。设置屏幕分辨率用一条Python命令就能搞定import ctypes # 设置分辨率为1920x1080 user32 ctypes.windll.user32 user32.SetDisplayMode(1920, 1080, 32)注意这条命令在部分Windows版本上需要管理员权限而且设置后屏幕会闪一下建议放到所有用例执行前的fixture里统一处理不是在每条用例里调用。缩放比例统一到100%后还有一个隐形问题Windows的文本缩放会直接影响控件渲染尺寸如果被测机是125%或150%缩放图标实际显示尺寸会比截图大一圈导致模板匹配失败率激增。用脚本改缩放比例是可行的但改完必须注销重登才能完全生效我实际操作中发现如果只是改注册表不注销部分应用不会立即采用新缩放值。这个坑建议提前跟运维协调好把执行机直接做成固定配置。主题统一这点容易被忽略。QQ音乐有深色模式和浅色模式如果你截图时用的是深色主题脚本跑在浅色主题机器上界面的按钮颜色完全不同模板匹配基本必挂。所以我建议测试机统一用浅色模式并且关闭客户端的“自动跟随系统主题”选项。3.3 QQ音乐客户端的版本锁定和登录态准备GUI自动化最怕版本变一更新界面就变所有坐标全部作废。所以测试机上必须锁定QQ音乐版本关掉“自动更新”功能。具体操作设置 → 通用 → 关闭自动更新。同时为了防止QQ音乐启动时弹出升级提示框我还专门截了一张升级弹窗的截图在每次启动后检查这个弹窗是否存在存在就点掉。登录态处理上有两个选择一是每次测试前都重新扫码登录二是利用记住登录状态只登录一次。实测下来QQ音乐在测试机上每天首次启动可能需要重新登录一次之后只要不退出后续重启客户端会自动恢复登录态。所以我采用的方法是所有用例共用一个登录fixture首次执行时如果检测到未登录就扫码登录成功后截图保存登录态特征图后续用例都默认已登录减少重复操作。4. 核心测试场景设计与脚本实现4.1 启动客户端与登录态自动化处理启动和登录是所有场景的前置条件我单独写了一个fixtureimport pytest import pyautogui import subprocess import time import cv2 import numpy as np QQMUSIC_PATH rC:\Program Files\Tencent\QQMusic\QQMusic.exe def find_image(template_path, confidence0.8, regionNone): 在屏幕上查找模板图片返回中心坐标找不到返回None screenshot pyautogui.screenshot() # 如果指定了region只截取该区域加快查找速度 if region: screenshot screenshot.crop(region) screenshot_cv cv2.cvtColor(np.array(screenshot), cv2.COLOR_RGB2BGR) template cv2.imread(template_path) result cv2.matchTemplate(screenshot_cv, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc cv2.minMaxLoc(result) if max_val confidence: h, w template.shape[:2] center_x max_loc[0] w // 2 center_y max_loc[1] h // 2 if region: center_x region[0] center_y region[1] return center_x, center_y return None pytest.fixture(scopesession) def qqmusic_ready(): 确保QQ音乐启动并已登录 # 如果客户端还没启动先启动 subprocess.Popen(QQMUSIC_PATH) time.sleep(8) # 检测登录界面是否出现 login_btn find_image(templates/login_btn.png, confidence0.7) if login_btn: # 提示用户扫码等待登录完成 print(请使用QQ音乐App扫码登录登录完成后脚本会自动继续) # 轮询等待登录成功登录按钮消失或主界面元素出现 for _ in range(60): time.sleep(2) if find_image(templates/play_btn.png, confidence0.7): break else: raise TimeoutError(扫码登录超时) return True这段代码的核心思路是用OpenCV的matchTemplate做模板匹配这一步比PyAutoGUI自带的locateOnScreen更可控。confidence参数代表了匹配的“置信度”太高找不到、太低误匹配我一般调试时从高到低逐级调整找到最佳值。实操心得模板截图一定要从“真实运行环境”里截不要用UI设计稿。我刚开始用设计师给的图标原图当模板死活匹配不上后来才发现渲染出来的图标和原图在明暗对比、边缘锐度上都有差异。正确做法是先用截屏工具在目标界面上截取按钮区域再把这个截图作为模板。4.2 搜索歌曲并播放的核心操作流搜索播放是QQ音乐最核心的路径覆盖了搜索框输入、结果列表点击、播放按钮确认三个子动作。我的实现方案分五步第一步点击顶部搜索框这里用PyAutoGUI的坐标点击。def open_search(): 点击搜索框 pos find_image(templates/search_box.png, confidence0.75) if pos is None: raise RuntimeError(未找到搜索框请检查当前界面是否为QQ音乐主界面) pyautogui.click(pos) time.sleep(1)第二步输入搜索词。PyAutoGUI的typewrite在输入中文时有坑它的字符映射表只覆盖ASCII范围中文会报错或乱码。我的解决办法是先通过剪贴板粘贴def input_text(text): 通过剪贴板输入文本支持中文 import pyperclip pyperclip.copy(text) pyautogui.hotkey(ctrl, v) time.sleep(2)第三步等待搜索结果列表出现按下回车键或点击“搜索”按钮。def search_song(song_name): 搜索歌曲并播放 open_search() input_text(song_name) pyautogui.press(enter) time.sleep(3)第四步验证搜索结果。这里不能只看界面是否出现了歌曲列表要看是否出现了包含关键词的文本。由于PyAutoGUI本身不能读文本我又不想引入OCR增加复杂度所以退而求其次验证结果列表第一项附近是否匹配到“播放按钮”的模板。pos find_image(templates/song_item_play_btn.png, confidence0.7, region(300, 300, 1300, 500)) if pos is None: raise AssertionError(搜索结果中未找到可播放的歌曲)第五步点击播放然后等待3秒后验证播放状态是否变化。播放状态验证有一个隐蔽的细节QQ音乐播放按钮会在“播放”和“暂停”两个图标间切换。因此匹配播放按钮时要考虑到当前状态。我的处理是匹配两种图标如果匹配到暂停图标说明已经在播放如果匹配到播放图标说明还没播再去点击。if find_image(templates/pause_btn.png, confidence0.75): print(已经是播放状态无需重复点击) else: play_btn find_image(templates/play_btn.png, confidence0.75) if play_btn is None: raise AssertionError(未找到播放按钮) pyautogui.click(play_btn)这几个步骤组装成一个完整用例def test_search_and_play(qqmusic_ready): 搜索歌曲《晴天》并播放 search_song(晴天 周杰伦) # 等待播放缓冲 time.sleep(3) assert find_image(templates/pause_btn.png, confidence0.7) is not None, 歌曲未成功播放运行结果理想的话从输入关键词到确认播放状态整条链路在20秒内跑完比手动操作快不少而且可以反复执行不厌烦。4.3 音量调节和歌词显示验证音量调节这个场景是典型“看似简单、做起来容易翻车”的用例。QQ音乐的音量条是滑条控件滑条比较细光靠模板匹配很容易点偏。我的方案是先定位音量图标的位置然后通过相对坐标偏移定位滑条的轨道和滑块再用拖拽操作调音量。def set_volume(target_percent): 设置音量为目标百分比 # 定位音量图标 vol_icon find_image(templates/volume_icon.png, confidence0.75) if vol_icon is None: raise RuntimeError(未找到音量图标) # 音量条在音量图标右侧约80像素处这是根据实际界面量出来的 x_offset vol_icon[0] 60 y_offset vol_icon[1] 5 # 从当前滑块位置拖拽到目标位置 pyautogui.moveTo(x_offset, y_offset) pyautogui.dragTo(x_offset int(120 * target_percent / 100), y_offset, duration1) time.sleep(1)这里有个经验值QQ音乐音量条的可用拖动范围大约是120像素对应0%到100%。换算关系要用实际截屏量出来不同版本可能有差异所以在脚本头部写清楚版本号方便后续排查。歌词显示验证更有意思。QQ音乐的歌词是在播放页面动态滚动的歌词文本本身无法通过模板匹配去逐字验证。我的做法是检测“歌词面板是否显示”这个状态而不是检测歌词内容截取歌词区域的图片判断该区域是否包含歌词行具体思路是计算该区域的色彩方差——如果正在显示歌词会有多行不同透明度的文本像素方差通常较大如果没有歌词背景会非常干净方差很小。def is_lyrics_displayed(): 粗略判断歌词区域是否有歌词内容 region (200, 400, 900, 500) # 歌词显示区域根据实际界面调整 screenshot pyautogui.screenshot(regionregion) img cv2.cvtColor(np.array(screenshot), cv2.COLOR_RGB2GRAY) variance img.var() return variance 30 # 阈值需要跑几次调整这个粗糙但有效的方案在多数情况下稳定后续如果要升级可以考虑接入OCR引擎做逐字匹配那是另一个维度的工作了。4.4 切歌与播放模式切换的健壮性设计切歌和播放模式切换是我项目里跑的次数最多、也最容易出现随机失败的两个场景。切歌有三种方式点击“下一首”按钮、双击列表里的其他歌曲、播放完自动切歌。我覆盖的是前两种。点击“下一首”按钮的核心逻辑和之前的按钮点击一致不赘述。麻烦的是双击歌曲列表切歌。QQ音乐的列表行在“悬停”状态下会显示播放/暂停按钮非悬停状态下只有一个歌曲名。双击操作需要先精确移动到指定行再双击这两步之间如果鼠标发生位移比如被后台弹窗打断就会点到别的行。我的处理方式是定位到指定歌曲行后先用moveTo移动鼠标sleep 0.5秒让悬停效果加载出来再执行双击最后再等一声。实测下来悬停等待能明显降低失败率。播放模式切换列表循环、单曲循环、随机播放在QQ音乐里是同一个按钮的多次点击。要验证当前处于什么模式只能靠按钮图标的区别来识别。我把三种模式的图标分别截图保存为不同模板然后用模板匹配判断当前模式。这个方案有个细节三个图标外形相似、差异较小置信度阈值要调得比较高0.85以上否则会混淆。def switch_play_mode(target_mode): 切换到指定的播放模式loop / single / shuffle mode_templates { loop: templates/mode_loop.png, single: templates/mode_single.png, shuffle: templates/mode_shuffle.png, } # 点击一次模式按钮切换 mode_btn find_image(templates/mode_btn.png, confidence0.7) if mode_btn is None: raise RuntimeError(未找到播放模式按钮) # 最多尝试6次因为模式只有3种理论上6次内肯定能切到目标 for _ in range(6): # 判断当前是什么模式 for mode, template in mode_templates.items(): if find_image(template, confidence0.85): current_mode mode break else: raise RuntimeError(无法识别当前播放模式) if current_mode target_mode: return True pyautogui.click(mode_btn) time.sleep(1) raise AssertionError(f无法切换到目标模式: {target_mode})这里用了一个“试错循环”的设计因为三种模式之间有固定循环顺序只要当前模式不是目标模式就点击一次按钮再判断最多6次必然命中。这个思路比一次性计算“点击几次能到目标模式”要稳妥——如果中途有一次点击没生效界面卡顿循环也能自动修正不会跑到错误分支上去。5. 常见问题与排查技巧实录5.1 图像匹配找不到目标或误匹配这是图像识别方案最高频的问题我总结排查经验有四个维度第一模板图是否清晰完整。不要截到按钮边缘的阴影或圆角外区域这会让匹配噪声变大。用截图工具放大到200%再裁剪保证模板边界贴合按钮边缘。第二置信度阈值是否合理。我一般先用0.6跑一遍把找到的坐标打印出来看匹配到的位置是否正确。如果找到了但位置不对说明阈值太低逐步提高到0.75、0.8如果根本找不到说明阈值太高或者模板和环境差异大。0.7到0.8是个比较均衡的区间。第三region参数是否设置。全屏搜索在1920x1080下一张截图约200万像素matchTemplate跑一次得几十毫秒频繁调用会拖慢整个测试。而且全屏搜索容易误匹配到其他区域的相似图标。我实际操作时一定先人工确认目标按钮的大致区域在代码里用region限定搜索范围。第四环境是否变化。分辨率变了、主题变了、客户端版本升级了这些都会导致匹配失败。所以每次客户端升级后第一件事就是重新截取所有模板图确认后再跑回归。5.2 非预期弹窗导致测试失败怎么办QQ音乐的弹窗极其丰富而且出现时机不确定。最常见的包括会员活动弹窗、每日推荐弹窗、版本更新提醒、签到提醒、网络异常提示。一个弹窗如果在点击播放按钮的前一秒出现就会把坐标完全顶掉点击不生效整个用例失败。解决思路有三种我建议组合使用一种是“启动后集中清理”。在客户端启动后、执行任何用例前运行一个“弹窗扫荡”函数遍历所有已知弹窗模板如果找到了就点击关闭按钮。这个函数执行完再进入业务用例。但干扰弹窗经常在业务操作过程中出现所以只能降低概率不能完全消除。第二种是“失败重试机制”。在pytest里用retry装饰器对用例做重试失败后先清弹窗再重跑。这个方案应对随机弹窗最有效。如果弹窗只在特定时段出现比如会员活动是每天第一次启动弹重试一次就能跳过。pip install pytest-rerunfailures然后在运行命令里加上重试参数pytest --reruns 2 --reruns-delay 3第三种是“每步操作前都先做弹窗检查”。在核心交互函数click_target、input_text里统一加一道弹窗检测发现已知弹窗就先关闭再继续。这个方案最彻底但消耗时间会多一些适合对稳定性要求高的冒烟测试。注意不要把弹窗关闭按钮的坐标写死。不同弹窗的关闭按钮位置不同而且如果弹窗加载动画还没结束就点击会点到空白区域。正确做法是对每个弹窗截取“关闭按钮”的小图作为模板按模板匹配去点击。5.3 脚本跑着跑着界面就卡了或者变慢了QQ音乐客户端长时间运行后占用的内存越来越大UI响应越来越慢截图处理的时间也成比例增加。我遇到过一次连续跑3个小时后全部用例超时的状况排查发现是客户端内存占用从400M涨到2.5G。解决策略是给所有用例设置合理的超时时间超过一定执行时间自动重启客户端。pytest里可以用pytest-timeout插件pip install pytest-timeout pytest --timeout60同时写一个用例级fixture每个用例执行前检查客户端进程的CPU和内存占用如果内存超过阈值先杀掉进程重新启动。这个fixture的检查开销很小但能有效防止客户端状态劣化传导到测试结果上。import psutil pytest.fixture(autouseTrue) def check_client_health(): 每个用例前检查客户端进程健康状态 for proc in psutil.process_iter([name]): if proc.info[name] QQMusic.exe: memory_mb proc.memory_info().rss / 1024 / 1024 if memory_mb 1500: proc.kill() time.sleep(2) subprocess.Popen(QQMUSIC_PATH) time.sleep(8) break yield5.4 日志与截图的留存策略GUI自动化如果没有日志和截图排查问题会非常痛苦。我采用两个措施第一在关键步骤插入操作日志记录每个动作的坐标、匹配置信度、耗时。用loguru的日志格式可以直接输出到文件和终端。from loguru import logger def click_target(template_path, confidence0.75, regionNone): pos find_image(template_path, confidence, region) if pos is None: logger.warning(f未找到目标图片: {template_path}, 置信度阈值: {confidence}) pyautogui.screenshot(ffailure_{time.time()}.png) raise RuntimeError(f未找到目标图片: {template_path}) logger.info(f点击 {template_path} 位于 {pos}) pyautogui.click(pos) return pos第二每个测试用例结束后都强制截图即使通过也保存。失败时额外再保存一张并且把截图路径写进测试报告。这样回看报告时可以直接看到执行到哪一步、界面长什么样。allure原生支持截图附加一行代码搞定def attach_screenshot(namescreenshot): allure.attach(pyautogui.screenshot(), namename, attachment_typeallure.attachment_type.PNG)6. 测试报告生成与持续优化方向pytest allure的组合可以生成很直观的HTML报告运行方式pytest test_qqmusic.py --alluredir./allure-results allure generate ./allure-results -o ./allure-report --clean allure open ./allure-report报告中能看到每条用例的执行结果、附带的截图、日志、失败时的整屏截图。这个报告不只是给自己看团队其他成员也能快速判断客户端回归状态。后续优化的方向有几个。一是引入OCR替换掉部分模板匹配比如歌词内容验证、搜索结果中的文本断言OCR可以直接读屏幕文本断言内容精度更高。二是加入异常弹窗的自动学习能力遇到没见过的弹窗时先暂停并截屏人工标记一次后后续自动处理同款弹窗。三是把这套脚本接到定时任务里每天凌晨自动跑一遍核心回归早上到公司直接看报告。我实际做下来QQ音乐客户端GUI自动化最难的不是写脚本而是让脚本在一个充满不确定性弹窗、网络、更新、登录态的真实桌面环境里稳定运行。PyAutoGUI这套方案虽然“看起来不够高级”但它解决了真实问题在自绘控件环境里找到了一条可落地、可维护、可扩展的路径。如果你也想对类似桌面客户端做GUI自动化我的建议是先从一条核心路径摸透所有套路再横向扩展到更多场景。环境规范化的优先级永远高于脚本本身的复杂度这一点越早明白越省力。
返回列表