当今,“自动化测试”大行其道,其中“接口自动化测试”便是同行们谈得最多的话题之一。了解测试金字塔分层理念的童鞋都清楚,接口自动化测试有以下优点。
正因为以上优点,接口自动化测试逐渐成为了业界主流,各种工具/框架层出不穷,比如Postman,Jmeter,Htttpclient,Rest assured,HttpRunnerManager等。
二、背景
此前笔者曾基于Jenkins+Ant+Git+Jmeter搭建过一套接口自动化框架,期间亦针对Jmeter做了许多功能的扩展,比如:生成excle结果文件、数据库断言、自动提交缺陷、自动更新案例执行结果至Testlink等。虽说Jmeter简单易上手,但在笔者看来,其并不是接口自动化测试的首选,其中的原因暂不祥谈,毕竟仁者见仁。
近段时间,笔者一直在思索,学习前辈们优秀的经验,并从公司项目架构出发,搭建了一套基于Jenkins+Maven+Git+TestNG+RestAssured+Allure的持续集成测试框架,相比原先Jmeter的那套,其易用性更好、效率更高、扩展性更强。
用例文件名称与测试类名一致,比如开户的测试类名为OpenAcc,则用例文件名为OpenAcc.xls,用例模板由以下几部分组成。
(1)自定义函数供数。引用格式为:__phone()表示生成11位手机号。__idno()表示生成18位身份证号。
(2)查询数据池供数。引用格式为:${dp.sql(select accountNo from M_account where status = 1)}
(3)查询数据库供数。引用格式为:${db.sql(select accountNo from M_account_card where status = 1)}
(4)先接口请求,然后提取响应报文供数。引用格式为:KaTeX parse error: Expected '}', got 'EOF' at end of input: …g.case023.post(.data.code)},表示先以post方式发送SendmsgYg接口请求,然后再提取响应报文的code字段。支持接口之间的多重依赖。
(5)先接口请求,然后查询数据库/池供数。引用格式为:${SendmsgYg.case023.post.db.sql(select accountNo from M_account_card where status = 1)},表示先以post方式发送SendmsgYg接口请求,然后再查询数据库(db)/数据池(dp)获取数据。
数据库断言文件名称与测试类名一致,比如开户的测试类名为OpenAcc,则断言文件为OpenAcc.xml。一个接口对应一个数据库断言文件,一个断言文件里可包含多条案例,每条案例可以断言多张表,模板如下。
ps:为了提升效率,后续亦会提供一个生成数据库断言文件小工具。
<?xml version="1.0" encoding="utf-8"?>
<dbCheck dbCheck_name="开户绑卡数据库检查点">
<caseNo case_no="case085"> <!--案例编号-->
<table table_name="M_ACCOUNT"> <!--表名-->
<priKey key_name="ACCOUNT_NO">ACCOUNT_NO</priKey> <!--主键-->
<column column_name="CUST_ID">CUST_ID</column> <!--其他字段的预期结果-->
<column column_name="MERCHANT_ID">MERCHANT_ID</column> <!--其他字段的预期结果-->
<column column_name="ACCOUNT_STATUS">1</column> <!--其他字段的预期结果-->
<column column_name="ORGAN_NO">0019901</column> <!--其他字段的预期结果-->
</table>
</caseNo>
<caseNo case_no="case086">
<table table_name="M_ACCOUNT_CARD">
<priKey key_name="ACCOUNT_NO">ACCOUNT_NO</priKey>
<priKey key_name="CARD_NO">CARD_NO</priKey>
<column column_name="CARD_TYPE">2</column>
<column column_name="MERCHANT_ID">MERCHANT_ID</column>
<column column_name="CUST_ID">CUST_ID</column>
<column column_name="CARD_IMG">CARD_IMG</column>
<column column_name="OPEN_BANKNAME">NOTNULL</column>
</table>
</caseNo>
</dbCheck>
对于未确定的预期结果,使用变量代替,后续编写测试类时再做映射。
测试类分为两大类,一是只需响应报文断言,二是需要响应报文及数据库断言。测试人员按照以下模板编写脚本即可。
/*
*短信发送接口
* 环境参数在SetUpTearDown 父类定义
*/
@Feature("分类账户改造")
public class SendmsgYg extends SetUpTearDown {
@Story("发送短信")
@Test(dataProvider = "dataprovider",
dataProviderClass = DataProviders.class,
description = "发送短信")
public void runCase(String caseMess, String bodyString) throws IOException, SQLException, ClassNotFoundException {
//发送请求
Response response = RunCaseJson.runCase(bodyString, "post");
//只进行响应报文断言
asserts(caseMess, bodyString, response.asString(),"",null);
}
}
数据库断言文件中的变量,可通过调用封装的方法取值,比如查数据库、提取响应报文、调用接口等方式。
/*
*开立分类账户
* 环境参数在SetUpTearDown 父类定义
*/
@Feature("分类账户改造")
public class OpenYg extends SetUpTearDown {
@Story("分类账户开户")
@Test(dataProvider = "dataprovider",
dataProviderClass = DataProviders.class,
description = "开户")
public void runCase(String caseMess, String bodyString) throws IOException, SQLException, ClassNotFoundException {
//发送请求
Response response = RunCaseJson.runCase(bodyString, "post");
//如果需要数据库断言,此处添加断言文件变量的map映射
//可通过调用封装的方法取值,比如查数据库、提取响应报文、调用接口等方式。
Map<String, String> map = new HashMap<>();
//查询数据库获取,取不到值返回""
String account = DataBaseCRUD.selectData("select accountNo from M_ACCOUNT where status =1");
//提取响应报文,取不到值返回""
String custId = RespondAssertForJson.getBuildValue(response.asString(),"$.data.custid");
//执行SendmsgYg接口的case023案例,然后提取响应报文的merchanId ,取不到值返回""
String merchanId = RespondAssertForJson.getBuildValue("","${SendmsgYg.case023.post($.data.merchanId)}");
map.put("ACCOUNT_NO",account);
map.put("CUST_ID",custId);
map.put("MERCHANT_ID",merchanId);
//断言(包含响应报文断言和数据库断言)
String xmlFileName = this.getClass().getSimpleName(); //数据库断言xml文件名(与类名保持一致)
asserts(caseMess, bodyString, response.asString(),xmlFileName,map);
}
}
对于多个suite,可通过suite-files配置。testng.xml文件配置如下。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="IIACCOUNT自动化测试" parallel="classes">
<listeners>
<!--失败重跑-->
<listener class-name="com.iiaccount.listener.FailedRetryListener"/>
</listeners>
<test verbose="2" name="IIACCOUNT_YG">
<classes>
<class name="com.iiaccout.yiguan.OpenYg"/>
<class name="com.iiaccout.yiguan.SendmsgYg"/>
</classes>
</test>
</suite>
选择环境及测试用例集,开始构建,构建完成后生成测试报告及日志。也可根据需要设置定时构建,持续进行质量监控。
在这个注重颜值的世界,allure框架出来的测试报告绝对称得上“报告界的小鲜肉”。
测试报告总览包含用例通过率、测试套件、环境、feature、类别、趋势等信息。以下示例截图的案例全部执行失败,所以总览的通过率是0%。
类别主要展现失败的用例信息,可根据项目情况自定制报告内容,比如请求报文、响应报文、断言结果等。
支持多环境(sit,uat)切换,结合Jenkins使用。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>HFIIACCOUNT</groupId>
<artifactId>ApiAutoTest</artifactId>
<version>1.0-SNAPSHOT</version>
<!--通过“-D”引用变量-->
<properties>
<aspectj.version>1.8.10</aspectj.version>
<!-- 解决mvn编译乱码问题-->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!--外部传参-->
<xmlFileName></xmlFileName>
</properties>
<dependencies>
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>6.11</version>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>ru.yandex.qatools.allure</groupId>
<artifactId>allure-testng-adaptor</artifactId>
<version>1.3.6</version>
<exclusions>
<exclusion>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-testng</artifactId>
<version>2.0-BETA14</version>
</dependency>
<dependency>
<groupId>net.sourceforge.jexcelapi</groupId>
<artifactId>jxl</artifactId>
<version>2.6.12</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.2</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.13</version>
</dependency>
<dependency>
<groupId>com.oracle</groupId>
<artifactId>ojdbc14</artifactId>
<version>10.2.0.4.0</version>
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<filters>
<filter>src/main/filters/filter_${
env}.properties</filter>
</filters>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.20</version>
<configuration>
<argLine>
-javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar"
</argLine>
<!--生成allure-result的目录-->
<systemProperties>
<property>
<name>allure.results.directory</name>
<value>./target/allure-results</value>
</property>
</systemProperties>
</configuration>
<dependencies>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>${
aspectj.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.19</version>
<configuration>
<suiteXmlFiles>
<!--该文件位于工程根目录时,直接填写名字,其它位置要加上路径-->
<!--suiteXmlFile>src/main/resources/testngXml/${
xmlFileName}</suiteXmlFile-->
<suiteXmlFile>${
project.basedir}/target/classes/testngXml/${
xmlFileName}</suiteXmlFile>
</suiteXmlFiles>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>8</source>
<target>8</target>
</configuration>
</plugin>
<!--增加此配置,防止编译后xls文件乱码-->
<!--Maven resources 插件会对文本资源文件进行转码,但是它无法区分文件是否是纯文本文件还是二进制文件.于是二进制文件在部署过程中也就被转码了.-->
<!--https://blog.csdn.net/xdxieshaa/article/details/54906476-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>2.6</version>
<configuration>
<nonFilteredFileExtensions>
<!-- 不对xls进行转码 -->
<nonFilteredFileExtension>xls</nonFilteredFileExtension>
</nonFilteredFileExtensions>
</configuration>
</plugin>
</plugins>
</build>
<!--通过“-P”引用变量-->
<profiles>
<!-- uat测试环境 -->
<profile>
<id>uat</id>
<properties>
<env>uat</env>
</properties>
</profile>
<!-- sit测试环境 -->
<profile>
<id>sit</id>
<properties>
<env>sit</env>
</properties>
<activation>
<activeByDefault>true</activeByDefault><!--默认启用的是sit环境配置-->
</activation>
</profile>
</profiles>
</project>
public static void getMap(Sheet sheet, int cols, int row, String pubArgs){
for (int col = 0; col < cols; col++) {
String cellKey = sheet.getCell(col, 0).getContents();//表头
String cellValue = sheet.getCell(col, row).getContents();//值
if (col >= 5) {
//appid,api,version属于公共入参,公共入参字段在PublicArgs.properties文件进行配置
// getBuildValue(value1,value2)方法用于转换${}或者函数为对应的值
if (pubArgs.toLowerCase().contains(cellKey.toLowerCase().trim())) {
bodyMap.put(cellKey, RespondAssertForJson.getBuildValue("", sheet.getCell(col, row).getContents()));
} else {
dataMap.put(cellKey, RespondAssertForJson.getBuildValue("", sheet.getCell(col, row).getContents()));
}
} else {
caseMessMap.put(cellKey, cellValue);
}
}
bodyMap.put("data", dataMap);
map.put(new Gson().toJson(caseMessMap), new Gson().toJson(bodyMap));
}
/**
* 支持json串转换
* 支持自定义函数的转换
* 支持${}变量转换
*
* @param sourchJson
* @param key
* @return
*/
public static String getBuildValue(String sourchJson, String key) {
key = key.trim();
Matcher funMatch = funPattern.matcher(key);
Matcher replacePattern = replaceParamPattern.matcher(key);
log.info("key is:" + key);
try{
if (key.startsWith("$.")) {
// jsonpath
key = JSONPath.read(sourchJson, key).toString(); //jsonpath读取对应的值
log.info("key start with $.,value is:" + key);
} else if (funMatch.find()) {
//函数
String args = funMatch.group(2); //函数入参
log.info("key is a function,args is:" + args);
String[] argArr = args.split(",");
for (int index = 0; index < argArr.length; index++) {
String arg = argArr[index];
if (arg.startsWith("$.")) {
//函数入参亦支持json格式
argArr[index] = JSONPath.read(sourchJson, arg).toString();
}
}
log.info("argArr:"+argArr.length);
String value = FunctionUtil.getValue(funMatch.group(1), argArr); //函数名不区分大小写,返回函数值
log.info("函数名 funMatch.group(1):" + funMatch.group(1));
key = StringUtil.replaceFirst(key, funMatch.group(), value); //把函数替换为生成的值
log.info("函数 funMatch.group():" + funMatch.group());
log.info("key is a function,value is:" + key);
} else if (replacePattern.find()) {
//${}变量
log.info("${}变量体:"+replacePattern.group(1));
String var = replacePattern.group(1).trim();
String value1 = DataBuilders.dataprovide(var);
key = StringUtil.replaceFirst(key, replacePattern.group(), value1); //把变量替换为生成的值
log.info("key is a ${} pattern,value is:" + key);
}
return key;
}catch(Exception e){
log.info(e.getMessage());
return null;
}
}
/*
*map包含两部分json,key为caseNo等信息,value为接口入参
*/
@DataProvider(name = "dataprovider")
public static Object[][] dataP(Method method) throws IOException, BiffException, URISyntaxException {
String className = method.getDeclaringClass().getSimpleName(); //获取类名
String caseFileName = className+".xls"; //测试案例名称为:类名.xls
Object[][] objects = null;
Map<String,String> map = new HashMap<String, String>();
map = AssembledMessForJson.assembleMess(caseFileName,""); //""表示读取所有的为Y的case
objects = new Object[map.size()][2];
int i=0;
for(Map.Entry<String, String> entry : map.entrySet()){
objects[i][0] = entry.getKey();
objects[i][1] = entry.getValue();
i++;
}
map.clear(); //需清空map,否则案例会不断叠加 2018-10-19 add by lrb
return objects;
}
父类部分方法:
//环境配置
@BeforeClass
public void envSetUp() {
try {
String system = "env.properties"; //环境由filter配置
RestAssured.baseURI = new GetFileMess().getValue("baseURI", system);
RestAssured.basePath = new GetFileMess().getValue("basePath", system);
RestAssured.port = Integer.parseInt(new GetFileMess().getValue("port", system));
} catch (IOException e) {
e.printStackTrace();
}
}
/*
*实现IAnnotationTransformer接口,修改@Test的retryAnalyzer属性
*/
public class FailedRetryListener implements IAnnotationTransformer {
public void transform(ITestAnnotation iTestAnnotation, Class aClass, Constructor constructor, Method method) {
{
IRetryAnalyzer retry = iTestAnnotation.getRetryAnalyzer();
if (retry == null) {
iTestAnnotation.setRetryAnalyzer(FailedRetry.class);
}
}
}
}
/*
*测试报告展现
*/
public class TestStep {
public static void requestAndRespondBody(String URL, String Body,String Respond){
requestBody(URL,Body);
respondBody(Respond);
}
@Attachment("请求报文")
public static String requestBody(String URL, String body) {
//格式化json串
boolean prettyFormat = true; //格式化输出
JSONObject jsonObject = JSONObject.parseObject(body);
String str = JSONObject.toJSONString(jsonObject,prettyFormat);
//报告展现请求报文
return URL+"\n"+str;
}
@Attachment("响应报文")
public static String respondBody(String respond) {
//报告展现响应报文
return respond;
}
@Attachment("数据库断言结果")
public static StringBuffer databaseAssertResult(StringBuffer assertResult){
//报告展现数据库断言结果
return assertResult;
}
@Attachment("响应报文断言结果")
public static StringBuffer assertRespond(StringBuffer assertResult){
//报告展现数据库断言结果
return assertResult;
}
}
log4j.rootLogger=INFO, stdout, D , E
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%-d{
yyyy-MM-dd HH:mm:ss} [ %C.%M(%L) ] - [ %p ] %m%n
# 文件达到指定大小的时候产生一个新的文件
log4j.appender.D=org.apache.log4j.DailyRollingFileAppender
# TODO 部署时,修改为指定路径
log4j.appender.D.File=logs/apiAutoTest_debug.log
log4j.appender.D.Append = true
# 输出DEBUG级别以上的日志
log4j.appender.D.Threshold = DEBUG
log4j.appender.D.layout=org.apache.log4j.PatternLayout
log4j.appender.D.layout.ConversionPattern=%-d{
yyyy-MM-dd HH:mm:ss} [ %C.%M(%L) ] - [ %p ] %m%n
### 保存异常信息到单独文件 ###
log4j.appender.E = org.apache.log4j.DailyRollingFileAppender
## 异常日志文件名
# TODO 部署时,修改为指定路径
log4j.appender.E.File = logs/apiAutoTest_error.log
log4j.appender.E.Append = true
## 只输出ERROR级别以上的日志!!!
log4j.appender.E.Threshold = ERROR
log4j.appender.E.layout = org.apache.log4j.PatternLayout
log4j.appender.E.layout.ConversionPattern = %-d{
yyyy-MM-dd HH:mm:ss} [ %C.%M(%L) ] - [ %p ] %m%n
可在线安装插件或下载到本地安装,下载地址。
新建一个maven项目。
全局工具配置(Jenkins-系统管理-全局工具配置)。
参数化构建过程配置,选择【choice Parameter】,配置的Name需与pom.xml文件的变量名一致,字典根据源码中实际用途定义。此处需注意,profiles定义的属性通过“-P”引用,下文会提及。
源码管理配置,此处根据源码管理工具配置。
build配置,此处通过clean test -P e n v − D x m l F i l e N a m e = {env} -DxmlFileName= env−DxmlFileName={xmlFileName}来把参数传给pom,-P和-D的区别可百度。
配置构建后操作,即测试报告生成路径。
邮件配置操作自行百度。
至此,Jenkins配置工作全部搞掂,接下来构建测试即可。
<!--增加此配置,防止编译后xls文件乱码-->
<!--Maven resources 插件会对文本资源文件进行转码,但是它无法区分文件是否是纯文本文件还是二进制文件.于是二进制文件在部署过程中也就被转码了.-->
<!--https://blog.csdn.net/xdxieshaa/article/details/54906476-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>2.6</version>
<configuration>
<nonFilteredFileExtensions>
<!-- 不对xls进行转码 -->
<nonFilteredFileExtension>xls</nonFilteredFileExtension>
</nonFilteredFileExtensions>
</configuration>
</plugin>
mvn编译后出现乱码。需要在pom文件增加如下配置。
<properties>
<aspectj.version>1.8.10</aspectj.version>
<!-- 解决mvn编译乱码问题-->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
(1)、设置jenkins所在服务器环境变量,添加系统变量。
变量名:JAVA_TOOL_OPTIONS
变量值:-Dfile.encoding=UTF8
(2)、修改Tomcat配置,进入apache_tomcat/conf文件夹下,编辑server.xml,在Connector port="8080"后面加入useBodyEncodingForURI=“true”
(3)、启动tomcat,运行jenkins,进入系统管理→系统设置,在全局属性处勾选Environment variables,添加编码环境变量LANG=zh_CN.UTF-8
master作为稳定的分支,测试人员在dev分支进行脚本编写,执行无误再合并master分支,然后触发Jenkins自动构建。
后续在dev分支进行脚本编写,脚本调试无误后再push到远程的dev分支,然后合并到master分支。
切换到master分支,然后合并dev分支内容,然后再push到远程仓库。
更详细的操作可参考idea中git分支的使用。
(1)先在本机链接外网下载所有的依赖包,然后再拷贝本机用户目录的maven仓库(C:\Users\lenovo.m2)到jenkins服务器对应的用户目录下。
(2)修改maven_home conf目录下的settings.xml文件,增加localRepository。
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<!-- localRepository
| The path to the local repository maven will use to store artifacts.
|
| Default: ${
user.home}/.m2/repository
<localRepository>/path/to/local/repo</localRepository>
-->
<localRepository>C:/Users/wtapp01/.m2/repository</localRepository>
上述框架目前仅局限于测试端,严格意义上来说并不算真正的持续集成,后续再完善以下几点。
https://github.com/Tomandy08/ApiAutoTest
文末分享:这下面有我学习整理出来的自动化测试资料,以及大厂面试(附带答案)待你来领取~ 见公众号:【伤心的辣条】愿你我都有所获…
合理利用自己每一分每一秒的时间来学习提升自己,不要再用"没有时间“来掩饰自己思想上的懒惰!趁年轻,使劲拼,给未来的自己一个交代!
我的测试学习交流群:902061117 群里有技术大牛一起交流分享~
原文不易呀,眼睛都留眼泪了!麻烦伸出发财小手点个赞,感谢您的支持,你的点赞是我持续更新的动力。
包装成1年工作经验的测试工程师,我给他的面试前的建议如下
自动化测试到底要学什么?
为何跳槽不考虑腾讯?聊聊我和鹅厂的一点往事
自动化测试和手动测试哪个更高级?
新手必看:怎么写一个合格的测试用例?
python登录接口测试问题记录与解决 ( 干 货 )