每次执行完测试之后将测试结果插入数据库
使用Spring Boot+MyBatis读取数据
前端通过接口获取处理后的数据并在图表上展示
最终展示
数据源来自于pytest
执行之后的结果,由于使用allure
进行结果的保存,所以直接读取对应的测试结果文件
'-result.json'
名称的文件{
"name": "处方购药",
"status": "skipped",
"statusDetails": {
"message": "Skipped: 非测试环境,不执行该用例",
"trace": "('/Users/zhongxin/gitproject/wytest/src/cases_ui/2021Q4/test_whole_process.py', 133, 'Skipped: 非测试环境,不执行该用例')"
},
"start": 1640090078320,
"stop": 1640090078320,
"uuid": "e1333fd7-ed19-47a3-8cb8-6bfe532d0e5b",
"historyId": "2489564d3e1374d96c407b6c6352448f",
"testCaseId": "32b23ec92bffbdf659133aeb8d49dc00",
"fullName": "src.cases_ui.2021Q4.test_whole_process#test_buy_medicine",
"labels": [
{
"name": "story",
"value": "全流程测试-测试环境"
},
{
"name": "feature",
"value": "微医主站"
},
{
"name": "tag",
"value": "dependency(name='buy_medicine', depends=['prescription'])"
},
{
"name": "tag",
"value": "h5"
},
{
"name": "tag",
"value": "@pytest.mark.skipif(True, reason='非测试环境,不执行该用例')"
},
{
"name": "parentSuite",
"value": "src.cases_ui.2021Q4"
},
{
"name": "suite",
"value": "test_whole_process"
},
{
"name": "host",
"value": "localhost"
},
{
"name": "thread",
"value": "1548-MainThread"
},
{
"name": "framework",
"value": "pytest"
},
{
"name": "language",
"value": "cpython3"
},
{
"name": "package",
"value": "src.cases_ui.2021Q4.test_whole_process"
}
]
}
def get_allure_result(self, path=f'{REPORT_PATH}/allure_results'):
"""
读取allure测试结果
:param path: 存放allure运行json结果的文件夹
:return:
"""
result_list = [i for i in os.listdir(path) if '-result.json' in i]
allure_results = []
allure_dict = {}
for i in result_list:
json_data = self.read_json(os.path.join(path, i))
full_name = json_data.get("fullName")
labels = '_'.join([i.get('value', '') for i in json_data.get("labels") if
i.get('name', '') in ['feature', 'story']]) + "_" + json_data.get("name")
parameters = json_data.get("parameters", '') and '_'.join([i.get('value', '') for i in
json_data.get("parameters", '')])
statusDetails = json_data.get('statusDetails', '')
statusDetails1 = {
"message": "",
"trace": ""
}
if statusDetails:
try:
statusDetails1["message"] = statusDetails.get("message", "")
statusDetails1["trace"] = statusDetails.get("trace", "")
except Exception:
pass
data = {
"fullName": full_name,
"status": json_data.get("status"),
"labels": labels,
"duration": json_data.get("stop", 0) - json_data.get("start", 0),
"parameters": parameters,
"statusDetails": statusDetails1
}
if f'{full_name}_{parameters}' not in allure_dict:
allure_dict[f'{full_name}_{parameters}'] = [data]
else:
allure_dict[f'{full_name}_{parameters}'].append(data)
for v in allure_dict.values():
if len(v) == 1:
allure_results.append(v[0])
else:
for v1 in v:
if 'pass' in v1.get("status"):
allure_results.append(v1)
break
else:
allure_results.append(v[0])
return allure_results
我们将需要的信息存放到数据库中,所以设计的数据表如下:
create table ui_report (
id int(11) unsigned auto_increment comment 'ID'
primary key,
project_name varchar(255) null comment 'Jenkins Job名称',
build_num varchar(255) null comment '构建编号',
code_name varchar(255) null comment '用例名称',
status varchar(50) null comment '运行结果',
case_desc varchar(255) null comment '用例描述',
duration int default 0 null comment '执行耗时',
parameters varchar(255) null comment '用例参数',
message varchar(255) null comment '错误信息',
gmt_created datetime not null comment '创建时间',
gmt_modified datetime not null comment '修改时间'
)
comment 'UI用例执行结果';
使用peewee
模块进行数据库ORM操作
将数据库映射成python代码
from peewee import *
database = MySQLDatabase('auto_test', **{'charset': 'utf8', 'sql_mode': 'PIPES_AS_CONCAT', 'use_unicode': True,'host': '', 'port': 3306,'user': '', 'password': ''})
class UnknownField(object):
def __init__(self, *_, **__): pass
class BaseModel(Model):
class Meta:
database = database
class UiReport(BaseModel):
project_name = CharField(null=True)
build_num = CharField(null=True)
case_desc = CharField(null=True)
code_name = CharField(null=True)
duration = IntegerField(constraints=[SQL("DEFAULT 0")], null=True)
parameters = CharField(null=True)
status = CharField(null=True)
message = CharField(null=True)
gmt_created = DateTimeField()
gmt_modified = DateTimeField()
class Meta:
table_name = 'ui_report'
在执行完测试之后进行插入数据操作
在conftest.py
文件的pytest_terminal_summary
钩子函数中处理数据插入
def pytest_terminal_summary(terminalreporter, exitstatus, config):
"""
统计测试结果
:param terminalreporter:
:param exitstatus:
:param config:
:return:
"""
logger.info(f"总计:{terminalreporter._numcollected}")
stats = terminalreporter.stats
failed_num = len(stats.get('failed', []))
error_num = len(stats.get('error', []))
logger.info(f"通过:{len(stats.get('passed', []))}")
logger.info(f"失败:{failed_num}")
logger.info(f"异常:{error_num}")
logger.info(f"跳过:{len(stats.get('skipped', []))}")
duration = time.time() - terminalreporter._sessionstarttime
logger.info(f"总耗时:{duration}秒")
if not hasattr(config, "workerinput"):
jsonoperator = JsonOperator()
allure_results = jsonoperator.get_allure_result()
if ROBOT:
try:
r = ReportOperator(hook=ROBOT.split(','))
r.save_result_magic(allure_results)
except Exception as e:
logger.error(f'存入数据库失败:{e}')
save_result_magic
遍历刚才读取json拿到的内容然后依次插入数据
def save_result_magic(self, run_detail):
"""
将运行结果存储到magic数据库
"""
logger.info(run_detail)
if ProjectName != "非jenkins运行":
for i in run_detail:
try:
UiReport(
project_name=ProjectName,
build_num=f"{timeoperator.now2}_{BUILD_NUMBER}",
case_desc=i.get("labels"),
code_name=i.get("fullName"),
duration=i.get("duration"),
parameters=i.get("parameters"),
status=i.get("status"),
message=i["statusDetails"].get("message", "")[:255],
gmt_created=timeoperator.now1,
gmt_modified=timeoperator.now1
).save()
except Exception as e:
logger.error(f"存储数据{i}失败:「{e}」")
通过用例执行明显拿到每天的执行情况
我们在图表中要展示的数据如下
@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
public class UiReportDayDo {
/**
* Jenkins Job名称
*/
String projectName;
/**
* 执行时间,格式:年-月-日
*/
String time;
/**
* 运行总次数
*/
Long runTimes;
/**
* 失败总次数
*/
Long errorNum;
}
但是我们存入的数据和这个数据差别比较大,所以我们需要使用SQL进行一轮处理
根据 Job名称、执行编号进行分组 拿到执行状态是failed
或 broken
的数据
select
ui.project_name,
ui.build_num,
sum(IF(ui.status = 'failed' || ui.status = 'broken', 1, 0)) as error_num
from ui_report as ui
group by ui.build_num, ui.project_name
然后
build_num
进行切割取前面10个字符error_num
等于0则为0,不然显示1,这样就拿到了每个build_num
是否有错误select a.project_name, left(a.build_num, 10) as time, IF(a.error_num = 0, 0, 1) as error_num
from (
select ui.project_name, ui.build_num,
sum(IF(ui.status = 'failed' || ui.status = 'broken', 1, 0)) as error_num
from ui_report as ui
group by ui.build_num, ui.project_name
) as a
最后
count(*) as run_times
每天的总执行次数sum(b.error_num) as error_num
每天的总失败次数select b.project_name as project_name, b.time, count(*) as run_times, sum(b.error_num) as error_num
from (
select a.project_name, left(a.build_num, 10) as time, IF(a.error_num = 0, 0, 1) as error_num
from (
select ui.project_name, ui.build_num,
sum(IF(ui.status = 'failed' || ui.status = 'broken', 1, 0)) as error_num
from ui_report as ui
group by ui.build_num, ui.project_name
) as a
) as b
public interface UiReportMapper {
/**
* 分页获取每日执行情况
*
* @return
*/
@PageMapper(listStatement = "findDayByQuery", countStatement = "countDayByQuery")
Page<UiReportDayDo> pageDayQuery(UiReportPageQuery uiReportPageQuery);
}
需要进行分页+查询所以xml需要有
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.guahao.qa.magicapiweb.biz.dal.uireport.mapper.UiReportMapper">
<sql id="allFields">
id
,project_name,build_num,code_name,status,case_desc,duration,parameters,message,gmt_created,gmt_modified
</sql>
<sql id="queryDayInfo">
select b.project_name as project_name, b.time, count(*) as run_times, sum(b.error_num) as error_num
from (
select a.project_name, left(a.build_num, 10) as time, IF(a.error_num = 0, 0, 1) as error_num
from (
select ui.project_name, ui.build_num,
sum(IF(ui.status = 'failed' || ui.status = 'broken', 1, 0)) as error_num
from ui_report as ui
group by ui.build_num, ui.project_name
) as a
) as b
</sql>
<sql id="queryDayCondition">
<where>
<if test="projectName!= null and projectName!=''">and b.project_name= #{projectName}</if>
<if test="buildNum!= null and buildNum!=''">and b.time like CONCAT('%', #{buildNum}, '%')</if>
</where>
</sql>
<select id="findDayByQuery" resultType="com.guahao.qa.magicapiweb.biz.dal.uireport.model.UiReportDayDo">
<include refid="queryDayInfo"/>
<include refid="queryDayCondition"/>
group by b.time,b.project_name
order by b.time desc
LIMIT #{offset}, #{pageSize}
</select>
<select id="countDayByQuery" resultType="long">
SELECT count(*)
FROM (
<include refid="queryDayInfo"/>
<include refid="queryDayCondition"/>
group by b.time,b.project_name
order by b.time desc
) as c
</select>
</mapper>
public interface UiReportManager {
/**
* 获取每日执行情况
*
* @return
*/
Page<UiReportDayDo> getCalcDay(@NotNull UiReportPageQuery uiReportPageQuery);
}
@Service
@Slf4j
@Validated
public class UiReportManagerImpl implements UiReportManager {
@Override
public Page<UiReportDayDo> getCalcDay(UiReportPageQuery pageQuery) {
return uiReportMapper.pageDayQuery(pageQuery);
}
}
@RestController
@Slf4j
@CrossOrigin(origins = "*")
@RequestMapping("/uireport")
public class UiReportController {
/**
* 获取每日运行情况
*
* @return
*/
@RequestMapping(value = "/queryUiDayList", method = RequestMethod.POST, produces = {
"application/json;charset=UTF-8" })
public Result<Page> queryUiDayList(@RequestBody UiReportPageQuery uiReportPageQuery) {
Page<UiReportDayDo> calcCase = uiReportManager.getCalcDay(uiReportPageQuery);
return Results.success(calcCase);
}
}
前端请求/uireport/queryUiDayList
就可以拿到执行数据
{"code":"0","data":{"pageNumber":1,"pageSize":10,"results":[{"errorNum":7,"projectName":"pipeline_h5_Python","runTimes":20,"time":"2022-01-02"},{"errorNum":20,"projectName":"pipeline_h5_Python","runTimes":24,"time":"2022-01-01"},{"errorNum":10,"projectName":"pipeline_h5_Python","runTimes":24,"time":"2021-12-31"},{"errorNum":10,"projectName":"pipeline_h5_Python","runTimes":24,"time":"2021-12-30"},{"errorNum":5,"projectName":"pipeline_h5_Python","runTimes":21,"time":"2021-12-29"},{"errorNum":5,"projectName":"pipeline_h5_Python","runTimes":9,"time":"2021-12-28"}],"totalCount":6,"totalPages":1},"message":"成功!"}
每次获取数据后都去重新渲染一次图表
async getDayInfo() {
const response = await UiReport.apiGetDayInfo(this.searchData)
if (response.status === 200) {
this.calcData = { errorNum: [], successNum: [], rate: [] }
this.time_list = []
this.dayCaseInfo = response.data.data.results
this.totalCount = response.data.data.totalCount
this.dayCaseInfo.forEach(item => {
const rate = ((item.runTimes - item.errorNum) / item.runTimes).toFixed(3) * 100
item.rate = rate + '%'
this.time_list.push(item.time)
this.calcData.errorNum.push(item.errorNum)
this.calcData.successNum.push(item.runTimes - item.errorNum)
this.calcData.rate.push(rate + '')
})
this.$nextTick(() => {
this.showCharts()
})
}
},
查询某个项目
查询某个项目的某个时间
然后使用vue进行展示
<template>
<div>
<el-form :data="searchData" :inline="true">
<el-form-item>
<el-select v-model="searchData.projectName" placeholder="请选择Job" clearable filterable>
<el-option v-for="(item, index) in $store.state.ui.jobList" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-input v-model="searchData.buildNum" placeholder="请输入日期" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getDayInfo">查询</el-button>
</el-form-item>
</el-form>
<div class="tableDiv">
<div id="CalcReportChart" style="width:1200px;height:500px"></div>
</div>
<el-table :data="dayCaseInfo" :header-cell-style="{ background: '#0e2642', color: '#fff' }" border>
<el-table-column prop="projectName" label="Job名称"></el-table-column>
<el-table-column prop="time" label="日期"></el-table-column>
<el-table-column prop="runTimes" label="执行次数"></el-table-column>
<el-table-column prop="errorNum" label="错误次数"></el-table-column>
<el-table-column prop="rate" label="成功率"></el-table-column>
</el-table>
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="searchData.pageNumber"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="totalCount"
></el-pagination>
</div>
</template>
<script>
import UiReport from '@/api/ui/index.js'
import * as echarts from 'echarts'
export default {
components: {},
props: {},
data() {
return {
dayCaseInfo: [],
searchData: {
pageNumber: 1,
pageSize: 10,
projectName: 'pipeline_h5_Python',
buildNum: ''
},
totalCount: 0,
calcData: { errorNum: [], successNum: [], rate: [] },
time_list: []
}
},
computed: {},
watch: {},
methods: {
// 获取每天运行结果
async getDayInfo() {
const response = await UiReport.apiGetDayInfo(this.searchData)
if (response.status === 200) {
this.calcData = { errorNum: [], successNum: [], rate: [] }
this.time_list = []
this.dayCaseInfo = response.data.data.results
this.totalCount = response.data.data.totalCount
this.dayCaseInfo.forEach(item => {
const rate = ((item.runTimes - item.errorNum) / item.runTimes).toFixed(3) * 100
item.rate = rate + '%'
this.time_list.push(item.time)
this.calcData.errorNum.push(item.errorNum)
this.calcData.successNum.push(item.runTimes - item.errorNum)
this.calcData.rate.push(rate + '')
})
this.$nextTick(() => {
this.showCharts()
})
}
},
handleSizeChange(val) {
this.searchData.pageNumber = 1
this.searchData.pageSize = val
this.getDayInfo()
},
handleCurrentChange(val) {
this.searchData.pageNumber = val
this.getDayInfo()
},
createCalcData() {
const _this = this
var xData = _this.time_list
const option = {
backgroundColor: '#0e2642',
title: {
show: true,
text: _this.searchData.projectName,
textStyle: {
color: '#fff'
},
x: 'center'
},
legend: {
data: ['成功次数', '错误次数', '成功率'],
left: '18%',
top: 30,
itemWidth: 16.7,
itemHeight: 7.6,
type: 'plain',
textStyle: {
color: 'RGBA(154, 209, 253, 1)'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
textStyle: {
color: '#fff'
}
}
},
grid: {
borderWidth: 0,
top: 100,
bottom: 80,
left: 55,
right: 45,
textStyle: {
color: '#fff'
}
},
calculable: true,
xAxis: [
{
type: 'category',
axisLine: {
lineStyle: {
color: 'rgba(255,255,255,.5)'
}
},
splitLine: {
show: false
},
axisTick: {
show: false
},
splitArea: {
show: false
},
axisLabel: {
interval: 0,
color: 'rgba(255,255,255,0.7)',
fontSize: 10
},
data: xData
}
],
yAxis: [
{
name: '总次数',
nameTextStyle: {
color: '#FDFDFD',
padding: [0, 0, 0, -50]
},
nameGap: 15,
type: 'value',
splitLine: {
show: true,
lineStyle: {
type: 'dashed',
color: 'RGBA(3, 75, 97, 1)'
}
},
axisLine: {
show: false
},
axisTick: {
show: false
},
axisLabel: {
interval: 0,
color: 'rgba(255,255,255,0.5)',
fontSize: 10
},
splitArea: {
show: false
}
},
{
name: '成功率',
nameTextStyle: {
color: '#FDFDFD',
padding: [0, 0, 0, 35]
},
type: 'value',
splitLine: {
show: false
},
min: 0,
axisLabel: {
interval: 0,
color: 'rgba(255,255,255,0.5)',
fontSize: 10,
formatter: '{value}%'
}
}
],
series: [
{
name: '成功次数',
type: 'bar',
stack: '1',
barMaxWidth: 15,
barGap: '10%',
itemStyle: {
normal: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: '#2764CA' // 0% 处的颜色
},
{
offset: 1,
color: '#6D9EEE' // 100% 处的颜色
}
],
global: false // 缺省为 false
},
opacity: 1
}
},
data: _this.calcData.successNum
},
{
name: '错误次数',
type: 'bar',
stack: '1',
itemStyle: {
normal: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: '#FF8B77' // 0% 处的颜色
},
{
offset: 1,
color: '#FE6AAC' // 100% 处的颜色
}
],
global: false // 缺省为 false
},
opacity: 0.9,
barBorderRadius: 0
}
},
data: _this.calcData.errorNum
},
{
name: '成功率',
type: 'line',
yAxisIndex: 1,
symbolSize: 0,
symbol: 'emptyCircle',
itemStyle: {
normal: {
color: '#FFC130',
barBorderRadius: '100%'
},
borderWidth: 0
},
smooth: true,
lineStyle: {
normal: {
width: 3,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: '#FF8B77' // 0% 处的颜色
},
{
offset: 1,
color: '#FE6AAC' // 100% 处的颜色
}
],
global: false // 缺省为 false
}
}
},
data: _this.calcData.rate
}
]
}
return option
},
showCharts() {
var myChart = echarts.init(document.getElementById('CalcReportChart'))
const options = this.createCalcData()
console.log(options)
myChart.setOption(options)
}
},
mounted() {
this.showCharts()
},
created() {
this.getDayInfo()
}
}
</script>
<style scoped></style>