金蝶云星空HTML5 WebUI自动化测试框架分享
分享一个针对金蝶云星空做的WebUI自动化测试项目,只支持HTML5界面,希望抛砖引玉获得一些金蝶云星空WebUI自动化测试或CI/CD建设方面的讨论与建议。
# Nubula自动化测试项目介绍
```
_ __ __ __
/ | / /__ / /_ __ __/ /___
/ |/ / _ \/ __ \/ / / / / __ \
/ /| / __/ /_/ / /_/ / / /_/ /
/_/ |_/\___/_.___/\__,_/_/\__,__\
```
## 概述
- Nebula针对金蝶云星空(K3Cloud)开发的Web UI自动化测试框架。
- Nebula提供操作K3Cloud的的各类接口(如过滤单据,打开单据,字段取值/赋值,点击按钮等)。
- Nebula是基于Selenide和少量Selenium接口并结合K3Cloud使用习惯进行二次封装,旨在简化接口,方便调用。
- Nebula内置了多种动态等待、重试机制,用例运行稳定性大幅提高。
## 架构介绍
### 技术栈
Java + Spring + Selenide + TestNg + Allure + Maven
- 使用Java语言
- 使用Spring依赖注入特性,JDBC模板等简化代码
- Selenide是一个使用Java语言基于Selenium开发的Web自动化框架,它提供了对html元素的操作接口
- TestNg是Java优秀的单元测试框架,它提供测试用例组织,执行,正确性断言等测试相关功能
- Allure是一个图文并茂的优秀测试报告框架,结合TestNg可直观展示测试用例执行结果及分析。
- 使用Maven进行依赖管理,包管理。
### 项目结构
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070918332.webp)
### 架构图
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070918007.webp)
#### 模块介绍
- actions:对Selenide进行二次封装,是页面元素操作的接口(如点击、键盘输入、下拉列表选择、复选框勾选、获取元素value,动态等待元素出现/消失等)。部分Selenide无法实现的功能直接调用Selenium接口或执行JS代码实现,扩展了Selenide接口功能。
- pagecomponents:使用页面组件模型概念进行设计,不同于PageObjectModel(页面对象模型)将一个页面封装为一个对象的模式,页面组件模型将K3CLOUD系统界面拆分为多种组件,一个页面将由多个组件组合而成(如一个简单的单据页面对象可以由单据头组件对象,单据体组件对象组成)。
- pagemodel:页面模版对页面组件进行组合,封装了典型的页面对象。页面对象为测试用例提供操作特定页面的能力。
- businessflow:将某些常用、固定的业务流程功能进行封装,businessflow允许跨页面操作,是多个pagemodel对象的组合。
- testdata:存放测试所需的Sql语句对象,Excel数据文件等。
- service:用于定义各类服务功能如系统登录。
- dataprovider:定义自定义注解为测试数据提供方法指明测试数据位置;定义测试数据处理方法,解析Excel文档数据,并返回迭代器对象供TestNg调用。
- listener:定义测试执行结果处理监听器,实现TestNg用例重试接口。
- common:定义框架所需Enum类型、异常类型、参数类型,字段类型等。
- utils:定义各种工具类供支撑层、服务层,业务层使用,如日期处理工具类,JDBC连接池工具类,自定义Java工具类等。
- logs:存放测试过程日志。
### 操作浏览器原理
#### Selenide
Nebula基于Selenide框架开发,Selenide是一个由Java语言开发,基于Selenium2.0(又称Selenium WebDriver)的Web自动化框架。Selenide简化了Selenium接口操作,使用更简单的配置,支持Ajax,封装了对异常的处理。代码可维护性,稳定性较Selenium有明显提高。
引用Selenide官网介绍:
> WHAT IS SELENIDE?
> Selenide is a framework for test automation powered by Selenium WebDriver that brings the following advantages:
Concise fluent API for tests Ajax support for stable tests Powerful selectors Simple configuration
You don't need to think how to shutdown browser, handle timeouts and StaleElement Exceptions or search for relevant log lines, debugging your tests.
Just focus on your business logic and let Selenide do the rest
#### Selenium WebDriver
Selenium 2.0,又称 Selenium WebDriver,它利用的原理是:使用浏览器原生的 WebDriver 实现页面操作。
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070919687.webp)
1. 当使用 Selenium2.0 启动浏览器 Web Browser 时,后台会同时启动基于 WebDriver Wire 协议的 Web Service 作为 Selenium 的 Remote Server,并将其与浏览器绑定。绑定完成后,Remote Server 就开始监听 Client 端的操作请求。
2. 执行测试时,测试用例会作为 Client 端,将需要执行的页面操作请求以 Http Request 的方式发送给 Remote Server。该 HTTP Request 的 body,是以 WebDriver Wire 协议规定的 JSON 格式来描述需要浏览器执行的具体操作。
3. Remote Server 接收到请求后,会对请求进行解析,并将解析结果发给 WebDriver,由 WebDriver 实际执行浏览器的操作。
4. WebDriver 可以看做是直接操作浏览器的原生组件(Native Component),所以搭建测试环境时,通常都需要先下载浏览器对应的 WebDriver。
### PageComponentModel vs PageObjectModel
#### PageObjectModel页面对象模型
页面对象模型的核心理念是,以页面(Web Page 或者 Native App Page)为单位来封装页面上的控件以及控件的部分操作。而测试用例,更确切地说是操作函数,基于页面封装对象来完成具体的界面操作。
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070919407.webp)
PageObjectModel页面对象模型适合对每个Web页面相对独立、功能明确的产品进行自动化设计。K3CLOUD每个功能页面以单据形式呈现,系统内包含大量单据(页面),每个单据结构相似,单据内元素操作方式相同,若使用页面对象模型以单据为单位封装页面控件及操作,将存在大量重复代码,同时几百上千个页面对象也极不易使用。
#### PageComponentModel页面组件模型
为解决此痛点,Nebula使用PageComponentModel页面组件模型的方式,将K3CLOUD系统页面根据功能及元素定位的相似关系分割为多个组件,单据(页面)由组件组成,大幅提高代码复用率,典型的调用方式为XXXPageModel.YYYComponent.ZZZOperation()
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070919591.webp)
如“接单目标设定”单据,此单据由一个单页签单据头(标准单据头包含多个页签,单页签单据头内部元素html代码层级与标准单据头不同,无法通用,故单页签单据头单独作为一个组件)组件,一个标准单据体组件组成,其代码组织形式如下。
> HeadOnlyOneTabCompImpl.java
```java
@Component("headOnlyOneTabCompImpl")
public class HeadOnlyOneTabCompImpl extends BaseHeadCompImpl implements IHeadOnlyOneTabComp {
public HeadOnlyOneTabCompImpl(){
super.textField_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'TAB') and @class='k-content k-state-active']//span[translate(text(), ' ', '') = '%s']/parent::div/following-sibling::div[1]/descendant::input[1]";
super.baseField_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'TAB') and @class='k-content k-state-active']//span[translate(text(), ' ', '') = '%s']/parent::div/following-sibling::div[1]/descendant::input[1]";
super.selectField_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'TAB') and @class='k-content k-state-active']//span[translate(text(), ' ', '') = '%s']/parent::div/following-sibling::div[1]";
super.isSelectField_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'TAB') and @class = 'k-content k-state-active']//span[translate(text(), ' ', '') = '%s']/parent::div/following-sibling::div[1]//span[@class = 'k-icon k-i-arrow-s']";
super.isBaseField_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'TAB') and @class = 'k-content k-state-active']//span[translate(text(), ' ', '') = '%s']/parent::div/following-sibling::div[1]//span[@class = 'k-icon k-i-search']";
super.textValueInput_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'TAB') and @class='k-content k-state-active']//span[translate(text(), ' ', '') = '%s']/parent::div/following-sibling::div[1]/descendant::span[@class = 'ui-poplistedit-displayname']";
super.baseValue_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'TAB') and @class = 'k-content k-state-active']//span[@role='kdspanlabel' and @title='%s']/parent::div/following-sibling::div[@class='kdItemContainer_editorct']//span[@class='ui-poplistedit-displayname']";
super.selectValue_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'TAB') and @class = 'k-content k-state-active']//span[@role='kdspanlabel' and translate(@title, ' ', '') = '%s']/parent::div/following-sibling::div[@class='kdItemContainer_editorct']//span[@class='k-input']";
}
@Resource
IActions actions;
@Override
/**
* 单据头文本字段赋值
* */
public void setText(String fieldName, String value) {
actions.clickByJs(String.format(textField_ArgsLoc, fieldName));
actions.input(String.format(textField_ArgsLoc, fieldName), value, false,true);
clickBlank();
}
}
```
**HeadOnlyOneTabCompImpl**类,继承自**BaseHeadCompImpl**,**BaseHeadCompImpl**类中定义了通用单据头元素定位语句,单据头常用操作方法,单页签单据头与通用单据头仅在元素定语句,文本字段赋值方法上存在差异,故**HeadOnlyOneTabCompImpl**类仅需修改基类元素定位语句、重写文本字段赋值方法,其他元素操作方法直接调用基类方法,即可实现单页签单据头所有操作功能。
>UniversalBodyCompImpl.java
```java
@Component("universalBodyCompImpl")
public class UniversalBodyCompImpl extends BaseBodyCompImpl implements IUniversalBodyComp {
String otherBaseField_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'TAB') and @class='k-content k-state-active']//*[@data-field=//th[@data-title='%s']/@data-field and @data-rowid='%d']/ancestor::td/descendant::input[2]";
String checkBoxField_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'TAB') and @class='k-content k-state-active']//*[@data-field=//th[@data-title='%s']/@data-field and @data-rowid='%d']/label";
String textFieldInBodyHead_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'SPLITECONTAINER') and @splitter='two']//div[contains(@id, '-FTAB') and @class='k-content k-state-active']//span[text()= '%s']/../following-sibling::div[1]//span[@class = 'ui-poplistedit-displayname']/preceding-sibling::input";
String baseFieldInBodyHead_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'SPLITECONTAINER') and @splitter='two']//div[contains(@id, '-FTAB') and @class='k-content k-state-active']//span[text()= '%s']/../following-sibling::div[1]//span[@class = 'ui-poplistedit-displayname']/preceding-sibling::input";
String selectFieldInBodyHead_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'SPLITECONTAINER') and @splitter='two']//div[contains(@id, '-FTAB') and @class='k-content k-state-active']//span[text()= '%s']/../following-sibling::div[1]//span[@class = 'k-dropdown-wrap k-state-default']";
String textValueInBodyHead_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'SPLITECONTAINER') and @splitter='two']//div[contains(@id, '-FTAB') and @class='k-content k-state-active']//span[text()= '%s']/../following-sibling::div[1]//span[@class = 'ui-poplistedit-displayname']";
String baseValueInBodyHead_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'SPLITECONTAINER') and @splitter='two']//div[contains(@id, '-FTAB') and @class='k-content k-state-active']//span[text()= '%s']/../following-sibling::div[1]//span[@class = 'ui-poplistedit-displayname']";
String selectValueInBodyHead_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'SPLITECONTAINER') and @splitter='two']//div[contains(@id, '-FTAB') and @class='k-content k-state-active']//span[text()= '%s']/../following-sibling::div[1]//span[@class = 'k-dropdown-wrap k-state-default']/span[1]";
@Resource
IActions actions;
@Override
public void setBaseOther(String fieldName, int rowNum, String value) {
return;
}
@Override
public void checkCheckBox(String fieldName, int rowNum) {
actions.clickByJs(String.format(checkBoxField_ArgsLoc, fieldName, rowNum-1));
}
@Override
public void setTextInBodyHead(String fieldName, String value) {
actions.input(String.format(textFieldInBodyHead_ArgsLoc, fieldName), value,true, true);
clickBlank();
}
@Override
public void setSelectInBodyHead(String fieldName, String value) {
actions.selectByContent(String.format(selectFieldInBodyHead_ArgsLoc, fieldName), String.format(selectItem_ArgsLoc, value));
}
@Override
public void setBaseInBodyHead(String fieldName, String value) {
actions.clickByJs(String.format(baseFieldInBodyHead_ArgsLoc, fieldName));
actions.input(String.format(baseFieldInBodyHead_ArgsLoc, fieldName), value,false, true);
actions.sleepBaby(1);
//actions.click(String.format(baseItem_ArgsLoc, value));
actions.clickByJs(String.format(baseItem_ArgsLoc, value));
}
@Override
public String getTextInBodyHead(String fieldName) {
String value = actions.getValue(String.format(textValueInBodyHead_ArgsLoc, fieldName));
if (value == null){
return actions.getText(String.format(textValueInBodyHead_ArgsLoc, fieldName));
}
return value;
}
@Override
public String getBaseInBodyHead(String fieldName) {
String value = actions.getValue(String.format(baseValueInBodyHead_ArgsLoc, fieldName));
if (value == null){
return actions.getText(String.format(baseValueInBodyHead_ArgsLoc, fieldName));
}
return value;
}
@Override
public String getSelectInBodyHead(String fieldName) {
String value = actions.getValue(String.format(selectValueInBodyHead_ArgsLoc, fieldName));
if (value == null){
return actions.getText(String.format(selectValueInBodyHead_ArgsLoc, fieldName));
}
return value;
}
}
```
与单页签单据头不同**UniversalBodyCompImpl**通用单据体类,对基类**BaseBodyCompImpl**进行了扩展,添加了一些更为少见的单据体元素及相关操作(如勾选复选框,操作位于单据体中以单据头形式展示的字段),代码开头部分为新加入元素的定位语句,后续方法则是这些元素操作方法的具体定义。
>OrderTargetSettingBill.java
```java
package com.luthai.autotest.ui.pagemodel;
import com.luthai.autotest.ui.pagecomponents.IHeadOnlyOneTabComp;
import com.luthai.autotest.ui.pagecomponents.IUniversalBodyComp;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 接单目标设定单据
* */
@Component
public class OrderTargetSettingBill {
@Autowired
public IHeadOnlyOneTabComp billhead;
@Autowired
public IUniversalBodyComp billBody;
}
```
**OrderTargetSettingBill**类作为单据模型,通过组合**IHeadOnlyOneTabComp**接口实现类对象与**IUniversalBodyComp**接口实现类对象,同时具有操作单页签单据头与通用单据体能力,测试用例调用**OrderTargetSettingBill**单据模型对象内部组件的方法,实现操作单据的具体业务功能。
### 等待机制
稳定性是GUI自动化测试是否有效最重要的指标,GUI自动化测试不稳定因素主要有:
- 页面控件属性的细微变化
- 随机页面延迟造成控件识别失败
- 非预计弹出框
- 测试数据问题
其中最频繁发生并较难处理的是“随机页面延迟造成控件识别失败”,以下介绍Nebula解决“随机页面延迟造成控件识别失败”问题使用的几种方法。
#### 动态等待
Selenide自带元素的动态等待定位,根据设置的timeout时间及判断条件,Selenide使用用户给出的元素定位语句(CSS或Xpath形式的元素定位语句,Nebula使用的是Xpath)在timeout时间内,每0.5秒轮询DOM一次,查找元素。
Selenide动态等待
```java
$x(eleLoc).shouldBe(Condition.exist);
```
设置定位超时时间
```java
import com.codeborne.selenide.Configuration;
public BaseTestCase(){
Configuration.timeout = 4000;
}
```
#### 定位等待
定义判断元素是否存在方法**isElementExist()** 使用Selenide动态等待方法shouldBe(),并加入重试机制,进行元素操作时首先使用**isElementExist()** 方法判断元素是否存在,存在则定位元素并进行操作,不存在时抛出ElementNotFoundException异常。
```java
public boolean isElementExist(String eleLoc) {
for (int i = 0; i < 3; i++) {
try {
$x(eleLoc).shouldBe(Condition.exist);
return true;
} catch (Error error) {
if (i < 2) {
log.debug(String.format("未找到元素%s,进行第%d次重试...", eleLoc, i + 1));
}
}
}
return false;
}
```
```java
@Override
/**
* 元素赋值
* 元素定位默认4s动态等待时间并最多重试2次,重试2次仍未定位到元素抛出com.luthai.autotest.ui.common.ElementNotFoundException异常
* @eleLoc: xpath元素定位语句
* */
public void input(String eleLoc, String content) {
if (isElementExist(eleLoc)) {
SelenideElement element = $x(eleLoc);
element.sendKeys(content);
} else throw new ElementNotFoundException(String.format("错误:重试2次仍未找到元素%s", eleLoc));
}
```
#### 参照物等待
“定位等待”是一种被动等待方式,是用户在定位元素时被动的无感知的应用的一种等待方式,参照物等待则是一种主动的等待方式,需要用户主动调用相关等待方法使程序在某一位置暂停执行并轮询判断参照物的状态从而实现动态等待。
如下图的业务场景,批量下推时弹出“正在批量下推”进度窗体,此时代码需等待下推完成后进行下一步操作。此种类型等待时间因数据量而异,可通过判断进度窗体是否消失决定代码是否继续执行。
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070919765.webp)
```java
@Component
public class Wait implements IWaitElementDisplay {
private final static String pushProgress = "//span[@class = 'k-window-title' and text()='正在批量下推']";
@Autowired
IActions actions;
@Override
public void waitPushProgress(int timeoutSecond) {
actions.waitElementDisappear(pushProgress, timeoutSecond);
actions.sleepBaby(3);
}
}
```
**Wait**类封装各种参照物等待方法,**String pushProgress**定义批量下推进度窗体的定位语句,**waitPushProgress()** 方法接收一个timeout时间作为参数,超时时间内每0.5秒轮询一次判断元素是否消失,通常参照物等待超时时间应设置为比根据经验所得业务执行时间略大。
#### Ajax请求等待
点击按钮,加载数据此类操作时,页面会向服务器发送Ajax请求,此时页面会出现一个加载图片占据html最顶层,此时对元素的任何操作都无法进行,Ajax请求结束后(收到响应)加载图片消失,元素恢复可操作状态。
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070918210.webp)
Nebula通过以下方式处理Ajax请求等待:
1. 登录系统后在系统主界面顶部添加ajax状态提示栏,展示ajax请求状态及数量
2. 登录系统后执行JS代码,监听ajax请求状态,并将状态及数量信息显示在提示栏
3. 定义wait_ajax()方法,ajax状态满足一定条件时跳出等待,否则继续等待。
4. 在点击类操作方法中调用wait_ajax()方法,进行Ajax请求等待。
**1. 登录系统后在系统主界面顶部添加ajax状态提示栏,展示ajax请求状态及数量**
```java
public void addAjaxMonitor() {
String add_screen_jq = "$(document).ready(function(){$(\"div[id$='-FSEARCH']\").before(\"\");})";
executeJs(add_screen_jq);
}
```
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070918635.webp)
**2. 登录系统后执行JS代码,监听ajax请求状态,并将状态及数量信息显示在提示栏**
```java
@Override
public void addAjaxMonitor() {
String add_screen_jq = "$(document).ready(function(){$(\"div[id$='-FSEARCH']\").before(\"\");})";
executeJs(add_screen_jq);
String jq = "var ajax_count = 0;\n" +
" $(document).ready(function(){\n" +
"\n" +
" \t\t\t$(document).ajaxStart(function(){\n" +
" \t\t\t\t$(\"#AT_state_v\").html(\"ajaxStart!\")\n" +
" \t\t\t\tconsole.log(\"strat!\");\n" +
" \t\t\t\tajax_count +=1;\n" +
" \t\t\t\t$(\"#AT_count_v\").html(ajax_count)\n" +
" \t\t\t});\n" +
" \t\t\t$(document).ajaxStop(function(){\n" +
" \t\t\t $(\"#AT_state_v\").html(\"ajaxStop!\")\n" +
" \t\t\t console.log(\"stop!\");\n" +
"\n" +
" \t\t\t});\n" +
" \t\t\t})";
executeJs(jq);
}
```
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070918016.webp)
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070918255.webp)
**3. 定义wait_ajax()方法,ajax状态满足一定条件时跳出等待,否则继续等待。**
```java
public void wait_ajax() {
int count = 0;
while (count < 3){
String stateValue = getText("//div[@id = 'AT_state_v']");
int countValue = Integer.parseInt(getText("//div[@id = 'AT_count_v']"));
if (stateValue.equals("ajaxStart!")){
count = 0;
sleepBaby(0.5);
continue;
}
sleepBaby(0.5);
if(getText("//div[@id = 'AT_state_v']").equals("ajaxStart!") || countValue != Integer.parseInt(getText("//div[@id = 'AT_count_v']"))){
count = 0;
continue;
}
count++;
}
}
```
获取ajax状态提示栏的状态和数量信息,每0.5秒轮询一次,如果是“ajaxStart!”状态则继续等待,当状态为“ajaxStop!”时等待0.5秒重新获取ajax状态提示栏的状态和数量信息,若连续3次0.5秒内没有新的ajax请求产生则停止等待。
**4. 在点击类操作方法中调用wait_ajax()方法,进行Ajax请求等待。**
```java
@Override
/**
* 元素点击方法
* 元素定位默认4s动态等待时间并最多重试2次(最多等待4*(1+2)=12s),重试2次仍未定位到元素抛出com.luthai.autotest.ui.common.ElementNotFoundException异常
* @eleLoc: xpath元素定位语句
* */
public void click(String eleLoc) {
if (isElementExist(eleLoc)) {
SelenideElement element = $x(eleLoc);
element.click();
wait_ajax();
} else throw new ElementNotFoundException(String.format("错误:重试2次仍未找到元素%s", eleLoc));
}
```
### 重试机制
重试机制是提高测试稳定性、准确性的最后保障,其主要作用是在定位、操作,用例执行异常时发起重试,降低因系统卡顿,网络波动等异常情况对测试稳定性、准确性带来的影响。Nebula重试机制根据其应用位置主要分为定位重试、操作重试、用例重试。
#### 定位重试
定位重试是指在元素定位时若未定位到元素则重新进行定位直到到达重试次数上限(当前设置为重试2次)
```java
public boolean isElementExist(String eleLoc) {
for (int i = 0; i < 3; i++) {
try {
$x(eleLoc).shouldBe(Condition.exist);
return true;
} catch (Error error) {
if (i < 2) {
log.debug(String.format("未找到元素%s,进行第%d次重试...", eleLoc, i + 1));
}
}
}
return false;
}
```
注:定位重试与定位等待是在一起实现的,功能角度不同。
#### 操作重试
操作重试主要应对操作级别的异常情况,如点击下拉列表未弹出选项值,基础资料未弹出备选数据,当无法定位到下一步待操作元素时重新执行整个操作过程。
```java
public void selectByContent(String arrowEleLoc, String itemLoc) {
for (int i = 0; i < 3; i++) {
try {
SelenideElement element = $x(arrowEleLoc).shouldBe(Condition.exist);
element.click();
sleepBaby(1); //等待下拉列表弹出,为提高稳定性
SelenideElement itemElement = $x(itemLoc).shouldBe(Condition.exist);
itemElement.click();
return;
} catch (Error error) {
if (i < 2) {
log.info(String.format("未找到元素%s,进行第%d次重试...", itemLoc, i + 1));
}
}
}
throw new ElementNotFoundException(String.format("错误:重试2次仍未找到元素%s", itemLoc));
}
```
#### 用例重试
有时测试用例运行失败并不代表产品功能存在缺陷,可能由于执行用例时环境的某些异常情况导致,加入用例重试机制对执行失败的用例连续几次重试,确保都执行失败是真正意义上的产品缺陷,对提高测试准确性和可信度具有较大意义。
##### 重试分析器
TestNg提供**org.testng.IRetryAnalyzer**接口用于指示失败的用例是否需要重试,实现该接口,重写**public boolean retry(ITestResult result)** 接口方法,指定一个计数器控制测试用例重试次数。
```java
package com.luthai.autotest.ui.listener;
import org.testng.IRetryAnalyzer;
import org.testng.ITestResult;
public class Retry implements IRetryAnalyzer {
private int retryCount = 0;
private int maxRetryCount = 3; // retry a failed test 2 additional times
@Override
public boolean retry(ITestResult result) {
if (retryCount <maxRetryCount) {
retryCount++;
return true;
}
return false;
}
}
```
##### 使用@Test(retryAnalyzer = Retry.class)指定重试分析器
只需在测试用例的测试方法注解@Test中简单指定**retryAnalyzer**属性值即可将重试应用到测试方法上。
```java
@Test(retryAnalyzer = Retry.class)
```
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070919359.webp)
##### 在运行时指定retryAnalyzer
TestNg提供**IAnnotationTransformer**监听器接口,实现此接口并在testng.xml配置文件中将其注册为监听器,即可再测试运行期间动态为每个测试方法添加重试分析器
```java
package com.luthai.autotest.ui.listener;
import org.testng.IAnnotationTransformer;
import org.testng.annotations.ITestAnnotation;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
public class RetryListener implements IAnnotationTransformer {
@Override
public void transform(ITestAnnotation annotation, Class testClass,
Constructor testConstructor, Method testMethod) {
Class<?> retry = annotation.getRetryAnalyzerClass();
if (retry == null) {
annotation.setRetryAnalyzer(Retry.class);
}
}
}
```
此简单实现用于在测试运行期间动态设置@Test注解的retryAnalyzer属性
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="platform-checkself">
<listeners>
<listener class-name="com.luthai.autotest.ui.listener.CaseListener" />
<listener class-name="com.luthai.autotest.ui.listener.RetryListener" />
</listeners>
<test name="checkself" enabled="true">
<classes>
<class name="com.luthai.autotest.ui.testcase.TestCaseSimple"/>
</classes>
</test>
</suite>
```
在testng.xml配置文件中将IAnnotationTransformer实现类配置为监听器
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070919763.webp)
使用此种方式,无需再为每个@Test注解设置retryAnalyzer重试分析器
### 元素定位语句
K3CLOUD中的元素id属性都为动态生成,无name属性,class属性无法精确定位,所以无法直接使用元素属性进行定位。Nebula使用XPath,以“国家.省.市.区.人”的形式进行逐级定位,并同时考虑元素状态、相关属性模糊匹配,进行消除空格等处理,确保元素定位语句在界面结构不进行大规模变动的情况下能够长期稳定有效。
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070919039.webp)
```html
ajax状态:
ajax请求结束!
count:
0
ajax状态:
ajax请求结束!
count:
0
```
如上图元素id属性前半部分为动态生成。
拆解单据头组件中基础资料字段定位语句为例
```java
protected String baseField_ArgsLoc = "//div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']//div[contains(@id, 'SPLITECONTAINER') and @splitter='first']//div[contains(@id, 'TAB') and @class = 'k-content k-state-active']//span[translate(text(), ' ', '') = '%s']/parent::div/following-sibling::div[1]/descendant::input[1]";
```
> //div[contains(@id, '-FMAINTAB_c-') and @class='k-content k-state-active']
xpath第一段定位整个单据窗体条件为活动状态,防止同时打开多个单据时误中其他单据的同名元素
>//div[contains(@id, 'SPLITECONTAINER') and @splitter='first']
xpath第二段定位单据的上半部分,屏蔽单据体字段元素
>//div[contains(@id, 'TAB') and @class = 'k-content k-state-active']
xpath第三段定位单据头中的活动页签,屏蔽其他页签字段元素
>//span[translate(text(), ' ', '') = '%s']
xpath第四段去空格后匹配输入框前字段名
>/parent::div/following-sibling::div[1]/descendant::input[1]
xpath第五段根据目标元素具体类型确定最终位置
### 执行JS
Selenide、Selenium均开放了执行JS代码接口,Nebula亦对此接口进行了二次封装。
直接执行JS可对浏览器进行控制,实现某些Selenide框架/Selenium框架未能实现的功能或自定义功能,如Ajax等待中监控Ajax请求是通过向浏览器注入JS的方式实现。
```java
@Override
/**
* 执行不带参数JS代码
* */
public void executeJs(String jsCode) {
Selenide.executeJavaScript(jsCode);
}
@Override
/**
* 执行带参数JS代码
* */
public void executeJs(String jsCode, Object... arguments) {
Selenide.executeJavaScript(jsCode, arguments);
}
```
#### JS操作WebElement元素
Selenide提供**WebDriverRunner.getWebDriver()** 方法获取浏览器webDriver对象,通过webDriver对象可调用selenium原生方法获取JS可操作的WebElement对象。
在某些情况下Selenide元素点击方法(甚至Selenium原生点击方法)无效,此时可使用JS点击的方式进行元素点击操作。
```java
public void clickByJs(String eleLoc) {
if (isElementExist(eleLoc)) {
WebElement element = WebDriverRunner.getWebDriver().findElement(By.xpath(eleLoc));
String jsCode = "arguments[0].click();";
Selenide.executeJavaScript(jsCode, element);
wait_ajax();
} else throw new ElementNotFoundException(String.format("错误:重试2次仍未找到元素%s", eleLoc));
}
```
### 赋值元素自动识别
Nebula提供多种元素赋值方式
```java
public interface IBaseHeadComp {
void setText(String fieldName, String value);
void setBase(String filedName, String value);
void selectByContent(String fieldName, String itemValue);
void setValue(String fieldName, FieldType fieldType, String... value);
void setValueUniversal(String fieldName, String... value);
...
```
- setText()、setBase()、selectByContent()、setValue()都是指定元素类型进行赋值,易用性较差但性能最好。
- setValueUniversal()方法调用parseFieldType()根据各字段特性自动解析字段类型,使用时无需指定字段类型,易用性较好,但性能较差(因需考虑元素加载情况每种字段类型在判断时都要经历秒级等待)
```java
@Override
/*
* 单据头字段类型解析,解析每种字段类型时默认有0.5s动态等待,下拉列表,多选下拉列表即时返回,基础资料字段0.5s返回,大文本1s返回,文本字段需1.5s返回
* 文本类型字段表现形式多样不好做通用性判断,只得放在if最后,单取文本字段类型值时建议使用getValue()方法。
* */
public FieldType parseFieldType(String fieldName) {
if(fieldName == "维度选择"){
return FieldType.MULTISELECT;
}
if(actions.isElementExistEager(String.format(isSelectField_ArgsLoc, fieldName))){
return FieldType.SELECT;
}
if(actions.isElementExistEager(String.format(isBaseField_ArgsLoc, fieldName))){
return FieldType.BASE;
}
if(actions.isElementExistEager(String.format(isTextAreaField_ArgsLoc, fieldName))){
return FieldType.TEXTAREA;
}
return FieldType.TEXT;
}
```
## 用例设计
### TestNg单元测试框架
TestNg使用@BeforeClass、@AfterClass、@BeforeMethod、@AfterMethod、@Test()等注解控制测试用例中测试方法的执行顺序。
@BeforeClass、@AfterClass分别在此类的测试执行开始前、结束后执行,一般用于测试环境准备,测试数据初始化,测试场景恢复,删除测试数据。
@BeforeMethod、@AfterMethod则是在每个测试方法执行前、结束后执行,测试方法独立与其他测试无关联时使用,为测试进行测试初始化及场景恢复。
```java
package com.luthai.autotest.ui.testcase;
import org.testng.annotations.*;
public class TestCaseSimple extends BaseTestCase{
@BeforeClass
public void envInit(){
System.out.println("执行测试环境初始化,测试数据准备等工作");
}
@AfterClass
public void envReset(){
System.out.println("执行测试环境还原及数据清理工作");
}
@BeforeMethod
public void preTest(){
System.out.println("在每个测试方法前执行");
}
@AfterMethod
public void afterTest(){
System.out.println("在每个测试方法执行后下个测试方法执行前执行");
}
@Test()
public void test01(){
System.out.println("run test01()");
}
@Test()
public void test02(){
System.out.println("run test02()");
}
}
```
```java
执行测试环境初始化,测试数据准备等工作
在每个测试方法前执行
run test01()
在每个测试方法执行后下个测试方法执行前执行
在每个测试方法前执行
run test02()
在每个测试方法执行后下个测试方法执行前执行
执行测试环境还原及数据清理工作
===============================================
Default Suite
Total tests run: 2, Passes: 2, Failures: 0, Skips: 0
===============================================
```
### 用例组织
使用testng.xml配置文件对测试用例进行组织,可指定哪些测试用例class文件属于哪个test分组,测试分组是否执行,是否按配置顺序执行等。也可指定测试用例class文件内部哪些测试方法参与执行,哪些不参与执行。
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="platform-checkself" preserve-order="true">
<listeners>
<listener class-name="com.luthai.autotest.ui.listener.CaseListener" />
<listener class-name="com.luthai.autotest.ui.listener.RetryListener" />
</listeners>
<test name="testsimple" enabled="true">
<classes>
<class name="com.luthai.autotest.ui.testcase.TestCaseSimple">
<methods>
<include name="test01" />
<exclude name="test02" />
</methods>
</class>
</classes>
</test>
</suite>
```
### 用例重试
同重试机制>[用例重试](#jump)
### 用例参数化DataProvider
TestNg使用@Test注解中的dataProvider属性指明测试方法中使用的数据提供者方法。@DataProvider用以声明当前方法是一个数据提供者方法,该方法应返回一个Object[][]或Iterator<Object[]>对象。
#### 使用示例
```java
@Step("创建接单目标设定单据")
@Test(dataProvider = "testData", testName = "接单目标设定单据模型自检", description = "接单目标设定单据头单据体赋值按钮点击测试", retryAnalyzer = Retry.class)
@TestDataParams(filePath = DATA_FILE_PATH, sheetName = "test01")
public void test01(Map<String, String> data){
homePage.home.changeOrg("100.1");
homePage.home.searchBill("接单目标设定");
orderTargetSettingBill.billhead.setValueUniversal("销售部门", data.get("销售部门"));
orderTargetSettingBill.billhead.setValueUniversal("年度", data.get("年度"));
orderTargetSettingBill.billhead.setValueUniversal("月份", data.get("月份"));
orderTargetSettingBill.billhead.setValueUniversal("订单类型",data.get("订单类型"));
orderTargetSettingBill.billhead.setValueUniversal("接单量", data.get("接单量"));
orderTargetSettingBill.billBody.setValueUniversal("生产车间", 1, data.get("b1生产车间"));
orderTargetSettingBill.billBody.setValueUniversal("数量", 1, data.get("b1数量"));
orderTargetSettingBill.billhead.clickButton("保存");
}
```
1. 使用@Test(dataProvider = "testData")指定数据提供者方法
2. 使用Nebula自定义注解@TestDataParams指定测试数据Excel地址及页签名称
3. 被@Test修饰的测试方法使用Map<String, String>对象作为参数
4. 测试方法内部使用data.get("参数名")形式调用
#### 实现方式
**定义@TestDataParams注解,用于传递测试数据路径信息**
```java
package com.luthai.autotest.ui.dataprovider;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TestDataParams {
public String filePath() default "";
public String sheetName() default "";
}
```
**测试用例基类中定义数据提供者方法**
使用@DataProvider(name="testData")注解修饰,通过动态获取测试方法的@TestDataParams自定义注解,获取测试数据文件路径
```java
@ContextConfiguration("classpath*:application-context.xml")
public class BaseTestCase extends AbstractTestNGSpringContextTests {
@DataProvider(name="testData")
public static Iterator<Object[]> data(Method method) throws IOException
{
TestDataParams testDataParams = method.getAnnotation(TestDataParams.class);
return new ExcelDataProvider(testDataParams.filePath(),testDataParams.sheetName(),"");
}
}
```
**定义实际数据处理方法**
该方法返回被@DataProvider注解修饰的数据提供者方法所需的Iterator<Object[]>对象。
```java
public class ExcelDataProvider implements Iterator<Object[]> {
private Workbook book = null;
private Sheet sheet = null;
//some other...
public ExcelDataProvider(String excelFile, String sheetName,
String groupName) throws IOException {...}
private Map<String, String> GetRowData(Row tmpRow) {...}
public boolean hasNext() {...}
public Object[] next() {...}
public void remove() {...}
@Override
public void forEachRemaining(Consumer<? super Object[]> action) {...}
}
```
#### 数据驱动
> 相同的测试脚本使用不同的测试数据来执行,测试数据和测试行为进行了完全的分离,这样的测试脚本设计模式叫做数据驱动。
通俗理解数据驱动就是同一测试方法使用不同测试数据进行测试,测试数据的行数决定测试用例执行的次数。
TestNg的@Test测试方法与@DataProvider数据提供者方法配合,默认具备数据驱动能力。
如下具有多行用户账户信息的测试数据
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070919389.webp)
测试执行时根据测试数据行数对同一个测试方法使用不同的参数执行了多次。
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070918131.webp)
### 操作数据库
Nebula使用Druid数据库连接池与Spring JdbcTemplate简化数据库操作
```java
@BeforeClass
public void createTestData() throws InterruptedException {
JdbcTemplate jt = new JdbcTemplate(JDBCUtils.getDataSource());
//生成委外订单数据
jt.batchUpdate(CreateAndDeleteSubBill.createSubSqls);
...
//修改委外用料清单发料方式
jt.update(CreateAndDeleteSubBill.updateIssueType);
}
```
## 测试报告
Nebula使用Allure展示测试报告,Allure支持自定义缺陷分类,缺陷级别定义,用户故事定义,失败用例截图等。
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070918947.webp)
### 错误分类
categories.json
```json
[
{
"name": "Ignored tests",
"matchedStatuses": ["skipped"]
},
{
"name": "Infrastructure problems",
"matchedStatuses": ["broken", "failed"],
"messageRegex": ".*bye-bye.*"
},
{
"name": "Outdated tests",
"matchedStatuses": ["broken"],
"traceRegex": ".*FileNotFoundException.*"
},
{
"name": "Product defects",
"matchedStatuses": ["failed"]
},
{
"name": "Test defects",
"matchedStatuses": ["broken"]
}
]
```
allure通过匹配测试执行状态,输出内容依据配置文件确定缺陷类型,通常**Prodrct defects** 定义产品功能缺陷,**Test defects** 定义测试设计缺陷,实际应用中**Test defects** 缺陷越少代表测试越稳定,可信度越高。
### 失败用例截图
![](https://gitee.com/TallGiraffe/tall-giraffe-pic/raw/master//pic/202112070918209.webp)
## 简单示例
使用Nebula框架编写一个测试用例测试委外订单审核时生成委外用料清单功能是否正常。
```java
package com.luthai.autotest.ui.testcase;
import com.luthai.autotest.ui.businessflow.AutotestFlow;
import com.luthai.autotest.ui.dataprovider.TestDataParams;
import com.luthai.autotest.ui.pagemodel.HomePage;
import com.luthai.autotest.ui.pagemodel.UniversalBill;
import com.luthai.autotest.ui.service.ILoginService;
import com.luthai.autotest.ui.testdata.CreateAndDeleteSubBill;
import com.luthai.autotest.ui.utils.JDBCUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testng.Assert;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import java.util.Map;
public class TestExample extends BaseTestCase{
private final static String DATA_FILE_PATH = "C:\\Users\\LPDU\\Desktop\\222.xlsx";
@Autowired
ILoginService loginService;
@Autowired
HomePage homePage;
@Autowired
UniversalBill universalBill;
@Autowired
AutotestFlow atFlow;
/**
* 测试开始前通过执行SQL生成委外订单数据
* */
@BeforeClass
public void createTestBill(){
JdbcTemplate jt = new JdbcTemplate(JDBCUtils.getDataSource());
//生成委外订单数据
jt.batchUpdate(CreateAndDeleteSubBill.createSubSqls);
}
/**
* 测试结束后执行SQL删除委外订单数据,及生成的委外用料清单数据
* */
@AfterClass
public void deleteTestBill(){
JdbcTemplate jt = new JdbcTemplate(JDBCUtils.getDataSource());
jt.batchUpdate(CreateAndDeleteSubBill.deletePPBomSqls);
jt.batchUpdate(CreateAndDeleteSubBill.deleteSubBillSqls);
}
@Test(dataProvider = "testData", testName = "委外订单保存提交审核")
@TestDataParams(filePath = DATA_FILE_PATH, sheetName = "testExa")
public void test01(Map<String, String> data){
loginService.login();
homePage.home.searchBill("委外订单列表");
universalBill.list.clickButton("过滤");
universalBill.filter.clickToolBarBtn("全部删除");
universalBill.filter.checkAllOrg();
universalBill.filter.setCondition(1, "衬衣订单号");
universalBill.filter.setValueUniversal(1, data.get("衬衣订单号"));
universalBill.filter.clickOkButton();
universalBill.list.openBillFromList(1);
atFlow.saveSubmitAudit();
homePage.home.closeAllBill();
}
@Test(dataProvider = "testData", testName = "委外用料清单行数检查", description = "检查委外订单审核生成的委外用料清单行数是否为198行")
@TestDataParams(filePath = DATA_FILE_PATH, sheetName = "testExa")
public void test02(Map<String, String> data){
homePage.home.searchBill("委外用料清单列表");
universalBill.list.clickButton("过滤");
universalBill.filter.clickToolBarBtn("全部删除");
universalBill.filter.checkAllOrg();
universalBill.filter.setCondition(1, "衬衣订单号");
universalBill.filter.setValueUniversal(1, data.get("衬衣订单号"));
universalBill.filter.clickOkButton();
int rowCount = universalBill.list.getRowCount();
Assert.assertEquals(rowCount, 198, "生成的委外用料清单行数与预期行数不符,请检查!");
}
}
```
开源吗?
金蝶云星空HTML5 WebUI自动化测试框架分享
分享一个针对金蝶云星空做的WebUI自动化测试项目,只支持HTML5界面,希望抛砖引玉获得一些金蝶云星空WebUI自动化测试或CI/CD建设方面的讨论...
点击下载文档
本文2024-09-16 17:19:49发表“云星空知识”栏目。
本文链接:https://wenku.my7c.com/article/kingdee-k3cloud-15002.html
您需要登录后才可以发表评论, 登录登录 或者 注册
最新文档
热门文章