演练: 操作Qt应用中的Table

针对Qt中的TableView/TableWidget组件的自动化

背景

有一个表格数据需要批量导入到目标应用的表格中,手动导入比较繁琐,目标是通过自动化的方式导入。导入的操作包括数据源的读写、应用中表格的修改等。现在我们针对Qt提供的“SpreadSheet”演示应用进行操作。

SpreadSheet应用

目标

了解如何使用CukeTest进行Qt表格(即的TableView/TableWidget组件)的操作,实现表格的自动化处理。

  1. 读取xlsx表格文件中的数据;
  2. 写入到应用中的表格;
  3. 读取应用中的表格数据;
  4. 将数据写入到MySQL数据库中;

实际操作

用CukeTest可以方便的创建BDD(行为驱动)自动化测试脚本,因此我们在实际演练中会按照BDD的方式来开发脚本。

创建项目

打开CukeTest,在“欢迎”界面选择“新建项目”,项目名称自定义。模版选择Windows模版,这是由于Qt应用也是一种桌面应用,因此,这个模板会在新建的项目中添加模型文件(*.tmodel)用于管理测试对象。

了解项目结构

假设项目名称为QtTableProject,下面列出了目录树,其中的加*的文件表示在之后我们需要手动编写的脚本文件。

  1. QtTableProject // 项目名称
  2. package.json // npm包管理文件,默认状态即可
  3. └─features // 项目的主体文件夹,根目录可以直接存放多个剧本文件
  4. feature1.feature // 剧本文件
  5. ├─step_definitions // 脚本文件夹,存放步骤定义脚本和模型文件
  6. definitions1.js // 步骤定义脚本
  7. └─ model1.tmodel // 模型文件
  8. └─support // 存放项目的其它脚本和被调用资源
  9. env.js // 配置cucumber内核运行配置,默认修改了超时时间
  10. *hook.js // 管理生命周期,或者称作钩子
  11. *db.js // 稍后要添加的文件
  12. └─*data // 存放csv和xlsx类型表格文件
  13. *spreadsheet_data.csv
  14. *spreadsheet_data.xlsx

编写feature文件

CukeTest是一个强大的剧本文件编辑器,后缀名为*.feature的文件称作剧本文件。按照“目标”一节中的自动化步骤,编写feature文件如下:

编写剧本文件

或者可以直接切换文本模式直接复制以下内容,二者是等效的:

  1. # language: zh-CN
  2. 功能: QtTable自动化
  3. spreadsheet应用为对象,完成对Qt的自动化操作
  4. 场景: 检索应用中的表格内容
  5. 同时输出00列的单元格数据
  6. 当读取spreadsheet中的第1行数据
  7. 假如输出所有单元格数据
  8. 场景: 从文件中导入数据
  9. 当读取"spreadsheet_data.xlsx"文件中的数据
  10. 那么写入到spreadsheet
  11. 场景: 从表格中导出数据到数据库
  12. 当读取spreadsheet中的第1行数据
  13. 那么将数据写入到MySQL数据库中
  14. 那么数据被成功写入数据库

更多剧本文件相关查阅剧本编辑概述

通过env.js文件理解项目加载方式

打开env.js文件,可以看到文件中只有一句调用:

  1. setDefaultTimeout(30 * 1000); //set step timeout to be 30 seconds

该语句是将步骤的超时时限设为30秒。缺省超时时间为5秒,这可以避免在步骤出现异常时无限期的等待下去。因为有些Qt的自动化操作场景可能超过5秒,这里设置一个更长的超时时间,即在等待30秒以后就会自动停止并报错。

那么这句调用是如何生效的呢?这简单介绍一下Cucumber项目的加载方式,在开始运行(包括运行项目、剧本、场景、步骤,但不包括运行脚本)时,项目会加载所有的”features”目录下的文件,如果该文件是js文件,则加载过程会使其运行,这时暴露在函数体外的脚本都会运行生效(由于env.js中没有函数,因此所有的代码都在函数体外,会被直接运行)。

可以通过将env.js文件改为如下脚本后,点击运行剧本运行任意某个剧本文件:

  1. console.log("env.js was ran!\n");

输出结果如下,可以看到文件成功的被加载了: 演练: 操作Qt应用中的Table - 图3

使用模型管理器修改模型文件

模型文件中保存的是被测应用的控件的测试对象信息,通过模型管理器识别被测应用中的控件,来得到相应的测试对象。接着我们通过调用测试对象上的API来操作控件,实现自动化操作。

因此首先我们需要使用模型管理器添加被测应用的控件,被测应用也就是spreadsheet应用。双击spreadsheet.exe打开应用,双击项目中的模型文件打开模型管理器进行编辑。

模型管理器

侦测被测应用中的Table控件,并添加到模型管理器中即可。事实上,由于TableView中的单元格控件的识别属性很有限,无法直接通过识别属性唯一识别某个单元格控件,因此在本次实践中使用了其它的方法。这种方法不依赖模型管理器,因此只需要识别到Table控件节点类型即可,节点树如下。

节点树

但是我们还是可以通过批量添加控件来了解到该应用的控件结构如下:

  1. - Table 表格控件
  2. - TableRow 行控件,代表表格中的行
  3. - Header 表头控件,无法编辑
  4. - TableItem 单元格控件,代表表格中的单元格,双击可以进入编辑状态

步骤定义

下面根据剧本文件中步骤描述,针对每个步骤的步骤定义进行介绍和实现:

输出0行0列的单元格数据

这是针对表格自动化的最基本操作——获取目标单元格,CukeTest提供了方便的获取目标单元格数据的方法——cellValue(),只需要传入行列信息,就可以取到数据了,因此脚本也可以写的非常简单:

  1. Given("输出{int}行{int}列的单元格数据", async function (row, column) {
  2. let tableModel = model.getTable("Table");
  3. let cell = await tableModel.cellValue(row, column);
  4. this.attach(cell);
  5. });

读取spreadsheet中的第{int}行数据

既然可以读取单元格数据,那么读取行数据也就理所当然的办的到了,只需要先获取行的宽度,再拿到行里的每个单元格数据就可以了。但是CukeTest提供了更方便的方法——rowData(),可以直接取到目标行的数据,并以数组形式返回。

  1. When("读取spreadsheet中的第{int}行数据", async function (targetRow) {
  2. let tableModel = model.getTable("Table");
  3. let rowdata = await tableModel.rowData(targetRow);
  4. this.cells = rowdata;
  5. });

输出所有单元格数据

遍历数据是自动化操作中比较常见的操作,尤其是针对表格,因为数据越是大量,自动化能够节省的时间就越多。CukeTest建议的遍历表格的方法,是按照表格的结构来进行,先遍历行,再遍历行中的每一列,这样就能够遍历到所有的单元格,但是这个步骤中只需要遍历的获取数据,因此可以直接遍历行并且获取整行的数据,可以写的比较简单。使用到以下两个方法:

  • rowCount: 获取表格的行数,以此确认遍历的范围。
  • rowData: 获取目标行的数据,以数组形式返回。
  1. Given("输出所有单元格数据", async function () {
  2. let tableModel = model.getTable("Table");
  3. let rowCount = await tableModel.rowCount();
  4. for(let i = 0; i < rowCount; i++){
  5. let rowData = await tableModel.rowData(i);
  6. this.attach(JSON.stringify(rowData)); // 输出的数据显示在报告中,请使用“运行项目”来生成报告
  7. }
  8. });

读取{string}文件中的数据

这一步骤是用于读出本地的表格文件,以便于将数据写入到应用中。下面的脚本是针对.xlsx文件,使用CukeTest提供的 xlsx 编写的,感兴趣的读者可以自行编写一个支持.csv文件读取的脚本,CukeTest也提供了csv读写的方法,可以直接中工具箱中拖拽使用。

由于涉及到了文件读写,所以在脚本顶部添加如下path库的引用:

  1. const path = require('path');

接着将使用到CukeTest提供的xlsx库中的几个方法:

  • xlsx.readFile: 读取.xlsx文件。
  • xlsx.utils.sheet_to_json: 将读取的文件数据转换为json格式,也就是是对象数组。
  1. When("读取{string}文件中的数据", async function (fileName) {
  2. // 直接由工具箱拖拽生成代码
  3. let workbook = xlsx.readFile(path.join(__dirname, '..', 'support', 'data', fileName));
  4. // console.log("表格数据为", workbook);
  5. let worksheetData = xlsx.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]);
  6. // console.log("工作簿数据为", worksheetData);
  7. this.attach(JSON.stringify(worksheetData, null, '\t'));
  8. this.xlsxData = worksheetData; // 保存到场景的world对象中来传递数据
  9. });

表格数据如下:
xlsx文件数据

在报告中可以看到数据读进来以后如下所示:

  1. [
  2. {
  3. "A": "Item",
  4. "B": "Date",
  5. "C": "Price",
  6. "D": "Currency",
  7. "E": "Ex. Rate",
  8. "F": "NOK"
  9. },
  10. {
  11. "A": "包子",
  12. "B": "15/6/2006",
  13. "C": "0.8",
  14. "D": "CNY",
  15. "E": "0.2",
  16. "F": "0.16"
  17. },
  18. {
  19. "A": "馒头",
  20. "B": "15/6/2006",
  21. "C": "0.5",
  22. "D": "CNY",
  23. "E": "0.2",
  24. "F": "0.1"
  25. },
  26. {
  27. "A": "可乐",
  28. "B": "15/6/2006",
  29. "C": "3.5",
  30. "D": "CNY",
  31. "E": "0.2",
  32. "F": "0.7"
  33. },
  34. {
  35. "A": "矿泉水",
  36. "B": "21/5/2006",
  37. "C": "2",
  38. "D": "CNY",
  39. "E": "0.2",
  40. "F": "0.4"
  41. },
  42. {
  43. "A": "薯片",
  44. "B": "16/6/2006",
  45. "C": "6.5",
  46. "D": "CNY",
  47. "E": "0.2",
  48. "F": "1.3"
  49. }
  50. ]

下一步就要将这些数据写入到应用中去。

写入到spreadsheet中

将数据写入到应用中的表格,还是按照第一步介绍的遍历方法,先行后列,只是这次不仅要获取数据,还要修改数据,因此涉及到以下几个方法:

  • Table.select: 选中目标单元格,并返回单元格的TableItem对象。
  • TableItem.set: 修改单元格的值。

此外,为了验证操作是否正常运行,我们还需要在运行过程中插入检查点——检查写入后的单元格内容是否与我们期望的写入值一直。这需要用到断言库assert,可以从工具箱中拖拽断言工具生成,也可以直接在文件顶部加入这一行引用:

  1. const assert = require('assert');

这里使用assert.strictEqual()方法进行严格比较,如果希望比较不那么严格,可以使用普通比较assert.equal()方法。

严格比较与普通比较的区别类似=====,比如字符串"8"与数字整型8不严格相等。

  1. > "8" == 8
  2. true
  3. > "8" === 8
  4. false
  1. Then("写入到spreadsheet中", async function () {
  2. let data = this.xlsxData;
  3. let tableModel = model.getTable("Table");
  4. let headers = await tableModel.columnHeaders();
  5. for (let row = 1; row < data.length; row++){ // 第0行不是数据,因此跳过
  6. let rowData = data[row];
  7. for(let header in rowData){
  8. let cellData = rowData[header];
  9. let cell = await tableModel.select(row, header);
  10. await cell.set(cellData);
  11. await cell.pressKeys('~'); // 输入回车键,用于确认单元格修改
  12. let actual = await cell.value();
  13. assert.strictEqual(actual, cellData);
  14. console.log(`成功在第${row}行${header}列写入${cellData}`);
  15. }
  16. }
  17. });

在修改单元格前执行了两次点击(select方法会选中单元格,可以当作一次点击),来使目标单元格进入编辑状态,接着再使用set方法修改单元格内容。

表格应用与数据库操作

下面这部分的内容涉及到数据库工具的脚本编写,相对的内容会比较多,如果应用不需要与数据库进行交互,可以跳过阅读。

数据库操作

数据库选用的MySQL 5.7,如果使用的是MySQL 8.0及以上的版本,可能会遇到密码策略不支持的错误,可以参考Q&A里的连接数据库出现ER_NOT_SUPPORTED_AUTH_MODE错误进行解决。下面将数据库的操作脚本全都放在support目录下的db.js文件里以便进行管理。

连接到数据库

连接MySQL数据库需要使用登录信息,包括用户名、密码、数据库名等,我们将其封装为函数,它传入登录信息并返回数据库连接对象,代码如下:

  1. const mysql = require('leanpro.mysql');
  2. function _connect(user="sa", password="root", database="") {
  3. let connectionSetting = {
  4. "host": "localhost",
  5. "user": user,
  6. "password": password,
  7. "database": database,
  8. "insecureAuth": true
  9. }
  10. return mysql.createConnection(connectionSetting);
  11. }

可能会有读者好奇这里的数据库连接为什么不是异步函数,这个可以通过将鼠标放在mysql.createConnection()方法上观察其返回值是否被Promise<>包括来分辨。这里只定义了连接的信息,实际的连接行为会发生在运行查询前,因此这里的函数并非异步操作。

运行查询语句

事实上,只要能够在该连接下运行SQL查询语句,我们就可以对数据库为所欲为了。

下面定义的query函数,按照mysql包的的参数传递格式,在查询语句中使用?作为占位符来传递参数。在之后的步骤定义中我们也会看到用法。或者可以点击查阅查询的执行

  1. async function query(queryString, queryData=[], conn) {
  2. if(!conn)
  3. conn = _connect();
  4. try{
  5. let res = null;
  6. if(queryData.length === 0){
  7. res = await conn.query(queryString);
  8. }
  9. else{
  10. res = await conn.query(queryString, queryData);
  11. }
  12. return res;
  13. }
  14. catch(err) {
  15. throw err;
  16. }
  17. finally {
  18. conn.end();
  19. }
  20. }

更多的MySQL的访问方法可以参考数据库访问

数据库环境配置

使用以下查询语句来创建/删除qt数据库和spreadsheet表:

  1. CREATE SCHEMA `qt` DEFAULT CHARACTER SET utf8 COLLATE utf8_bin ;
  2. DROP TABLE `qt`.`spreadsheet`;
  1. CREATE TABLE `qt`.`spreadsheet` (
  2. `Item` VARCHAR(50) NOT NULL,
  3. `Date` DATE NULL,
  4. `Price` FLOAT NULL,
  5. `Currency` FLOAT NULL,
  6. `ExRate` FLOAT NULL,
  7. `NOK` INT NULL,
  8. PRIMARY KEY (`Item`))
  9. ENGINE = InnoDB
  10. DEFAULT CHARACTER SET = utf8
  11. COLLATE = utf8_bin;

使用刚刚编写的query()方法来运行上述的两条SQL语句,实现重建数据库和数据表的操作:

  1. async function createTable(autoRemove=false){
  2. if(autoRemove){
  3. try{
  4. removeString = "DROP TABLE `qt`.`spreadsheet`;"
  5. await query(removeString);
  6. console.log("Success remove table!")
  7. }
  8. catch(err){
  9. console.warn("The table was removed.");
  10. }
  11. }
  12. await Util.delay(1000);
  13. let createString = `CREATE TABLE qt . spreadsheet (
  14. Item varchar(50) NOT NULL,
  15. Date varchar(50) DEFAULT NULL,
  16. Price float DEFAULT NULL,
  17. Currency varchar(10) DEFAULT NULL,
  18. ExRate float DEFAULT NULL,
  19. NOK float DEFAULT NULL,
  20. PRIMARY KEY (Item)
  21. ) ENGINE = InnoDB DEFAULT CHARSET= utf8 COLLATE= utf8_bin`;
  22. await query(createString);
  23. console.log("Success create table!")
  24. }

步骤描述: 将数据写入到MySQL数据库中

到这一步我们继续编写步骤描述,也就是写在definitions1.js文件中的内容。 到了这一步,上面编写的数据库操作脚本也就是db.js文件就派上用场了,首先在步骤描述文件definitions1.js顶部加入对db.js文件的引用:

  1. const db = require('../support/db');

不知道你有没有注意到,在读取spreadsheet中的第{int}行数据这一步骤中,我们写了这么一行脚本:

  1. this.cells = rowdata;

我们将取得的行数据数组存到了this.cells对象中,就是为了接下来的这个步骤。在每个场景中都维护着一个全局对象World,而步骤中的this始终指向该全局对象,借由该场景对象我们能够在场景的各个步骤间传递变量,具体可以查看在步骤间传递变量这一节。

因此,这一步骤的描述写作如下:

  1. Then("将数据写入到MySQL数据库中", async function () {
  2. let cellData = this.cells;
  3. console.log(cellData);
  4. await db.createTable(true);
  5. let res = await db.query(`INSERT INTO qt.spreadsheet
  6. (Item,
  7. Date,
  8. Price,
  9. Currency,
  10. ExRate,
  11. NOK)
  12. VALUES
  13. (?,?,?,?,?,?);`,
  14. cellData);
  15. });

这一步骤中,将传递进来的变量作为插入数据库的数据,执行相应的SQL语句来完成插入,并在下一步中执行查询来验证数据被正确插入。

步骤描述: 数据被成功写入数据库

这一步的目的是验证上一步中的插入操作是否成功的将数据插入到数据库中。

  1. Then("数据被成功写入数据库", async function () {
  2. let res = await db.query(`SELECT * FROM qt . spreadsheet;`);
  3. let arr = Object.values(res[0]);
  4. assert.deepEqual(arr,this.cells,'首条数据不匹配');
  5. });

这里由于比较的是两个对象(数组也是对象的一种),使用的是断言库的deepEqual()比较,因为无论是严格比较还是普通比较,只要两个对象的存储地址不一致就会提示不相等。

Q&A

连接数据库出现ER_NOT_SUPPORTED_AUTH_MODE错误

这是因为MySQL 8.0更新了新的密码加密方式,可以通过将登录用户的加密方式改为兼容MySQL 5的类型。使用如下语句:

  1. ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password';

rootpassword处改为自己的用户名和密码即可。