演练: 操作Qt应用中的List
背景
需要针对Qt的QListView组件开发的列表窗口进行操作和自动化测试。QListView通常用于含有大量选项的窗口,比如文件列表、清单等等。以下我们对QListView控件简称List。
目标
本次演练针对List的几个操作进行实现,但实际上CukeTest已经提供了合适的API帮助我们进行自动化了。实现以下几个操作:
- 选中选项;
- 滚动列表视图
本次用于测试用的被测应用为Qt中提供的Demo应用——FetchMore,它演示了一个简化的文件浏览工具,可以输入路径来检索路径下的文件/文件夹,界面如下:
为了达成目标,我们需要对列表控件实现以下几个自动化操作:
- 输入路径进行筛选
- 使用名称或索引定位目标列表项
- 点击/选中文件列表项
- 滚动列表视图
实际操作
在Qt的列表控件中,存在一些需要特别注意的点。
首先是针对选项的点击、选中操作会自动的滚动到目标选项位置以保证目标选项可见,这是因为桌面应用自身的限制。并且CukeTest为滚动的操作针对List
控件提供了scrollTo()
方法,针对ListItem
控件提供了scrollIntoView()
的方法,与Web中的scrollIntoView
类似,这些方法能够将视窗滚动到目标位置。
其次是Qt的列表视图为了性能方面的考虑,有时会采取批次加载(Batched Layout)的策略,每次只加载一部分列表,但列表到达底部时继续加载下一批次的列表,这种策略可以有效的避免一次性加载大量列表项带来的卡顿,但我们的自动化也要对其进行相应的处理。CukeTest针对这种策略进行了适配,如果目标选项位置超出了当前列表的范围,那么则会先滚动到底部触发下一批次加载,直到滚动到目标位置。
但是这个策略同样还会带来一些影响,上文提到批次加载的策略会使得只部分加载列表,这就导致针对列表的data()
方法可能会获取到不完整,因此还另外提供了scrollToBottom()
方法,直接滚动到列表底部直到内容完全加载,此时再调用data()
方法便可以取得完整的数据。
项目结构参考
在完成全部的编写后,项目的结构应该呈现如下:
├── features
│ ├── feature1.feature
│ └── step_definitions
│ ├── definitions1.js
│ └── model1.tmodel
└── package.json
完整项目可以直接从CukeTest主界面的教学样例——qt-list
打开。
剧本和模型文件
编辑剧本文件
新建项目后,按照行为驱动测试的最佳实践,首先编写剧本(*.feature文件),编写场景和步骤,然后生成代码模版。步骤在后续的开发中可能会进行调整,但在这一步我们已经通过场景描述对测试脚本的目标有了清晰的了解。
# language: zh-CN
功能: Qt ListView自动化
用于Qt的ListView组件的自动化
用于自动化的应用是FetchMore应用
场景: 选择目标位置的列表选项
当搜索CukeTest安装路径下的"./resources/api"
那么点击第13个选项
场景: 选择列表选项
当搜索CukeTest安装路径下的"./"
那么点击选项"version"
场景: 操作列表选项对象
假如操作对象为列表中的第11个选项
那么跳转到目标选项位置
那么点击目标选项
在工具栏中切换文本模式来编辑剧本内容,是一种高级的用法。
剧本文件编辑后的结果如下:
识别控件
首先创建模型文件,接着通过模型管理器打开演练所用的应用——FetchMore。对于本次自动化而言,需要添加的控件很少,只有三个:列表视图自身的List
控件、路径输入框的Edit
控件以及针对第三个场景识别的目标列表选项的ListItem
。
识别完毕后,模型树的内容如下:
执行完上述操作后,模型文件就可以满足对列表和列表项的自动化操作了,当然如果是为了调试或者熟悉应用结构,可以识别添加更多控件。
编写脚本
默认的步骤定义函数文件为definitions1.js
,当然文件名不重要,符合文件名规则并且位于项目的features
文件夹下即可被加载到。
步骤:搜索CukeTest安装路径下的{string}
任何系统中都能成功访问到的目录只有一个——CukeTest的安装目录!因此这里选择在CukeTest安装目录下搜索。process.execPath
代表CukeTest可执行文件的完整路径;path
库的dirname()
方法可以获取目标文件所在的文件夹的路径、join()
方法用于拼接多个路径,并处理不同系统间的路径差异:在Windows系统中使用反斜杠\
而Linux系统中使用斜杠/
拼接。
// 在顶部加入path库的引用
const { join, dirname } = require('path');
When("搜索CukeTest安装路径下的{string}", async function (dir) {
let installPath = dirname(process.execPath);
await model.getEdit("Edit").set(join(installPath, dir));
});
步骤:点击第{int}个选项
对于点击指定位置选项的步骤,建议使用select()
方法,当然click()
方法也能够起作用。选中前可以先使用scrollTo()
方法滚动到目标位置,使得更好观察结果,下面也会一直使用这种方法来观察运行结果。
Then("点击第{int}个选项", async function (itemIndex) {
let listObject = model.getList("List");
await listObject.scrollTo(itemIndex);
let item = await listObject.getItem(itemIndex);
await item.select();
await item.highlight()
});
上面的步骤中演示了操作列表选项的两种操作方式:
- 传入
itemIndex
操作列表中的指定项;- 调用列表的
getItem()
方法获得列表项后直接操作列表项。前者调用简单,后者操作更灵活。
步骤:点击选项{string}
对于点击指定名称的选项,CukeTest在List
控件上提供了一个findItem(itemName)
的方法,用于找到列表中指定名称的选项,注意这个方法返回的是一个ListItem
类型的自动化对象。因此以下两种写法分别针对List
和ListItem
为目标实现了点击,但是效果是完全一样的。
Then("点击选项{string}", async function (fileName) {
let listObject = model.getList("List");
let targetItem = await listObject.findItem(fileName);
let index = await targetItem.itemIndex();
await listObject.scrollTo(index);
await listObject.select(index);
});
以上代码也可以写作:
Then("点击选项{string}", async function (fileName) {
let listObject = model.getList("List");
let targetItem = await listObject.findItem(fileName);
await targetItem.scrollIntoView();
await targetItem.select();
});
不传参数调用
scrollTo()
方法可以回到顶部。
扩展知识:延时加载的列表
如果应用中的列表是延时加载的,比如新闻、消息栏,每次滚动到底部都会加载新的列表项,这个时候上面的脚本就不足以完成任务了,findItem()
方法没得到结果的情况应该滚动到底部后继续。在这种情况下脚本该写作:
Then("点击选项{string}", async function (fileName) {
let listObject = model.getList("List");
let targetItem;
while (true) {
if (targetItem = await listObject.findItem(fileName)) break;
let count = await listObject.itemCount();
await listObject.scrollToBottom(); //滚动到当前列表的底部以加载新选项
await Util.delay(1000);
let newCount = await listObject.itemCount();
if (newCount === count) break; // 如果没有加载新的选项即到达底部
}
if (!targetItem) throw 'object not found: ' + fileName;
let index = await targetItem.itemIndex();
await listObject.scrollTo(index);
let item = await listObject.getItem(index);
await item.select();
await item.highlight();
});
场景:操作列表选项对象
由于这个场景中的步骤比较短,因此集中在这一节中介绍。这个场景的目的主要是针对List
控件下的子控件——列表项控件ListItem
的操作,因此我们需要先在第一步中调用List
上的getItem()
方法获得列表项,接着通过变量传递传递到场景的其它步骤中使用。
Given("操作对象为列表中的第{int}个选项", async function (itemIndex) {
let targetItem = model.getList('List').getItem(itemIndex);
this.targetItem = targetItem;
});
Then("跳转到目标选项位置", async function () {
let targetItem = this.targetItem;
await targetItem.scrollIntoView();
});
Then("点击目标选项", async function () {
let targetItem = this.targetItem;
await targetItem.select();
await targetItem.highlight();
});
this.targetItem = targetItem
代表将变量targetItem
的值赋值给场景中的全局对象this
的targetItem
属性,这个步骤是用于在步骤之间传递对象,在之后还会经常遇到。
引入Hooks
在这里可以引入Hooks来完成以下操作:
- 开始执行步骤前,启动被操作应用,这里是fetch more应用;
- 每个场景执行完毕后使用
delay()
方法等待几秒方便观察现象; - (可选)项目执行完毕后关闭被操作应用,不关闭可以方便观察应用运行结果。
Hooks的概念和用法可以查看Hooks钩子。
因此在这一步中,在definitions1.js
脚本文件中插入了以下新的脚本:
const {BeforeAll, AfterAll, After, setDefaultTimeout} = require('cucumber');
const { CukeTest } = require('cuketest');
const { Util } = require('leanpro.common');
/// ... ///
/// 超时时间和Hook设置 ///
setDefaultTimeout(30 * 1000);
let proc; // 被测应用的进程信息,用于`launchQtProcessAsync()`和`stopProcess()`方法
BeforeAll(async function () {
CukeTest.minimize();
// Linux的可执行文件并没有`.exe`的后缀
let extension = (process.platform === 'win32') ? '.exe' : '';
proc = await QtAuto.launchQtProcessAsync(dirname(process.execPath) + "/bin/fetchmore" + extension); // 前缀为CukeTest安装路径
await model.getApplication("fetchmore").exists(10);
});
After(async function () {
// 每个场景结束后等待一会儿并截图
await Util.delay(2000);
let screenshot = await model.getWindow("Fetch_More_Example").takeScreenshot();
this.attach(screenshot, 'image/png');
});
AfterAll(async function () {
CukeTest.restore();
CukeTest.maximize();
await Util.stopProcess(proc);
});
运行结果
以下是样例在两个系统中的运行结果:
总结
以上就是针对Qt中的列表控件的自动化,针对列表的自动化场景种类不算多,因此举得例子也都比较简单,但是比较全面。在CukeTest提供的操作API加持下,很多无法顺利自动化的桌面应用也可以很成功的实现自动化。