演练: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自动化测试的练手。
而我们要做的事情,就是完成一整个租车的流程,包括以下内容:
- 登录
- 浏览车型
- 租车
- 填写日期和地点
- 挑选车型
- 填写个人信息
- 完成租车
- 查看租车订单
编写剧本文件
因为此次Java应用的自动化仍属于桌面应用,因此在项目创建时选择“Windows” 模版,在剧本中写入以下内容:
# language: zh-CN
功能: Java自动化API测试
使用CarRental应用进行Java自动化API的测试与验证
场景: 启动并进入欢迎界面
* 使用账户名"john"登录
场景: 浏览汽车
* 进入看车界面
* 选中汽车"Toyota Prius"
* 查看汽车信息
* 返回首页
场景: 选择租赁的汽车
* 进入租车界面
* 选择地区"New York"
* 选择汽车"Toyota Prius"
* 填写个人信息并选择附加服务
* 完成租车
场景: 查看租车订单
* 进入订单界面
* 搜索与"Mark"相关的订单
* 检查订单客户全称为"Mark Test"
* 返回首页
场景: 关闭应用
* 关闭CarRental应用
建立模型
编辑完场景,接下来需要根据场景中会用到的控件添加到模型树中,以便接下来编写自动化脚本。
侦测Java控件
双击项目目录中的.tmodel
文件打开模型管理器,接着打开租车应用CarRental.jar
,可以从CukeTest官方的Github中下载。
与侦测普通桌面应用不一样的是,侦测Java应用应该使用另一个独立的侦测按钮——“侦测Java控件”:
如果下载的应用无法打开,或者侦测Java控件时只能侦测到应用的窗口,而不是具体控件,可能是Java环境的问题,查看Java应用自动化。
由于这个应用中,每个模块都由不同的窗口分管,所以我们也按照不同模块的顺序来介绍需要侦测的控件。
侦测登录界面的控件
登录界面只需要侦测账户与密码输入框,和“登录”按钮即可。
侦测欢迎界面的控件
欢迎界面就是入口界面,从左往右有三个入口:浏览车型、查看订单和新建订单。之后我们所有操作结束后都会返回这个界面,以便于继续进行接下来的测试。
其实模型树中的
JPane
大部分都是不必要的,都只是各种类型的容器,它们在模型中的作用是为了定位到我们需要的子控件。
侦测“浏览车型”流程的控件
在“浏览车型”(View Cars)这个流程中,只有一个界面我们一次性全部都侦测好。侦测树控件前把要侦测的树节点全部展开,保证树节点加载完毕。
为了返回主界面,把Home
按钮一起侦测了。
侦测“新建订单”流程的控件
在主界面点击“New Order”按钮进入“新建订单”的流程界面,这个流程中有四个界面,我们按流程来识别一下:
“新建订单”第一步界面
这一步中实际操作下来会发现有个奇怪的地方,比如“下一步”按钮一开始是不可以点击的,但是鼠标移动过去时又会被激活。于是推测可能当鼠标悬停在该按钮上时会被激活,使用模型管理器调用该按钮的moveMouse()
的方法,发现并没有效果。
继续摸索界面的操作逻辑,发现当鼠标经过按钮上方的复选框所在行时,按钮会被触发。因此在点击“下一步”按钮前,先让鼠标光标移动到复选框上就可以正常点击了。
“新建订单”第二步界面
到了这个界面,突然觉得有些眼熟……这不就是刚刚“浏览车型”的界面吗?识别以后会发现都会被重命名,并且与“浏览车型”界面的控件重复了。
虽然这不会有影响,但是如果能够将结构与属性完全一致控件模型合并,就可以只用一套脚本完成对这些控件的操作了,这部分的优化可以查看后续的脚本改进。
“新建订单”第三步界面
接着进入下一步,这一步中要填写个人信息的表单,表单设定了几个必填项。与上一步类似,只有所有必填项都有值的时候才会激活“下一步”按钮。
在实际情况中会发现,当使用
set()
方法填充完三个必填输入框后,“下一步”按钮并没有触发,这是由于set()
方法直接修改了输入框的值,因此只触发了change
事件(Event),而“下一步”按钮可能监听的是focus
、unfocus
、blur
或者input
事件,因此没有被激活,这个时候可以接着输入Enter
键或Tab
键来进行触发。
“新建订单”第四步界面
这一步非常简单,只需要识别Finish
按钮,以及点击以后会弹出的提示框中的“确认”按钮。
改进相似模型
由于很多控件会在多个界面中复用,比如“浏览汽车”界面的树在“新建订单”第二步的界面中也出现了;返回主界面的Home
按钮以及下一步的Next
按钮,在很多界面中都看得到,这个时候我们可以将这些近似的对象去重——通过去除差异的识别属性(通常是控件所在窗口的标题属性),将两个对象合并为一个。
以Home
按钮举例,侦测控件的时候,将顶部的Window
控件中的Name
也就是标题属性从识别属性中删除:
这样完成侦测以后的控件因为与原先的属性不一致,所以会被当作不同的控件:
接着删除掉其它类似的控件对象,再将控件对象改为合适的名称,例如下面这样:
可以接着尝试把Next
按钮和“浏览汽车”界面的树都进行改进。
侦测“查看订单”流程的控件
“查看订单”流程只有一个界面,可以通过搜索框检索订单数据,订单数据以表格的形式呈现,因此我们配合搜索和查询表格内容来完成自动化。
调试表格控件的方法
你可能会想,如何确认控件支持操作的能力,从而指定自动化的实现方案呢?模型管理器提供了调试控件方法的功能,以刚刚侦测的表格控件为例,先选中这个控件的对象:
接着在右侧的属性栏的“控件操作”标签页中,选中data()
方法,再点击“测试运行”按钮,如下:
就能够获取到表格控件中的所有数据:
编写脚本
接下来进行最后一部分的内容,编写自动化脚本,这里我们将使用已经写好的脚本,将其场景化,以搭配业务流程的结构。
修改模型引入脚本
Java自动化脚本其实与其它Windows桌面自动化的脚本类似,因为大部分的操作方法和属性方法都统一了命名,所以从脚本来看并没有太多区别。只需要把引入模型代码替换:
const { AppModel, Auto } = require('leanpro.win');
const model = AppModel.loadModel(__dirname + "\\model1.tmodel")
将上面两行代码替换为下面两行代码:
const { JavaModel, JavaAuto } = require("leanpro.java");
let model = JavaModel.loadModel(__dirname + "/model1.tmodel");
AppModel和JavaModel都能加载模型文件,不同的是它们提供了不同技术的操作方法。前者是Windows控件后者是Java控件。
用户可以直接在模型管理器中选中任一Java控件,在操作方法页直接选择“复制模型代码”按钮:
引入写好的脚本文件
从此处复制写好的脚本文件,拷贝到definitions1.js
所在目录,命名为car-rental.js
。或者从附录直接复制。
接着在definitions1.js
文件中使用require
关键字来引用,在文件头部加入以下脚本:
const CarRental = require('./car-rental.js');
接着进行初始化,用上一步中加载模型文件后的model
变量来初始化。
const cr = new CarRental(model);
从剧本文件生成脚本框架
依次点击剧本中的灰色箭头在打开的脚本文件中生成脚本框架。
接下来只需要把调用自动化操作的脚本放到步骤中即可。
脚本的BDD改造
通过与业务流程贴近的场景和步骤描述管理脚本,是BDD(行为驱动测试)的核心理念,而所有自动化操作相关的脚本都写在car-rental.js
文件中,现在只需要做一些改造就能完成一个BDD项目。
以第一个步骤“使用账户名{string}登录”为例,生成的脚本框架如下:
Then("使用账户名{string}登录", async function(arg1) {
return 'pending';
});
而car-rental.js
中有一个login
方法,里面是完成登录操作的自动化脚本:
// "使用账户名{string}登录"
async login(username) {
await this.model.getJEdit("User name").set(username);
await this.model.getJButton("Login").click(0, 0, 1);
}
用调用这个方法的脚本代替原来步骤中的return 'pending'
语句。同时对参数命名做一些修改,使其语义更清晰,结果如下:
Then("使用账户名{string}登录", async function(username) {
await cr.login(username);
});
通过这种方式完成所有步骤的脚本编写,如下:
Then("使用账户名{string}登录", async function(username) {
await cr.login(username);
});
Then("进入订单界面", async function() {
await cr.redirectToView("View Orders")
});
Then("搜索与{string}相关的订单", async function(keyword) {
await cr.orderSearching(keyword);
});
Then("检查订单客户全称为{string}", async function(fullName) {
await cr.orderCheckingByName(fullName);
});
Then("返回首页", async function() {
await cr.redirectToView("Home");
});
Then("进入租车界面", async function() {
await cr.redirectToView("New Order");
});
Then("选择地区{string}", async function(location) {
await cr.selectLocation(location);
await cr.nextStep();
});
Then("选择汽车{string}", async function(car) {
await cr.selectCar("Compact");
await cr.selectCar("Toyota Prius");
await cr.nextStep();
});
Then("填写个人信息并选择附加服务", async function() {
await cr.fillForm();
await cr.nextStep();
});
Then("完成租车", async function() {
await cr.completeRental();
});
Then("进入看车界面", async function() {
await cr.redirectToView("View Cars");
});
Then("选中汽车{string}", async function(car) {
await cr.checkCar(car);
});
Then("进入下一步", async function() {
await cr.nextStep();
});
Then("查看汽车信息", async function() {
let info = await cr.checkCar();
this.attach(info, "image/png");
});
Then("关闭CarRental应用", async function() {
await cr.closeByDefault();
});
运行项目
运行结果如下:
附录
const assert = require('assert');
const child_process = require('child_process');
module.exports = class CarRental {
constructor(model){
this.model = model;
this.pid = null;
this.pickOrReturn = "Pickup State"; // "Pickup State" | "Return State"
}
// "进入订单界面"
async redirectToView(view) {
// view: "View Orders" | "New Order" | "View Cars" | "Home"
await this.model.getJButton(view).click(0, 0, 1);
}
// "搜索与{string}相关的订单"
async orderSearching(condition) {
await this.model.getJEdit("Search").set(condition);
await this.model.getJButton("Search1").click(0, 0, 1);
}
// "检查订单"
async orderCheckingByName(fullName) {
let firstInTable = async(data) => {
try {
let lastName = await this.model.getJTable("table").getCellValue(0, 2);
let firstName = await this.model.getJTable("table").getCellValue(0, 1);
return firstName + ' ' + lastName === data
}
catch(e) {
return false;
}
}
let nameInOrder = await firstInTable(fullName);
assert.ok(nameInOrder, '目标订单不在表中');
}
// "选择地区{string}"
async selectLocation(location, pickOrReturn) {
const model = this.model;
if(location == "New York"){
await model.getGeneric("scroll bar").click(0, 150, 1);
await model.getGeneric("scroll bar").click(0, 150, 1);
await model.getGeneric("scroll bar").click(0, 150, 1);
await model.getJLabel("New York").click(0, 0, 1);
await model.getJCheckBox("Return car at the same locatio").moveMouse();
return ;
}
if (pickOrReturn)
this.pickOrReturn = pickOrReturn;
let locationList = await this.getLocationList();
let targetIndex = locationList.find((loc)=> loc == location);
console.log(this.pickOrReturn);
await this.model.getJList(this.pickOrReturn).select(targetIndex);
}
async getLocationList() {
let locationList = await this.model.getJList("Pickup State").data();
return locationList;
}
// "进入下一步"
async nextStep(){
await this.model.getJButton("Next").click(0, 0, 1);
}
// "填写个人信息并选择附加服务"
async fillForm() {
await this.fillProfile();
await this.fillPricing();
await this.fillAddon();
}
// 填写个人信息
async fillProfile() {
await this.model.getJEdit("First Name").set("Mark");
await this.model.getJEdit("Last Name").set("Test");
await this.model.getJEdit("Driver License").click();
await this.model.getJEdit("Driver License").pressKeys('123456');
await this.model.getJEdit("Driver License").pressKeys('{TAB}');
}
// 填写优惠码
async fillPricing() {
await this.model.getJRadioButton("I have a discount coupon:").check();
await this.model.getJEdit("Discount").set("ABCD-CBAD-ADBC-BCAD");
}
// 选择其它业务
async fillAddon() {
await this.model.getGeneric("greenhouse gas").click(0, 0, 1);
await this.model.getGeneric("collision").click(0, 0, 1);
}
// "完成租车"
async completeRental() {
await this.model.getJButton("Finish").click(0, 0, 1);
await this.model.getJButton("确定").pressKeys("~");
}
// "选中汽车{string}"
async selectCar(carName) {
// 展开选中树节点
await this.model.getJLabel(carName).dblClick(0, 0, 1);
}
// "查看汽车信息"
async checkCar() {
// await this.model.getJLabel("label").takeScreenshot('selected_car.png'); // 将截图保存至本地
let actualCarImage = await this.model.getJLabel("label").takeScreenshot();
// let expectedCarImage = await Image.fromFile(".\\assets\\expected_car.png");
let remain = await this.model.getJEdit("Currently available cars").value();
console.log("当前选中的汽车库存为:", remain)
let charge = await this.model.getJEdit("Car charge per day").value();
console.log("当前选中的汽车每天租金为:", charge);
return actualCarImage;
}
// "启动CarRental应用"
static async launcher(path) {
this.pid = child_process.spawn("java", ["-jar", path, "&"], { detached: true, shell: false });
}
// "关闭CarRental应用"
async closeByDefault() {
let windowChild = this.model.getJMenu("About");
await windowChild.getJWindow("AnyWindow", {search:'up'}).close();
try{
// 如果是中途退出需要再次点击“确认”按钮
await this.model.getJButton("Yes").click(0, 0, 1);
}catch(e){};
}
// "通过菜单关闭CarRental应用"
async closeByMenu() {
await this.model.getJMenu("File").click(0, 0, 1);
await this.model.getJMenuItem("Close").click(0, 0, 1);
}
// "使用账户名{string}登录"
async login(username) {
await this.model.getJEdit("User name").set(username);
await this.model.getJButton("Login").click(0, 0, 1);
}
}