演练: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” 模版,在剧本中写入以下内容:

# 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环境的问题,查看Java应用自动化

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

侦测登录界面的控件

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

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

侦测欢迎界面的控件

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

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

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

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

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

侦测“浏览车型”界面

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

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

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

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

“新建订单”第一步界面

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

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

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

“新建订单”第二步界面

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

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

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

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

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

“新建订单”第三步界面

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

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

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

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

“新建订单”第四步界面

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

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

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

改进相似模型

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

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

删除差异属性

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

删除差异属性

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

调整对象名称

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

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

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

侦测“查看单”界面

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

调试表格控件的方法

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

选中表格控件对象

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

调试table控件的data()方法

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

获取表格数据

编写脚本

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

修改模型引入脚本

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

JavaScript
const { WinAuto } = require('leanpro.win');
const model = WinAuto.loadModel(__dirname + "\\model1.tmodel")

将上面两行代码替换为下面两行代码:
JavaScript
const { JavaAuto } = require("leanpro.java");
let model = JavaAuto.loadModel(__dirname + "/model1.tmodel");
Auto和JavaAuto都能加载模型文件,不同的是它们提供了不同技术的操作方法。前者是Windows控件后者是Java控件。

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

引入写好的脚本文件

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

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

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

JavaScript
const cr = new CarRental(model);

从剧本文件生成脚本框架

依次点击剧本中的灰色箭头在打开的脚本文件中生成脚本框架。

生成脚本框架

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

脚本的BDD改造

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

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

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

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

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

用调用这个方法的脚本代替原来步骤中的return 'pending'语句。同时对参数命名做一些修改,使其语义更清晰,结果如下:
JavaScript
Then("使用账户名{string}登录", async function(username) {
    await cr.login(username);
});

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

JavaScript
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();
});

运行项目

运行结果如下:
运行报告

附录

文件:car-rental.js

JavaScript
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); 
    }
}

results matching ""

    No results matching ""