基于Jenkins + Zentao+ JMeter实现自动化测试集成de起步阶段。
1. 前言
千呼万唤之下,公司内部的自动化测试流程终于蹒跚起步,可惜放眼整个公司,从理论体系到实际落地经验,完全没有现成的果子可以摘。
想要让自动化测试真正在公司内部落地,我们需要从零开始摸索。
本文尝试建立一个起步的自动化测试流程,实现Jmeter脚本自动执行,测试执行结果结构化存储,测试结果消息通知,需求-代码-测试关联的无人工参与,最大化地降低研发人员和测试人员的心智负担,提升协同效率。
2. 流程介绍
相关流程图:流程图 -> CI - 基于JMeter自动化测试 。
流程顺序(测试人员视角)
-
测试人员预先以需求为粒度,编写相应的JMeter测试脚本;并按照“产品-模块-需求”的禅道层级结构来组织创建相应的文件目录结构来存放相应的JMeter测试脚本。
-
测试人员从Zentao端,以禅道中的测试套件(对应禅道"产品"概念下的一个需求)为粒度,点击提供的二次开发按钮"执行该测试套件",发起指令调用Jenkins相应的任务。
-
Jenkins通过调用JMeter执行用户所选择的测试套件,并将生成的测试报告按照约定的目录结构进行存储。
-
Jenkins在执行完JMeter测试用例之后,需要收集每个测试用例的执行情况,生成自定义格式的汇总信息报表。
-
Jenkins将上一步生成的报表对应的远程访问链接,通过IM工具,QQ邮箱实时推动给在Zentao端发起调用指令的用户。
-
Jenkins还需要将报表对应的远程访问链接,反写回Zentao中对应测试套件的操作日志下,形成最终的闭环。
3. 环境准备
按照以上流程图,涉及到的相关组件有:
- Jenkins安装 - 略。作为持续流水线执行的推动者。(被部署在一台Windows Server2012服务器上)
- JMeter安装 - 略。执行实际的自动化测试。(与Jenkins一起,被部署在一台Windows Server2012服务器上)
- Tomcat安装 - 略。用作文件服务器。(与Jenkins一起,被部署在一台Windows Server2012服务器上)
- Zentao安装 - 略。用作产品生命周期管理。(部署在单独的服务器上)
- KIM即时消息通知,QQ邮箱等。
4. Jenkins端
作为流程实现的核心点之一,Jenkins的主要配置项是通过pipeline脚本来体现的。
properties([
parameters([
string(name: 'SCRIPTS_WILL_EXECUTE', defaultValue:"XXXXX", description: '将要被执行的脚本. 如果是多个脚本, 请使用 , 进行分隔'),
string(name: 'TEST_SUITE_ID', defaultValue:"555555", description: 'testsuite id'),
string(name: 'TEST_SUITE_NAME', defaultValue:"XXXXXXX", description: 'testsuite name'),
string(name: 'PRODUCT_NAME', defaultValue:"XXXXZ", description: '产品名称'),
string(name: 'NOTICED_USER', defaultValue:"XXZ", description: 'Job执行完毕之后的被通知对象'),
])
])
def zNotifyWin(String RECEIVERS, String MSG_TITLE ,String MSG_CONTENT, String EXECUTE_RESULT = "SUCCESS"){
["powershell.exe","/c","E:/_devops/notifyByIM.ps1 ${RECEIVERS} ${MSG_TITLE} ${MSG_CONTENT} ${EXECUTE_RESULT}"].execute()
}
pipeline {
agent any
environment{
CURRENT_TIME = new Date().format("yyyyMMddHHmmss")
CURRENT_TIME_READABLE = new Date().format("yyyy-MM-dd HH:mm:ss")
EXECUTED_SCRIPT_BASE_PATH = "F:/02server"
// 为了最大化的兼容现有流程和人员习惯,我们决定以中文来组织测试脚本的存放目录结构, 于是这里我们需要将zentao传递来的参数进行url解码(相应的, zentao那边会对参数进行url编码之后进再行传递)
SCRIPTS_WILL_EXECUTE_DECODED = URLDecoder.decode("${SCRIPTS_WILL_EXECUTE}","UTF-8")
TEST_SUITE_NAME_DECODED = URLDecoder.decode("${TEST_SUITE_NAME}","UTF-8")
PRODUCT_NAME_DECODED = URLDecoder.decode("${PRODUCT_NAME}","UTF-8")
}
stages {
stage('Initialize') {
steps {
powershell """
echo "${JOB_NAME}"
echo "清空工作空间"
"""
zNotifyWin("${NOTICED_USER}", "第${BUILD_NUMBER}构建-${TEST_SUITE_ID}-${TEST_SUITE_NAME_DECODED}","启动","skip")
cleanWs()
}
}
stage('执行JMeter测试') {
steps {
script{
def scriptArrWillExecute = "${SCRIPTS_WILL_EXECUTE_DECODED}".split(',')
for(scriptWillExecute in scriptArrWillExecute){
def NAME_OF_USE_CASE = "${scriptWillExecute}".split('/')[-1]
def NAME_OF_USE_CASE_FINAL = "${NAME_OF_USE_CASE}"[4..-1]
scriptWillExecute = "${scriptWillExecute}".replace("${NAME_OF_USE_CASE}","${NAME_OF_USE_CASE_FINAL}")
powershell """
\$env:path = \$env:path +"; C:/apache-jmeter-5.1.1/bin/"
mkdir ${EXECUTED_SCRIPT_BASE_PATH}/03report/html/${scriptWillExecute}/${NAME_OF_USE_CASE_FINAL}-${CURRENT_TIME} -Force
mkdir ${EXECUTED_SCRIPT_BASE_PATH}/03report/jtl/${scriptWillExecute}/ -Force
jmeter -n -t ${EXECUTED_SCRIPT_BASE_PATH}/01script/${scriptWillExecute}.jmx -l ${EXECUTED_SCRIPT_BASE_PATH}/03report/jtl/${scriptWillExecute}/${NAME_OF_USE_CASE_FINAL}-${CURRENT_TIME}.jtl -e -o ${EXECUTED_SCRIPT_BASE_PATH}/03report/html/${scriptWillExecute}/${NAME_OF_USE_CASE_FINAL}-${CURRENT_TIME}
"""
}
}
}
}
stage('抓取JMeter执行结果') {
steps {
script{
def scriptArrWillExecute = "${SCRIPTS_WILL_EXECUTE_DECODED}".split(',')
def links = []
def htmlResult = ""
for(scriptWillExecute in scriptArrWillExecute){
def NAME_OF_USE_CASE = "${scriptWillExecute}".split('/')[-1]
def NAME_OF_USE_CASE_FINAL = "${NAME_OF_USE_CASE}"[4..-1]
scriptWillExecute = "${scriptWillExecute}".replace("${NAME_OF_USE_CASE}","${NAME_OF_USE_CASE_FINAL}")
// 抓取当前测试用例脚本的执行通过成功率
def passedResults = (["powershell.exe","/c","E:/_devops/jmeterGrapExecuteResult.ps1 ${EXECUTED_SCRIPT_BASE_PATH}/03report/html/${scriptWillExecute}/${NAME_OF_USE_CASE_FINAL}-${CURRENT_TIME}/content/js/dashboard.js"].execute().text.readLines());
// 成功数量, 失败数量, 成功比例
def okCount = passedResults[0]
def notOkCount = passedResults[1]
def okPercent = passedResults[2]
def link = "${NAME_OF_USE_CASE}; 通过率 ${okPercent} ; http://172.XX.X.XXX:8080/infces2/${scriptWillExecute}/${NAME_OF_USE_CASE_FINAL}-${CURRENT_TIME} "
links += link
htmlResult += """
<p>
<a target="_blank" href="${link}">${scriptWillExecute}/${NAME_OF_USE_CASE_FINAL}-${CURRENT_TIME} ; 通过率 ${okPercent}</a>
</p>
<br />
"""
}
env.LINKS = links.join("\r\n")
env.HTML_RESULT = htmlResult
}
}
}
stage("反写Zentao") {
steps{
script{
// 注意去修改禅道中的 config/config.php 文件
// $config->features->apiGetModel = false; 把false修改为true
(["powershell.exe","/c","E:/_devops/zentaoPushActionAndHistory.ps1 ${TEST_SUITE_ID} 第${BUILD_NUMBER}构建_${BUILD_URL}"].execute().text.readLines());
}
}
}
stage("推送邮件结果") {
steps{
script {
emailext(
to: 'XXXXX@qq.com,YYYYY@qq.com',
//charset:'UTF-8',
//mimeType: 'text/html',
subject: "${PRODUCT_NAME_DECODED}-${TEST_SUITE_NAME_DECODED}-测试成功率(?)-${TEST_SUITE_ID}-${BUILD_NUMBER}",
body: """
执行脚本:
${SCRIPTS_WILL_EXECUTE_DECODED},
结果参见:
${env.LINKS}
""",
attachmentsPattern: '*.html'
)
}
}
}
}
post {
success {
zNotifyWin("${NOTICED_USER}", "第${BUILD_NUMBER}构建-${TEST_SUITE_ID}-${TEST_SUITE_NAME_DECODED}","","SUCCESS")
}
failure {
echo 'pipeline post failure'
zNotifyWin("${NOTICED_USER}", "第${BUILD_NUMBER}构建-${TEST_SUITE_ID}-${TEST_SUITE_NAME_DECODED}","", "FAIL")
}
}
}
以上代码已经能够清晰地说明执行逻辑,这里就不再赘述。
5. Zentao端
对于Zentao端,主要的扩展点是收集用户意图,以参数的形式传递给Jenkins,发起任务调用:
# .\zentao\module\testsuite\view\browse.html.php
# 为每个testsuite增加一个执行该testsuite的按钮
$runCaseURL = $this->createLink('testsuite', 'runCase', "suiteID=$suite->id&confirm=yes");
echo html::a("javascript:ajaxDelete(\"$runCaseURL\", \"suiteList\", \"执行当前套件[$suite->id]?\")", '<i></i>', '', "title='执行套件' class='icon-play'");
# .\zentao\module\testsuite\control.php
/**
* Run a test suite.
*
* @param int $suiteID
* @param string $confirm yes|no
* @param string $cases array
* @access public
* @return void
*/
public function runCase($suiteID, $confirm = 'no', $cases=array())
{
if($confirm == 'no')
{
die(js::confirm($this->lang->testsuite->confirmDelete, inlink('delete', "suiteID=$suiteID&confirm=yes")));
}
else
{
$currentAccount = $this->app->user->account;
$currentRealname = $this->app->user->realname;
$suite = $this->testsuite->getById($suiteID);
$productID = $suite->product;
$this->products = $this->loadModel('product')->getPairs('nocode');
$productName = isset($this->products[$productID]) ? $this->products[$productID] : '';
// 直接从 本类里的 view() 方法中抄来的
$cases = $this->testsuite->getLinkedCases($suiteID);
$modules = $this->loadModel('tree')->getOptionMenu($suite->product, 'case');
// 排序, 以安排testsuite中测试用例的执行顺序
array_multisort(array_column( $cases , 'title' ),SORT_ASC, $cases );
$casesWillExecute = array();
foreach($cases as $caseID => $case)
{
$casesWillExecute[$caseID] = $modules[$case->module] . "/" . $case->title ;
}
common::http("http://admin:admin@172.XX.X.XXX:8080/jenkins/job/%E6%8E%A5%E5%8F%A3%E6%B5%8B%E8%AF%95/job/000-LQ-BatchExecuteJmxByModule/buildWithParameters?delay=0sec",array("SCRIPTS_WILL_EXECUTE"=>urlencode(join(",", $casesWillExecute)) , "TEST_SUITE_ID"=>$suiteID, "TEST_SUITE_NAME"=>urlencode($suite->name),"PRODUCT_NAME"=>urlencode($productName), "NOTICED_USER"=>$currentAccount),array());
$this->executeHooks($suiteID);
/* if ajax request, send result. */
if($this->server->ajax)
{
if(dao::isError())
{
$response['result'] = 'fail';
$response['message'] = dao::getError();
}
else
{
$response['result'] = 'success';
$response['message'] = '';
}
$this->send($response);
}
die(js::reload('parent'));
}
}
6. 优化
- 只执行套件中的部分用例。