演练:Java桌面自动化测试项目

介绍

本次演练介绍了在Windows平台上如何自动化操作Java桌面应用。Java开发图形界面的GUI库有两个——Swing与AWT,目前市面上比较常见的还是前者。本次演练使用一个简单的、使用Swing开发的租车应用实例做Java自动化测试的演练。

Q: Java应用必须使用Java识别技术吗?

A: 不一定。这取决于该应用使用的GUI框架的组件属于自绘制还是对平台原生控件的封装。比如AWT就是抽象了系统原生控件,同样的Java.CheckBox,在Windows系统中可能使用的就是Windows原生的CheckBox控件,而在Mac中可能使用的就是Mac的CheckBox,因此这类应用都可以使用系统对应的技术进行识别,不需要使用Java识别技术。而Swing的控件都是自绘制的,在不同类型的系统中能保持相对统一的控件样式,但是需要使用专门的Java识别技术。CukeTest支持多种识别技术,包括原生Windows的识别技术以及Java识别技术,可以根据被测应用的实际情况选择对应的技术。

被测应用介绍

这次用于自动化的Java应用是一款租车应用,包不仅包含了完整的自动化流程,还包含各种复杂控件,比如列表、树、表格等,非常适合用于进行Java自动化测试的练手。
应用中的列表
应用中的树
应用中的表格

而我们要做的事情,就是完成一整个租车的流程,包括以下内容:

  1. 登录
  2. 浏览车型
  3. 租车
    1. 填写日期和地点
    2. 挑选车型
    3. 填写个人信息
    4. 完成租车
  4. 查看租车订单

编写剧本文件

因为此次Java应用的自动化仍属于桌面应用,因此在项目创建时选择“Windows” 模版,在剧本中写入以下内容:

  1. # language: zh-CN
  2. 功能: Java自动化API测试
  3. 使用CarRental应用进行Java自动化API的测试与验证
  4. 场景: 启动并进入欢迎界面
  5. * 使用账户名"john"登录
  6. 场景: 浏览汽车
  7. * 进入看车界面
  8. * 选中汽车"Toyota Prius"
  9. * 查看汽车信息
  10. * 返回首页
  11. 场景: 选择租赁的汽车
  12. * 进入租车界面
  13. * 选择地区"New York"
  14. * 选择汽车"Toyota Prius"
  15. * 填写个人信息并选择附加服务
  16. * 完成租车
  17. 场景: 查看租车订单
  18. * 进入订单界面
  19. * 搜索与"Mark"相关的订单
  20. * 检查订单客户全称为"Mark Test"
  21. * 返回首页
  22. 场景: 关闭应用
  23. * 关闭CarRental应用

建立模型

编辑完场景,接下来需要根据场景中会用到的控件添加到模型树中,以便接下来编写自动化脚本。

侦测Java控件

双击项目目录中的.tmodel文件打开模型管理器,接着打开租车应用CarRental.jar,可以从CukeTest官方的Github中下载

与侦测普通桌面应用不一样的是,侦测Java应用应该使用另一个独立的侦测按钮——“侦测Java控件”:

侦测Java控件

如果下载的应用无法打开,或者侦测Java控件时只能侦测到应用的窗口,而不是具体控件,可能是Java环境的问题,查看Java应用自动化

由于这个应用中,每个模块都由不同的窗口分管,所以我们也按照不同模块的顺序来介绍需要侦测的控件。

侦测登录界面的控件

登录界面只需要侦测账户密码输入框,和“登录”按钮即可。

侦测登录界面
侦测登录界面的模型

侦测欢迎界面的控件

欢迎界面就是入口界面,从左往右有三个入口:浏览车型、查看订单和新建订单。之后我们所有操作结束后都会返回这个界面,以便于继续进行接下来的测试。

侦测欢迎界面
侦测欢迎界面的模型

其实模型树中的JPane大部分都是不必要的,都只是各种类型的容器,它们在模型中的作用是为了定位到我们需要的子控件。

侦测“浏览车型”流程的控件

在“浏览车型”(View Cars)这个流程中,只有一个界面我们一次性全部都侦测好。侦测树控件前把要侦测的树节点全部展开,保证树节点加载完毕。

侦测“浏览车型”界面

侦测“浏览车型”界面的模型

为了返回主界面,把Home按钮一起侦测了。

侦测“新建订单”流程的控件

在主界面点击“New Order”按钮进入“新建订单”的流程界面,这个流程中有四个界面,我们按流程来识别一下:

“新建订单”第一步界面

侦测“新建订单”第一步界面

这一步中实际操作下来会发现有个奇怪的地方,比如“下一步”按钮一开始是不可以点击的,但是鼠标移动过去时又会被激活。于是推测可能当鼠标悬停在该按钮上时会被激活,使用模型管理器调用该按钮的moveMouse()的方法,发现并没有效果。

继续摸索界面的操作逻辑,发现当鼠标经过按钮上方的复选框所在行时,按钮会被触发。因此在点击“下一步”按钮前,先让鼠标光标移动到复选框上就可以正常点击了。

“新建订单”第二步界面

侦测“新建订单”第二步界面

到了这个界面,突然觉得有些眼熟……这不就是刚刚“浏览车型”的界面吗?识别以后会发现都会被重命名,并且与“浏览车型”界面的控件重复了。

与“浏览汽车”的界面控件完全一致

虽然这不会有影响,但是如果能够将结构与属性完全一致控件模型合并,就可以只用一套脚本完成对这些控件的操作了,这部分的优化可以查看后续的脚本改进

侦测“新建订单”第二步界面的模型

“新建订单”第三步界面

接着进入下一步,这一步中要填写个人信息的表单,表单设定了几个必填项。与上一步类似,只有所有必填项都有值的时候才会激活“下一步”按钮。

在实际情况中会发现,当使用set()方法填充完三个必填输入框后,“下一步”按钮并没有触发,这是由于set()方法直接修改了输入框的值,因此只触发了change事件(Event),而“下一步”按钮可能监听的是focusunfocusblur或者input事件,因此没有被激活,这个时候可以接着输入Enter键或Tab键来进行触发。

侦测“新建订单”第三步界面

侦测“新建订单”第三步界面的模型

“新建订单”第四步界面

这一步非常简单,只需要识别Finish按钮,以及点击以后会弹出的提示框中的“确认”按钮。

侦测“新建订单”第四步界面

侦测“新建订单”第四步界面的模型

改进相似模型

由于很多控件会在多个界面中复用,比如“浏览汽车”界面的树在“新建订单”第二步的界面中也出现了;返回主界面的Home按钮以及下一步的Next按钮,在很多界面中都看得到,这个时候我们可以将这些近似的对象去重——通过去除差异的识别属性(通常是控件所在窗口的标题属性),将两个对象合并为一个。

Home按钮举例,侦测控件的时候,将顶部的Window控件中的Name也就是标题属性从识别属性中删除:

删除差异属性

这样完成侦测以后的控件因为与原先的属性不一致,所以会被当作不同的控件:

删除差异属性

接着删除掉其它类似的控件对象,再将控件对象改为合适的名称,例如下面这样:

调整对象名称

可以接着尝试把Next按钮和“浏览汽车”界面的树都进行改进。

侦测“查看订单”流程的控件

“查看订单”流程只有一个界面,可以通过搜索框检索订单数据,订单数据以表格的形式呈现,因此我们配合搜索和查询表格内容来完成自动化。

侦测“查看单”界面

侦测“查看订单”界面的模型

调试表格控件的方法

你可能会想,如何确认控件支持操作的能力,从而指定自动化的实现方案呢?模型管理器提供了调试控件方法的功能,以刚刚侦测的表格控件为例,先选中这个控件的对象:

选中表格控件对象

接着在右侧的属性栏的“控件操作”标签页中,选中data()方法,再点击“测试运行”按钮,如下:

调试table控件的data()方法

就能够获取到表格控件中的所有数据:

获取表格数据

编写脚本

接下来进行最后一部分的内容,编写自动化脚本,这里我们将使用已经写好的脚本,将其场景化,以搭配业务流程的结构。

修改模型引入脚本

Java自动化脚本其实与其它Windows桌面自动化的脚本类似,因为大部分的操作方法和属性方法都统一了命名,所以从脚本来看并没有太多区别。只需要把引入模型代码替换:

  1. const { AppModel, Auto } = require('leanpro.win');
  2. const model = AppModel.loadModel(__dirname + "\\model1.tmodel")

将上面两行代码替换为下面两行代码:

  1. const { JavaModel, JavaAuto } = require("leanpro.java");
  2. let model = JavaModel.loadModel(__dirname + "/model1.tmodel");

AppModel和JavaModel都能加载模型文件,不同的是它们提供了不同技术的操作方法。前者是Windows控件后者是Java控件。

用户可以直接在模型管理器中选中任一Java控件,在操作方法页直接选择“复制模型代码”按钮: 复制模型代码

引入写好的脚本文件

此处复制写好的脚本文件,拷贝到definitions1.js所在目录,命名为car-rental.js。或者从附录直接复制。
接着在definitions1.js文件中使用require关键字来引用,在文件头部加入以下脚本:

  1. const CarRental = require('./car-rental.js');

接着进行初始化,用上一步中加载模型文件后的model变量来初始化。

  1. const cr = new CarRental(model);

从剧本文件生成脚本框架

依次点击剧本中的灰色箭头演练:Java桌面自动化测试项目 - 图28在打开的脚本文件中生成脚本框架。

生成脚本框架

接下来只需要把调用自动化操作的脚本放到步骤中即可。

脚本的BDD改造

通过与业务流程贴近的场景和步骤描述管理脚本,是BDD(行为驱动测试)的核心理念,而所有自动化操作相关的脚本都写在car-rental.js文件中,现在只需要做一些改造就能完成一个BDD项目。

以第一个步骤“使用账户名{string}登录”为例,生成的脚本框架如下:

  1. Then("使用账户名{string}登录", async function(arg1) {
  2. return 'pending';
  3. });

car-rental.js中有一个login方法,里面是完成登录操作的自动化脚本:

  1. // "使用账户名{string}登录"
  2. async login(username) {
  3. await this.model.getJEdit("User name").set(username);
  4. await this.model.getJButton("Login").click(0, 0, 1);
  5. }

用调用这个方法的脚本代替原来步骤中的return 'pending'语句。同时对参数命名做一些修改,使其语义更清晰,结果如下:

  1. Then("使用账户名{string}登录", async function(username) {
  2. await cr.login(username);
  3. });

通过这种方式完成所有步骤的脚本编写,如下:

  1. Then("使用账户名{string}登录", async function(username) {
  2. await cr.login(username);
  3. });
  4. Then("进入订单界面", async function() {
  5. await cr.redirectToView("View Orders")
  6. });
  7. Then("搜索与{string}相关的订单", async function(keyword) {
  8. await cr.orderSearching(keyword);
  9. });
  10. Then("检查订单客户全称为{string}", async function(fullName) {
  11. await cr.orderCheckingByName(fullName);
  12. });
  13. Then("返回首页", async function() {
  14. await cr.redirectToView("Home");
  15. });
  16. Then("进入租车界面", async function() {
  17. await cr.redirectToView("New Order");
  18. });
  19. Then("选择地区{string}", async function(location) {
  20. await cr.selectLocation(location);
  21. await cr.nextStep();
  22. });
  23. Then("选择汽车{string}", async function(car) {
  24. await cr.selectCar("Compact");
  25. await cr.selectCar("Toyota Prius");
  26. await cr.nextStep();
  27. });
  28. Then("填写个人信息并选择附加服务", async function() {
  29. await cr.fillForm();
  30. await cr.nextStep();
  31. });
  32. Then("完成租车", async function() {
  33. await cr.completeRental();
  34. });
  35. Then("进入看车界面", async function() {
  36. await cr.redirectToView("View Cars");
  37. });
  38. Then("选中汽车{string}", async function(car) {
  39. await cr.checkCar(car);
  40. });
  41. Then("进入下一步", async function() {
  42. await cr.nextStep();
  43. });
  44. Then("查看汽车信息", async function() {
  45. let info = await cr.checkCar();
  46. this.attach(info, "image/png");
  47. });
  48. Then("关闭CarRental应用", async function() {
  49. await cr.closeByDefault();
  50. });

运行项目

运行结果如下:
运行报告

附录

文件:car-rental.js

  1. const assert = require('assert');
  2. const child_process = require('child_process');
  3. module.exports = class CarRental {
  4. constructor(model){
  5. this.model = model;
  6. this.pid = null;
  7. this.pickOrReturn = "Pickup State"; // "Pickup State" | "Return State"
  8. }
  9. // "进入订单界面"
  10. async redirectToView(view) {
  11. // view: "View Orders" | "New Order" | "View Cars" | "Home"
  12. await this.model.getJButton(view).click(0, 0, 1);
  13. }
  14. // "搜索与{string}相关的订单"
  15. async orderSearching(condition) {
  16. await this.model.getJEdit("Search").set(condition);
  17. await this.model.getJButton("Search1").click(0, 0, 1);
  18. }
  19. // "检查订单"
  20. async orderCheckingByName(fullName) {
  21. let firstInTable = async(data) => {
  22. try {
  23. let lastName = await this.model.getJTable("table").getCellValue(0, 2);
  24. let firstName = await this.model.getJTable("table").getCellValue(0, 1);
  25. return firstName + ' ' + lastName === data
  26. }
  27. catch(e) {
  28. return false;
  29. }
  30. }
  31. let nameInOrder = await firstInTable(fullName);
  32. assert.ok(nameInOrder, '目标订单不在表中');
  33. }
  34. // "选择地区{string}"
  35. async selectLocation(location, pickOrReturn) {
  36. const model = this.model;
  37. if(location == "New York"){
  38. await model.getGeneric("scroll bar").click(0, 150, 1);
  39. await model.getGeneric("scroll bar").click(0, 150, 1);
  40. await model.getGeneric("scroll bar").click(0, 150, 1);
  41. await model.getJLabel("New York").click(0, 0, 1);
  42. await model.getJCheckBox("Return car at the same locatio").moveMouse();
  43. return ;
  44. }
  45. if (pickOrReturn)
  46. this.pickOrReturn = pickOrReturn;
  47. let locationList = await this.getLocationList();
  48. let targetIndex = locationList.find((loc)=> loc == location);
  49. console.log(this.pickOrReturn);
  50. await this.model.getJList(this.pickOrReturn).select(targetIndex);
  51. }
  52. async getLocationList() {
  53. let locationList = await this.model.getJList("Pickup State").data();
  54. return locationList;
  55. }
  56. // "进入下一步"
  57. async nextStep(){
  58. await this.model.getJButton("Next").click(0, 0, 1);
  59. }
  60. // "填写个人信息并选择附加服务"
  61. async fillForm() {
  62. await this.fillProfile();
  63. await this.fillPricing();
  64. await this.fillAddon();
  65. }
  66. // 填写个人信息
  67. async fillProfile() {
  68. await this.model.getJEdit("First Name").set("Mark");
  69. await this.model.getJEdit("Last Name").set("Test");
  70. await this.model.getJEdit("Driver License").click();
  71. await this.model.getJEdit("Driver License").pressKeys('123456');
  72. await this.model.getJEdit("Driver License").pressKeys('{TAB}');
  73. }
  74. // 填写优惠码
  75. async fillPricing() {
  76. await this.model.getJRadioButton("I have a discount coupon:").check();
  77. await this.model.getJEdit("Discount").set("ABCD-CBAD-ADBC-BCAD");
  78. }
  79. // 选择其它业务
  80. async fillAddon() {
  81. await this.model.getGeneric("greenhouse gas").click(0, 0, 1);
  82. await this.model.getGeneric("collision").click(0, 0, 1);
  83. }
  84. // "完成租车"
  85. async completeRental() {
  86. await this.model.getJButton("Finish").click(0, 0, 1);
  87. await this.model.getJButton("确定").pressKeys("~");
  88. }
  89. // "选中汽车{string}"
  90. async selectCar(carName) {
  91. // 展开选中树节点
  92. await this.model.getJLabel(carName).dblClick(0, 0, 1);
  93. }
  94. // "查看汽车信息"
  95. async checkCar() {
  96. // await this.model.getJLabel("label").takeScreenshot('selected_car.png'); // 将截图保存至本地
  97. let actualCarImage = await this.model.getJLabel("label").takeScreenshot();
  98. // let expectedCarImage = await Image.fromFile(".\\assets\\expected_car.png");
  99. let remain = await this.model.getJEdit("Currently available cars").value();
  100. console.log("当前选中的汽车库存为:", remain)
  101. let charge = await this.model.getJEdit("Car charge per day").value();
  102. console.log("当前选中的汽车每天租金为:", charge);
  103. return actualCarImage;
  104. }
  105. // "启动CarRental应用"
  106. static async launcher(path) {
  107. this.pid = child_process.spawn("java", ["-jar", path, "&"], { detached: true, shell: false });
  108. }
  109. // "关闭CarRental应用"
  110. async closeByDefault() {
  111. let windowChild = this.model.getJMenu("About");
  112. await windowChild.getJWindow("AnyWindow", {search:'up'}).close();
  113. try{
  114. // 如果是中途退出需要再次点击“确认”按钮
  115. await this.model.getJButton("Yes").click(0, 0, 1);
  116. }catch(e){};
  117. }
  118. // "通过菜单关闭CarRental应用"
  119. async closeByMenu() {
  120. await this.model.getJMenu("File").click(0, 0, 1);
  121. await this.model.getJMenuItem("Close").click(0, 0, 1);
  122. }
  123. // "使用账户名{string}登录"
  124. async login(username) {
  125. await this.model.getJEdit("User name").set(username);
  126. await this.model.getJButton("Login").click(0, 0, 1);
  127. }
  128. }