背景介绍
最近有时间可以写一点东西, 总结下我在前司做的基于Cypress+Mocha+gitlabCI的持续集成并行测试方案。为了方便测试同学自己维护自动化代码,所以自动化代码和前端项目代码是使用不同gitlab仓库管理的。因此本文整体方案:
- 前端项目merger master触发上线pipeline:
- 自动化专用环境更新(更新方式:docker + rancher)
- 触发自动化环境回测pipeline (触发方式:gitlab ci pipeline api)
- 等待自动化环境测试成功
- 线上环境更新(更新方式:docker + rancher )
- 自动化项目回测pipeline:
- 10个并行runner同时跑case(gitlab ci parallel关键字)
- 合并10个runner的测试报告
- 发送测试报告到mfs
- 发送html测试报告(tomcat服务读取mfs中html)
并行方案
Cypress官方并行测试方案
Cypress官网其实给出了一个并测实测方案: Parallelization
中心思想:
根据cypress的balance strategy策略将所有测试文件划分给多个可用机器
用法:
cypress run --record --key=abc123 --parallel
缺点:
- 得有多个同时安装cypress以及其他环境的机器来执行用例
- 依赖官方的算法策略和云服务,无法了解细节
Gitlab CI的parallel
在查找Cypress的parallel的过程中,发现gitlab ci也提供了一种在Pipeline中可以并行执行job的方法 parallel
用法:
test:
script: rspec
parallel: 2
上述代码是将 当前test job划分为2个并行job , 实现结果如图:
命名为: test 1/N - test N/N
因此可以将所有的用例划分为几份,分发给定义的多个并行job。
分配测试用例
Cypress可以执行指定的测试文件,而且可以指定多个,用到的参数是:
运行某个单独的测试文件而不是所有的测试用例:
cypress run --spec "cypress/integration/examples/actions.spec.js"
运行*号匹配到的文件目录(注意:推荐使用双星号**)
cypress run --spec "cypress/integration/login/**/*"
运行指定多个测试文件:
cypress run --spec "cypress/integration/examples/actions.spec.js,cypress/integration/examples/files.spec.js"
因此将所有的测试文件的路径划分为几份, 然后作为spec参数传递设置的并行的job中
tests:
image: cypress/browsers:node16.14.2-slim-chrome100-ff99-edge
stage: test
parallel: 5
script:
- yarn cypress:run --spec "测试文件所在路径"
然后恰巧我的测试用例都是以文件夹形式划分的, 每个模块一个文件夹,因此我只要有个function来查找出所有的可测文件的路径,然后划分为几等份即可
考虑到有些模块执行时间较长,如果这些执行时间较长的文件都分配到同一runner,那样总执行时间并不会变短,考虑使用加入贪心算法进行测试用例的分配,使用单独json文件存储每个js文件执行的时间(是比较麻烦,需要在本地调试的时候查看当前文件的执行时间),分配测试用例到当前最短时间的runner , 完整代码详见:
const fs = require("fs");
const path = require("path");
const NODE_INDEX = Number(process.env.CI_NODE_INDEX || 1);
const NODE_TOTAL = Number(process.env.CI_NODE_TOTAL || 1);
const TEST_FOLDER = path.resolve(process.cwd(), "cypress/integration/test");
const testFileExecutionTimes = require("../support/testFileExecutionTimes");
const getAllTestFiles = (dir) => {
const files = fs.readdirSync(dir);
const result = [];
files.forEach((file) => {
const filePath = path.resolve(dir, file);
const stats = fs.statSync(filePath);
if (stats.isFile()) {
result.push(filePath);
} else if (stats.isDirectory()) {
result.push(...getAllTestFiles(filePath));
}
});
return result;
};
const getCurTestFiles = () => {
const allSpecFiles = getAllTestFiles(TEST_FOLDER);
const sortedTestFiles = allSpecFiles
.map((file) => ({
file,
time: testFileExecutionTimes[path.basename(file)] || 0,
}))
.sort((a, b) => b.time - a.time);
const pipelineTestFiles = Array.from({ length: NODE_TOTAL }, () => []);
sortedTestFiles.forEach(({ file, time }) => {
const shortestPipelineIndex = pipelineTestFiles.reduce(
(minIndex, cur, index, arr) => {
const curTotalTime = cur.reduce(
(sum, f) => sum + testFileExecutionTimes[path.basename(f)],
0
);
const minTotalTime = arr[minIndex].reduce(
(sum, f) => sum + testFileExecutionTimes[path.basename(f)],
0
);
return curTotalTime < minTotalTime ? index : minIndex;
},
0
);
pipelineTestFiles[shortestPipelineIndex].push(file);
});
pipelineTestFiles.forEach((testFiles, index) => {
const totalTime = testFiles.reduce(
(sum, file) => sum + testFileExecutionTimes[path.basename(file)],
0
);
});
return pipelineTestFiles[NODE_INDEX - 1];
};
console.log(getCurTestFiles().join(", "));
gitlab-ci.yml
api-tests:
image: cypress/browsers:node16.14.2-slim-chrome100-ff99-edge
stage: test
parallel: 1
script:
- yarn cypress:run --spec $(node cypress/support/cypress-parallel-file.js)
合并Mocha测试报告
当每个runner测试完成后 都会在mochawesome-report目录下生成相应的json文件,例如: mochawesome.json mochawesome_001.josn
所以多个runner的时候会产生多份相同名称的json文件,而为了清晰的展示本次的测试结果,应该只产生一个完整的测试报告,包含所有执行的case,因此我们新建公共文件夹 并且重命名每份文件:
parallel: 2
script:
- yarn cypress:run --spec $(node cypress/support/cypress-parallel-file.js) --reporter mochawesome --reporter-options reportDir=mochawesome-report,overwrite=false,html=false,json=true
- mkdir public // 新建公共文件夹
- for f in mochawesome-report/*.json; do mv -- "$f" "${f%.json}${CI_JOB_ID}.json"; done; // 已job id 重命名
- for f in mochawesome-report/*.json; do mv $f public; done; // 转移到公共目录下
但是有测试用例失败时,当前script中剩余步骤不会执行 ,即无法将每次运行生产的json文件转移到公共目录下
开始各种翻gitlab docs … ,翻到了 after_script: 该关键字可以在每个job后运行,(失败的也阔以~),
所以, 修改api-test job, 将每个runner的json类型的报告转移公共目录的步骤放在after_script中:
api-tests:
image: cypress/browsers:node16.14.2-slim-chrome100-ff99-edge
stage: test
parallel: 2
script:
- mkdir public
- yarn cypress:run --spec $(node cypress/support/cypress-parallel-file.js) --reporter mochawesome --reporter-options reportDir=mochawesome-report,overwrite=false,html=false,json=true
after_script:
- for f in mochawesome-report/*.json; do mv -- "$f" "${f%.json}${CI_JOB_ID}.json"; done;
- for f in mochawesome-report/*.json; do mv $f public; done;
when: always
allow_failure: true
api-tests阶段执行完成后,所有的json类型测试报告都会懂到public目录下, 接下来就是将所有json报告合并成html类型报告
merge:
image: cypress/browsers:node16.14.2-slim-chrome100-ff99-edge
image: cypress/base:10
stage: merge
script:
- cd public
- npx mochawesome-merge *.json > mochawesome_merged.json // 合并成同一个json报告
- npx marge mochawesome_merged.json //生成html类型报告, 该命令执行完仍然会在public文件夹内生成一个目录 mochawesome-report 此时并不是原存储测试结果的目录reportDir=mochawesome-report, (踩坑了,合并的测试报告在public下找不到,要到public/mochawesome-report下才能找到)
- cd mochawesome-report
- mv mochawesome_merged.html mochawesome_merged_${CI_PIPELINE_ID}.html // 以pipeline ID 重命名html报告,后续可以区分哪次pipeline进行的测试
发送测试报告
因为该方案是使用gitlab default runner进行测试,每次执行完成后无法通过default runner的文件目录(如果大家有的话,欢迎评论区留言),也因此读取测试报告的服务无法读取到default runner下的html报告. 所以我采取的方案是使用artifacts将测试报告下载并上传到mfs上,然后通过tomcat服务读取mfs目录下测试报告.
因此新建jenkins job,用于接收合并测试文件的CI_JOB_ID, 调用下载artifacts的api :
这里踩了个坑点,当我将触发jenkins下载的步骤放在merge时, 会发现下载下来的 artifacts.zip 是空
merge:
image: cypress/browsers:node16.14.2-slim-chrome100-ff99-edge
image: cypress/base:10
stage: merge
script:
- cd public
- npx mochawesome-merge *.json > mochawesome_merged.json
- npx marge mochawesome_merged.json // 该命令执行完仍然会在public文件夹内生成一个目录 mochawesome-report 此时并不是原存储测试结果的目录reportDir=mochawesome-report, (踩坑了,合并的测试报告在public下找不到,要到public/mochawesome-report下才能找到)
- cd mochawesome-report
- mv mochawesome_merged.html mochawesome_merged_${CI_PIPELINE_ID}.html // 以pipeline ID 重命名html报告,后续可以区分哪次pipeline进行的测试
- curl -X POST http://XXXXX.com/jenkins/view/%E6%9C%89%E9%81%93%E5%B9%BF%E5%91%8A/view/XXX/job/download-autotest-report/buildWithParameters?token=XXX -d "projectId=${CI_PROJECT_ID}&jobId=${CI_JOB_ID}"。// 此时下载下来的artfacts.zip是空
回头去看了下官方文档, 只有当前job执行完成后,才会将文件上传至gitlab
因此将下载的放在下一个job中 ,但在sendReports job中我们需要获取到上一Job: merge 的CI_JOB_ID,这样才可以通过api下载,因此要用到pipeline之间的变量传递:
merge:
image: cypress/browsers:node16.14.2-slim-chrome100-ff99-edge
image: cypress/base:10
stage: merge
script:
- cd public
- echo ${CI_JOB_ID}
- echo "MERGE_JOB_ID=${CI_JOB_ID}" >> merge.env // 将变量写成VARIABLE_NAME=ANY VALUE HERE.形式
- npx mochawesome-merge *.json > mochawesome_merged.json
- npx marge mochawesome_merged.json
- cd mochawesome-report
- mv mochawesome_merged.html mochawesome_merged_${CI_PIPELINE_ID}.html
when: always
artifacts:
reports:
dotenv: public/merge.env // 将变量文件存在reports/dotenv下
expire_in: 30 days
when: always
paths:
- public
sendReports:
image: cypress/browsers:node16.14.2-slim-chrome100-ff99-edge
stage: sendReports
script:
- echo ${MERGE_JOB_ID}
- curl -X POST http://XXX/jenkins/view/%E6%9C%89%E9%81%93%E5%B9%BF%E5%91%8A/view/XXX/job/download-autotest-report/buildWithParameters?token=XXX -d "projectId=${CI_PROJECT_ID}&jobId=${MERGE_JOB_ID}"
dependencies: // 执行添加dependencies
- merge
这里用到JOB传递变量:在job脚本中,将变量保存为.env文件。文件的格式必须是每行一个变量定义。每个定义的行必须是VARIABLE_NAME=ANY VALUE HERE.值可以用引号引起来,但不能包含换行符 .
然后通过TomCat获取html文件,下载tomcat: apache-tomcat-10.0.27.tar.gz , vim conf/server.xml下添加路径 :
sh bin/startup.sh, 启动服务后配置域名,https://cypress-report.XXX.com/mocha_report_pipelineId获取每次的测试报告, 后续如果有统计代码覆盖率等操作,也可以直接在mfs的报告中进行分析.
这里还有一个踩坑点,第二步中使用 npx marge mochawesome_merged.json后 会生成mochawesome- report文件夹 ,该文件夹下不仅会包含html文件,还会包含assets文件夹,如果仅将htmls上传到mfs下,读取的到html将是空白页,因此需要将assets文件夹也移动到读取测试报告的目录下
最后将测试报告连接发送给测试或研发同学,该步骤是通过前司运维同学封装好的gitlab的通知功能,直接通知到工作群,因此这一步不详细讲述
但这样存在一个问题, 测试报告中仅有连接,无法知道具体执行的情况,比如成功多少,失败多少, 因此需要在通知中加入这些信息. 回过头翻看json类型的报告发现,json报告中详细描述了本次测试的情况,可以读取信息写在通知中
merge:
image: cypress/browsers:node16.14.2-slim-chrome100-ff99-edge
image: cypress/base:10
stage: merge
only:
variables:
- $AUTO_TEST
before_script:
- apt-get update && apt-get install -y jq // 处理json数据使用
script:
- cd public
- echo ${CI_JOB_ID}
- echo ${OVERSEAS_PIPELINE_ID}
- echo "MERGE_JOB_ID=${CI_JOB_ID}" >> merge.env
- npx mochawesome-merge *.json > mochawesome_merged.json
- TEST_DATA=$(cat mochawesome_merged.json | jq -r '.stats')
- echo ${TEST_DATA}
- FAILURES=$(echo "$TEST_DATA" | jq '.failures')
- PASSES=$(echo "$TEST_DATA" | jq '.passes')
- TESTS=$(echo "$TEST_DATA" | jq '.tests')
- echo $FAILURES
- echo $PASSES
- echo $TESTS
- echo "FAILURES=${FAILURES}" >> merge.env
- echo "PASSES=${PASSES}" >> merge.env
- echo "TESTS=${TESTS}" >> merge.env
- npx marge mochawesome_merged.json
- cd mochawesome-report
- mv mochawesome_merged.html mochawesome_merged_${OVERSEAS_PIPELINE_ID}.html
when: always
artifacts:
reports:
dotenv: public/merge.env
expire_in: 30 days
when: always
paths:
- public
- screenshots_${OVERSEAS_PIPELINE_ID}
通知:
notify_success:
stage: notify
only:
variables:
- $AUTO_TEST
tags:
- k8s
image: XXX
variables:
MSG: 'CI PIPELINE SUCCESS'
script:
- echo $FAILURES
- echo $PASSES
- echo $TESTS
- if [[ $FAILURES -gt 0 ]]; then
MSG="UI上线回测结果 存在失败用例!\n共执行${TESTS}个用例\n成功${PASSES}个\n失败${FAILURES}个\n测试报告:https://cypress-report.XXXX.com/pipelineReports/mochawesome_merged_${OVERSEAS_PIPELINE_ID}.html";
else
MSG="UI上线回测结果 全部成功!\n共执行${TESTS}个用例\n成功${PASSES}个\n失败${FAILURES}个\n测试报告:https://cypress-report.XXX.com/pipelineReports/mochawesome_merged_${OVERSEAS_PIPELINE_ID}.html";
fi
- ydci notify XXX -t $USER_EMAIL -s "$CI_PROJECT_NAME" -m "${MSG}"
dependencies:
- merge
pipeline触发和状态等待
前端项目触发自动化项目pipeline
job:auto-test:
image: XXX
tags:
- k8s
stage: auto-test
rules:
- if: $DEPLOY_AUTOTEST == "true"
- if: $CI_COMMIT_REF_NAME == "master" && $CI_PIPELINE_SOURCE != "api" && $CI_PIPELINE_SOURCE != "trigger"
script:
- echo "start e2e test"
- pipelineId=$(curl --request POST --header "PRIVATE-TOKEN:XXX"
--header "Content-Type:application/json"
--data '{ "ref":"'master'","variables":[{"key":"BASE_URL", "value":"'XXX'"},{"key":"AUTO_TEST","value":"'true'"},{"key":"USER_EMAIL","value":"'${GITLAB_USER_EMAIL}'"},{"key":"OVERSEAS_PIPELINE_ID","value":"'${CI_PIPELINE_ID}'"},{"key":"NODE_ENV","value":"'test'"}]}'
"https://gitlab.corp.youdao.com/api/v4/projects/12604/pipeline" | awk -F[,:] '{print $2}') // 传参数
- echo $pipelineId
- sh checkAutoTestPipelineStatus.sh $pipelineId // 等待pipelineId
通过gitlab pipeline api等待pipeline状态
# check gitlab pipeline status until it success
status=$(curl --request GET --header "PRIVATE-TOKEN:XXXX" "https://gitlab.XXX.com/api/v4/projects/XXX/pipelines/$1" | awk -F[,:] '{print $10}'| awk -F'"' '{print $2}')
while [[ "$status" == "running" || "$status" == "pending" || "$status" == "created" ]]
do
echo "pipeline is running ......"
sleep 10
status=$(curl --request GET --header "PRIVATE-TOKEN:XXX" "https://gitlab.XXX.com/api/v4/projects/12604/pipelines/$1" | awk -F[,:] '{print $10}'| awk -F'"' '{print $2}')
done
if [[ "$status" == "success" ]]; then
echo "pipeline running successfully"
elif [[ "$status" == "failed" ]]; then
echo "pipeline running failure"
exit 1
else
echo "pipeline is $status"
exit 1
fi