Squish 深入:架构、对象识别与实战要点
Squish 是 Qt 生态里的 GUI(Graphical User Interface,图形用户界面)自动化测试工具,原属德国 froglogic,2019 年被 Qt 公司收购,现在的正式名称是 Qt Squish。它解决的问题是:把「启动程序、点按钮、填表单、看结果」这一整套手工回归测试变成可重复执行的脚本。
一、和图像比对工具的根本区别
市面上的 UI 自动化工具大体分两派。SikuliX、Airtest 这类靠屏幕截图匹配,找到目标图片就点下去;Squish 走的是另一条路——对象级识别。
Squish 启动被测程序(AUT,Application Under Test,被测应用)时会往进程里注入 hook 库,借 Qt 的元对象系统(Meta-Object System)直接拿到运行时的 widget 树。脚本里操作的不是坐标,而是真实的 QObject 指针的代理:
table = waitForObject(":Address Book.contacts_QTableWidget")
test.compare(table.rowCount, 5) # 直接读 Qt 属性
table.sortByColumn(1, Qt.AscendingOrder) # 甚至能调 slot
带来的直接好处:换一套 QSS 皮肤、改分辨率、把按钮从左边挪到右边,脚本照样跑。截图派在这些改动下会全线失败。
代价是需要为每种 UI 工具包做专门的 hook 支持,所以 Squish 是按工具包授权的:Qt Widgets、QML(Qt Modeling Language)/ Quick、Java(Swing / SWT,Standard Widget Toolkit / JavaFX)、Web 浏览器、Windows 原生(MFC,Microsoft Foundation Classes / WinForms / WPF,Windows Presentation Foundation)、macOS、iOS、Android、Tk。
二、架构:三个进程
面试常问「Squish 是怎么控制被测程序的」,答案是三段式:
- squishserver — 常驻服务,负责启动 AUT 并注入 hook,管理连接。默认监听 4322 端口
- squishrunner — 执行测试脚本,把脚本里的操作翻译成命令发给 server
- Squish IDE — 开发环境,含录制回放、Spy(运行时对象浏览器)、Object Map 编辑器、验证点编辑器
数据流是 squishrunner → squishserver → AUT 内的 hook。理解这一层的实际意义在于:AUT 可以跑在另一台机器甚至另一个操作系统上,只要那边起了 squishserver。这是 Squish 做跨平台矩阵测试的基础。
命令行跑用例(CI 里就是这么接的):
# 注册 AUT
squishserver --config addAUT addressbook D:\build\addressbook\release
# 后台起 server
Start-Process squishserver
# 跑整个 suite,输出 JUnit XML 给 CI 收集
squishrunner --testsuite suite_addressbook `
--reportgen junit,results.xml `
--exitCodeOnFail 2
三、对象识别:Real Name 与 Symbolic Name
这是 Squish 最核心也最容易写坏的部分。定位一个控件有两种写法。
Real Name——属性组合的字面量,直接写在脚本里:
clickButton(waitForObject(
"{type='QPushButton' text='OK' visible='true' "
"window={type='QMainWindow' windowTitle='Address Book'}}"))
Symbolic Name——以冒号开头,指向 Object Map 里的一条记录:
clickButton(waitForObject(":Address Book.OK_QPushButton"))
Object Map(objects.map)里存的是符号名到真实属性组合的映射:
:Address Book_QMainWindow {type='QMainWindow' windowTitle='Address Book'}
:Address Book.OK_QPushButton {type='QPushButton' text='OK' \
window=':Address Book_QMainWindow'}
实践上一律用 Symbolic Name。理由很实际:界面改版后按钮文字从 OK 变成「确定」,用符号名只需改 Object Map 里一行,所有引用它的脚本自动修好;写死 Real Name 就得满仓库搜索替换。
3.1 让识别更稳的几条经验
- 让开发给控件设 objectName。这是最有效的一招。
text会随国际化变,objectName不会。有了它,属性组合可以简化成{type='QPushButton' objectName='btnOk'} - 避免依赖 occurrence 序号。
{type='QLabel' occurrence='3'}这种在界面加一个标签后就错位了 - 用 Spy 而不是猜。IDE 的 Spy 能在程序运行时展开完整对象树,看到每个控件的真实属性和可用方法
- 动态列表用属性过滤。表格行、列表项这类运行时生成的对象,用
waitForObjectItem配合文本定位
四、waitForObject 与同步问题
这是 Squish 用例不稳定(flaky test,间歇性失败的测试)的头号来源,也是必问题。
findObject(":Dialog.OK_QPushButton") # 立即查找,不存在则抛异常
waitForObject(":Dialog.OK_QPushButton") # 轮询等待,默认超时 20 秒
界面上任何异步产生的东西——网络请求返回后才填充的表格、延迟弹出的对话框、动画结束才可点击的按钮——都必须用 waitForObject。用 findObject 会随机失败,因为脚本执行速度远快于界面响应。
更麻烦的情况是对象已存在但状态没就绪,这时候要等条件而不是等对象:
// 等表格加载完数据,而不是等表格出现
waitFor(lambda: findObject(":contacts_QTableWidget").rowCount > 0, 10000)
// 等按钮变成可点击
waitFor(lambda: findObject(":Dialog.OK_QPushButton").enabled, 5000)
绝对不要用 snooze() 硬等。它是固定睡眠,写短了在慢机器上失败,写长了整个 suite 被拖慢。只有在实在找不到可判断的条件时才用,并且要写注释说明原因。
五、验证点
Squish 的断言都在 test 命名空间下:
test.compare(table.rowCount, 5) # 相等断言
test.verify(button.enabled) # 布尔断言
test.xcompare(a, b) # 预期失败(已知 bug)
test.fail("不该走到这个分支")
test.log("当前用户: " + username) # 只记录,不影响结果
# 截图比对,用于图表、自绘控件这类没法读属性的场景
test.vpImage("chart_baseline")
# 整个对象的属性快照比对
test.vp("contacts_table_state")
要点是 test.compare 失败后脚本会继续执行,只是把这条标记为 FAIL。想让它立刻中断得自己抛异常或提前 return。这个行为和 Qt Test 的 QCOMPARE 恰好相反,容易踩。
六、脚本组织与可维护性
录制回放只适合摸索阶段。录出来的脚本是一长串平铺的点击,改一次界面就废。真正能长期维护的写法是分层。
底层封装成页面对象(Page Object Pattern,页面对象模式):
# shared/scripts/pages.py
class AddressBookPage:
def new_contact(self, forename, surname, email):
clickButton(waitForObject(":Address Book.New_QToolButton"))
type(waitForObject(":Forename:_QLineEdit"), forename)
type(waitForObject(":Surname:_QLineEdit"), surname)
type(waitForObject(":Email:_QLineEdit"), email)
clickButton(waitForObject(":Address Book - Unnamed.OK_QPushButton"))
def row_count(self):
return waitForObject(":Address Book.contacts_QTableWidget").rowCount
用例层只表达业务意图:
source(findFile("scripts", "pages.py"))
def main():
startApplication("addressbook")
page = AddressBookPage()
page.new_contact("Jifang", "Guo", "guo@example.com")
test.compare(page.row_count(), 1)
page.new_contact("Alex", "Chen", "alex@example.com")
test.compare(page.row_count(), 2)
配套的目录约定:shared/scripts/ 放公共函数,shared/testdata/ 放 CSV/TSV 测试数据,suite_xxx/tst_yyy/ 是单个用例。数据驱动直接读表:
for record in testData.dataset("contacts.tsv"):
name = testData.field(record, "name")
email = testData.field(record, "email")
page.new_contact(name, "", email)
七、常见坑
- hook 注入失败:AUT 的 Qt 版本要和 Squish 编译时用的匹配。静态链接的 Qt 程序需要特殊处理(
--enable-attach或改用 Squish 提供的链接方案) - 脚本改了界面状态没复位:每个用例应该假设自己从干净状态开始。用
cleanup()函数或startApplication重启来保证隔离 - 中文输入:
type()对输入法有依赖,中文文本建议用setText直接赋值,或走剪贴板 - CI(Continuous Integration,持续集成)里没有图形环境:Linux 下需要 Xvfb(X Virtual Framebuffer,X 虚拟帧缓冲)虚拟显示;Windows 下注意会话隔离,服务账户跑不出交互式桌面
- 用例铺太多:Squish 用例的维护成本是单元测试的数倍。只覆盖关键路径,逻辑验证留给 Qt Test
术语表
| 简称 | 英文全称 | 中文与说明 |
|---|---|---|
| AUT | Application Under Test | 被测应用。被 Squish 注入 hook 并驱动的目标程序 |
| GUI | Graphical User Interface | 图形用户界面 |
| — | Object Map | 对象映射表。objects.map 文件,存符号名到属性组合的映射 |
| — | Real Name | 真实名。控件属性组合的字面量写法,直观但脆弱 |
| — | Symbolic Name | 符号名。以冒号开头指向 Object Map 的引用,推荐写法 |
| — | Verification Point (VP) | 验证点。test.* 系列断言,含属性快照和截图比对 |
| — | Page Object Pattern | 页面对象模式。把界面操作封装成类方法,用例层只表达业务意图 |
| — | flaky test | 间歇性失败的测试。同一份代码时而通过时而失败,多因同步问题 |
| CI | Continuous Integration | 持续集成 |
| Xvfb | X Virtual Framebuffer | X 虚拟帧缓冲。Linux 无显示器环境下跑 GUI 测试用 |
| QML | Qt Modeling Language | Qt 建模语言。声明式 UI 语言 |
| SWT | Standard Widget Toolkit | 标准控件工具包。Eclipse 的 Java GUI 库 |
| MFC | Microsoft Foundation Classes | 微软基础类库。Windows 原生 C++ GUI 框架 |
| WPF | Windows Presentation Foundation | Windows 呈现基础。微软的 XAML 界面框架 |
| XML | eXtensible Markup Language | 可扩展标记语言。Squish 报告输出格式之一 |
| TSV / CSV | Tab / Comma Separated Values | 制表符 / 逗号分隔值。数据驱动测试的测试数据格式 |
小结
Squish 的价值在于对象级识别带来的稳定性,而它的成本在于对象映射的维护。把 Object Map 管好、坚持用 Symbolic Name、所有等待走 waitForObject、脚本按 Page Object 分层——这四条做到了,Squish 用例就能长期活着,否则很快会变成没人敢动的负债。

浙公网安备 33010602011771号