Apifox 持续集成 + Java + 企微机器人 + xxljob 定时推送
—— 感谢 Apifox 用户「七分红酒」的投稿
如何定时执行 Apifox API 接口测试后,自动将报告和数据推送到企业微信群中?
本文主要实现把 Apifox 测试套件,通过 apifox-cli 持续集成执行后,生成 json 数据报告和 html 测报告,本地解析 json 拿到有用的数据,和 html 报告一起,通过定时执行推送到企微群里。
效果见下图:
一、需要的安装
1、安装 Node.js
yum install -y nodejs
2、安装 npm
yum install -y npm
3、安装 Apifox CLI
npm install -g apifox-cli
Apifox CLI 是 Apifox 的命令行运行工具,主要用来做持续集成。支持实时运行在线数据和导出数据运行 2 种方式。因为后续套件可能去增加用例,所以我们选择使用在线数据进行调用。关于 Apifox-cli 的详细介绍可以参考官方文档 —— Apifox CLI 介绍。
二、本地测试运行生成本地报告
1、必要的安装后,可以获取测试用例或套件的执行链接,先本地测试一下。 执行链接的获取方式:测试套件或用例点击详情,点击持续集后 -> 生成命令 -> 然后复制后 -> 在终端命令行直接运行在线数据。 命令类似长这样 :
apifox run http://xxx/api/v1/api-test/ci-config/xxxx/detail?token=xxxx -r html,cli
三、了解 Apifox 执行命令的伴随命令
我只列本次可能需要用到的,其它的可以这里去找更多选项 —— Apifox 执行命令扩展执行。
1、通过-r 指定生成 json 格式或 html 格式。生成 json 格式时,可以生成到本地,获取到后,解析拿到自己想要的数据;生成 html 格式时,可以推送出去。也可以使用 -r json,html,直接生成对应的 json 和 html 文件。
-r, --reporters [reporters] 指定测试报告类型, 支持 cli,html,json (default: ["cli"])
2、通过 --out-dir 指定输出报告的位置,如果不指定,默认会在当前路径下新建apifox-reports 文件夹。 自己也可以指定生成到自己想要的目录下。我的生成思路是把文件指定存到 /tmpapifox-reports,然后每次解析使用后,删除 2 个文件。
--out-dir <outDir> 输出测试报告目录,默认为当前目录下的 ./apifox-reports
3、通过--out-file 可以更改生成的 json文件或 html 文件的名称,也可以不自定义。我本次没有自定义。
--out-file <outFile> 输出测试报告文件名,不需要添加后缀,默认格式为 apifox-report-{当前时间戳}
4、--database-connection 指定你的数据库配置文件访问路径,如果你的测试套件用到的用例中,存在数据库校验;就需要在执行的当前路径配置数据库依赖配置文件。如果没有数据库校验则忽略。下载位置见下图:
--database-connection <path> 指定 [数据库配置] 的所处文件路径,使用 URL 测试的时候必须指定
四、代码部分-1 :终端执行生成报告
1、先找到可以运行终端命令的代码,自己写或找个工具类都可。主要依赖方法是 process = Runtime.getRuntime().exec(command); 这是我在网上找的。懒得找的话,创建个工具类把下边这个方法实现贴进去可以运行。
// command为运行命令,waitTime为命令执行等待时间,单位=秒。
public static String run(String command,int waitTime) throws IOException {
Scanner input = null;
String result = "";
Process process = null;
try {
process = Runtime.getRuntime().exec(command);
try {
// 等待命令执行完成
process.waitFor(waitTime, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
logger.info("CmdUtil- run(String,int)遇到异常1");
}
InputStream is = process.getInputStream();
input = new Scanner(is);
while (input.hasNextLine()) {
result += input.nextLine() + "\n";
}
result = command + "\n" + result; //加上命令本身,打印出来
} catch (Exception e){
e.printStackTrace();
logger.info("CmdUtil- run(String,int)遇到异常2");
}
finally {
System.out.println("---finally---");
if (input != null) {
input.close();
}
if (process != null) {
process.destroy();
}
}
return result;
}
2、通过调用终端类执行,生成对应的 json 报告和 html 报告。通过加指令指定生成的报告格式指定使用的 db.json 位置为 database-connections.json (我提前放过去的),指定生成的路径 -out-dir /tmp/apifox-reports
//生成json和html报告的命令
String apifoxcmd = "apifox run https://api.apifox.cn/api/v1/api-test/ci-config/359896/detail?token=xlH8c24skOlIwGWN08O-d2 -r json,html,cli --database-connection /tmp/database-connections.json --out-dir /tmp/apifox-reports"
//生成报告
CmdUtil.run(apifoxjsoncmd,10);
五、代码部分-2: 到 json 和 html 报告并解析 json 报告
先拿到 json 报告解析报告字段,拿到想要的字段。例如套件名称、总请求数、失败数、失败接口的名称。 字段有很多,想要什么,就自己解析拿到就行。
为了简化拿文件的方式,我是每次新生成报告文件,读取和解析后,就删除文件。这样每次我生成后文件夹下只有 1 个 json 文件和 1 个 html 文件,读完就删除。
//报告文件夹
File reports = new File("/tmp/apifox-reports");
//json报告路径
String jsonReportPath = "";
//html报告路径
String htmlReportPath = "";
//一共就2个文件,没有走循环,直接拿值。不是[0]就是[1]
if(reports.listFiles()[0].getAbsolutePath().contains("html")){
jsonReportPath = reports.listFiles()[1].getAbsolutePath();
htmlReportPath = reports.listFiles()[0].getAbsolutePath();
}else{
jsonReportPath = reports.listFiles()[0].getAbsolutePath();
htmlReportPath = reports.listFiles()[1].getAbsolutePath();
}
//json文件
File jsonReport = new File(jsonReportPath);
//html文件
File htmlReport = new File(htmlReportPath);
logger.info("jsonReportPath = "+ jsonReportPath);
logger.info("htmlReportPath = "+ htmlReportPath);
//读取到文件json内容
String jsonString = new String(Files.readAllBytes(Paths.get(jsonReportPath)));
JSONObject reportObject= JSONObject.parseObject(jsonString);
//测试套件名称
String TJName = reportObject.getJSONObject("collection").getJSONObject("info").getString("name");
logger.info("测试套件名称 = "+ TJName);
//总请求数
String requestTotal = reportObject.getJSONObject("result").getJSONObject("stats").getJSONObject("requests").getString("total");
logger.info("总请求数 = "+ requestTotal);
//总断言数
String assertionsTotal = reportObject.getJSONObject("result").getJSONObject("stats").getJSONObject("assertions").getString("total");
logger.info("总断言总数 = "+ assertionsTotal);
//请求失败数
String requestFailed = reportObject.getJSONObject("result").getJSONObject("stats").getJSONObject("requests").getString("failed");
logger.info("请求失败数 = "+ requestFailed);
//请求成功率
float requestOk = (Float.parseFloat(requestTotal)-Float.parseFloat(requestFailed))/Float.parseFloat(requestTotal);
String requestOkPersent = String.format("%.2f%%",requestOk*100);
logger.info("请求成功率 = "+ requestOkPersent );
//响应平均时间
String responseAverage = reportObject.getJSONObject("result").getJSONObject("timings").getString("responseAverage");
logger.info("响应平均时间 = "+ responseAverage);
//报错用例名列表
List<String> httpApiNames = new ArrayList<>();
JSONArray failures = reportObject.getJSONObject("result").getJSONArray("failures");
for(int i = 0;i<failures.size();i++){
String httpApiName = failures.getJSONObject(i).getJSONObject("source").getJSONObject("metaInfo").getString("httpApiName");
if(!httpApiNames.contains(httpApiName)){
httpApiNames.add(httpApiName);
}
}
//删除json文件
logger.info("执行删除json文件 ----"+jsonReportPath + "---" + jsonReport.delete());
六、代码部分-3 :通过企微机器人把 json 解析内容发到群里
//消息推送
String content = "Apifox测试套件《"+TJName+"》执行报告\n ";
content = content.concat("总接口数【"+requestTotal+"】 \n ");
content = content.concat("接口总断言数【"+assertionsTotal+"】 \n");
content = content.concat("接口请求失败数【"+requestFailed+"】 \n");
content = content.concat("接口请求成功率【"+requestOkPersent+"】 \n");
if(httpApiNames.size()>0){
logger.info("本次执行所有报错的数据 = "+ httpApiNames);
content = content.concat("接口失败名明细【"+httpApiNames.toString()+"】 \n");
}else{
logger.info("本次执行无报错");
}
String now = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss").format(new Date());
content = content.concat("【"+now+"】 \n");
Map<String,Object> markdownMap = new HashMap<>();
markdownMap.put("content",content);
Map<String,Object> text = new HashMap<>();
text.put("content",content);
//如果有报错
if(Integer.parseInt(requestFailed)>0){
ArrayList<String> mentioned_list = new ArrayList<>();
// mentioned_list.add("需要提醒的某个人");
// mentioned_list.add("@all"); //需要提醒所有人
text.put("mentioned_list",mentioned_list);
}
Map<String,Object> r_params = new HashMap<>();
r_params.put("msgtype","text");
r_params.put("text",text);
Map<String,String> headers = new HashMap<>();
headers.put("Content-Type", "application/json");
ResponseObj responseObj = TCHttpUtil.sendPostWithObjMap("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx",headers,r_params);
System.out.println("机器人响应结果:"+ responseObj.getResult());
七、代码部分-4 :通过企微机器人把 html 报告发送到群里
这里需要 2 步,第一步,html 文件需要先通过企微机器人 api 上传到去,会返回一个 media_id。这里注意是上传文件,需要 type=formdata 后推文件。第二步,根据企微机器人的推送消息方法,把 media_id 推上去,html 文件就可以推送到群里了。
企微机器人的方法,不再赘述,可以去官网这里去查看:企微机器人Api文档。
//html报告上传(html报告已经在前边的步骤拿到)
HttpHeaders requestHeaders = new HttpHeaders();
requestHeaders.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> requestBody = new LinkedMultiValueMap<>();
FileSystemResource resource = new FileSystemResource(htmlReport);
requestBody.add("upload_file", resource);
// 发送上传请求
HttpEntity<MultiValueMap> requestEntity = new HttpEntity<MultiValueMap>(requestBody, requestHeaders);
ResponseEntity<HashMap> responseEntity = new RestTemplate().postForEntity("https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?type=file&key=xxx",
requestEntity, HashMap.class);
logger.info("企微机器人上传文件成功 = "+ responseEntity.getBody());
String media_id = (String)responseEntity.getBody().get("media_id");
//把文件发到群里
Map<String,Object> params2 = new HashMap<>();
params2.put("msgtype","file");
Map<String,String> fileMap = new HashMap<>();
fileMap.put("media_id",media_id);
params2.put("file",fileMap);
Map<String,String> headers2 = new HashMap<>();
headers.put("Content-Type", "application/json");
responseObj = TCHttpUtil.sendPostWithObjMap("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx",headers2,params2);
//删除html文件
logger.info("执行删除html文件 ----"+htmlReportPath+ "---" + htmlReport.delete());
经过以上步骤,已经完成消息的发送。需要把其中需要自己替换的更改下,比如机器人链接 key、生成的 json 或 html 路径(如果需要的话)。
八、定时任务,使用 xxljob 负责调度调试就行了。
1、执行器管理-新建执行器
建议选自动注册,当代码持续运行后,会自动写入。当然直接写自己的 ip 也行。【公司一般都有 xxl-job 平台,没有就自己搭建下或者用公司的其他定时任务平台】
2、任务管理-执行器下新建 job 任务
这个 jobHandler 的名字( bugsJob1) 需要和代码中保持一致,先知道下。cron 表达式代表每天的9,14,17,20点执行。也可以自己写,有 cron 在线生成工具。
3、引入依赖
<dependency>
<groupId>自己找</groupId>
<artifactId>xxl-job-core</artifactId>
<version>arsenal.1.0.1.20200114.1-SNAPSHOT</version>
</dependency>
4、配置 xxl 信息
加到这个文件里 application.properties
xxl.job.adminAddresses=xxljob域名或ip
xxl.job.appName=qa-bugs - 一定是你写的执行器地址。
xxl.job.accessToken=整个xxljob 就一个accesstoken
xxl.job.logPath=/data/applogs/xxl-job/jobhandler
xxl.job.logRetentionDays
5、xxlconfig 实体类
@Data
@Configuration
@ConfigurationProperties(prefix = XxlJobConfig.XXL_JOB_CONFIG_PREFIX)
public class XxlJobConfig {
public static final String XXL_JOB_CONFIG_PREFIX = "xxl.job";
/**
* 调度中心地址
*/
private String adminAddresses ;
/**
* 对应执行器名称
*/
private String appName ;
private String ipe;
/**
* 本地连接端口
*/
private Integer port = 9999;
/**
* 访问token
*/
private String accessToken;
/**
* 服务端日志路径
*/
private String logPath = "/data/applogs/xxl-job/jobhandler";
/**
* 日志保留时间
*/
private Integer logRetentionDays = 60;
}
6、XXLJob 配置处理类
@Configuration
public class XxlJobExecutorConfig {
@Resource
private XxlJobConfig xxlJobConfig;
@Bean(name = "xxlJobExecutor", initMethod = "start", destroyMethod = "destroy")
public XxlJobExecutor xxlJobExecutor() {
XxlJobExecutor xxlJobExecutor = new XxlJobExecutor();
BeanUtils.copyProperties(xxlJobConfig, xxlJobExecutor);
return xxlJobExecutor;
}
}
7、job 执行类
JobHandler 的 value 一定等于你在 xxljob 平台创建的 jobHandler 值,方法的入参s,可以从在 xxljob 平台创建的 job 的入参扔进来,如果你需要用的话。
@JobHandler(value = "bugsJob1")
@Component
public class BugStatisticsJob1 extends IJobHandler {
@Override
public ReturnT<String> execute(String s) throws Exception {
这个s就是job的入参,有的话就用,没有就忽略。
logger.info("------推送定时的所有代码(第八步前的所有代码)------");
return new ReturnT(responseObj.getHttpCode(), (String)null);
}
代码要写在能持续运行的项目里,比如基于 Spring boot 搭建持续运行的测试平台,或者开发的服务代码里。
其他问题:
1、如果你需要把代码部署到线上,那么先要看看自己线上的数据库是否可以通过数据库地址直连。如果不行,要么去掉数据库校验部分,要么就别部署到线上。你如果是在 qa 写的,就直接部署到 qa 环境的机器上就行了。因为不同环境之前是无法直接访问的,比如用线上的域名访问 qa 的接口。
2、部署到 qa 机器上想正常运行,你就需要去机器上下载之前的所有依赖,本地调通只代表你的电脑本地可以访问,qa 机器上运行就需要保证机器上环境和依赖可用。把 node,apifox-cli,db.json 都准备好。
对于此步骤,我是修改了机器构建部分配置,重点看下这里
dockerfile 和 customCommand 中添加或替换了以下重点几行,自己分辨替换哦。
dockerfile前几行的配置:
FROM docker.17usoft.com/base/tomcat-node:v8
#FROM docker.17usoft.com/base/tomcat:v8-jdk1.8.0_191
RUN npm install -g apifox-cli #(下载apifox)
RUN cd /tmp/ && curl -O https://xxxstatic.com/database-connections.json#(我先把db.json上传到文件服务器,进入某个路径并下载)
customCommand前几行的配置
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
#npm install
nvm install v14.17.0
nvm use v14.17.0
node -v
npm install --registry=https://nexus.17usoft.com/repository/npm-all/ && npm run build-${serverGroupKey}
3、部署服务器上,基于问题 2,如果 database-connections.json 通过构建参数不好去上传下载,可以直接去对应目录去新建文件,然后把内容复制进入,也可以的。 唯一的不好之处就是需要每次部署后,都要去这样操作。
4、如果机器上安装了 apifox-cli,但是无法访问 apifox 命令(可以进到机器上运行下试试),可以换个思路,尝试使用完整路径去运行。
apifox 命令只是一种快捷访问方式,apifox 命令 等价于 node /usr/local/node-v12.13.0-linux-x64/lib/node_modules/apifox-cli/bin/cli.js (自己的在哪,自己查下) 替换后也可以运行的。
本来预期这样写
apifox run https://api.apifox.cn/api/v1/api-test/ci-config/359896/detail?token=xlH8c24skOlIwGWN08O-d2 -r json,cli --database-connection ./database-connections.json
可以找到 apifox-cli 的安装地址改成这样写
node /usr/local/node-v12.13.0-linux-x64/lib/node_modules/apifox-cli/bin/cli.js run https://api.apifox.cn/api/v1/api-test/ci-config/359896/detail?token=xlH8c24skOlIwGWN08O-d2 -r json,cli --database-connection ./database-connections.json