W1-W3: 基线修复与测试基础设施搭建 - 修复 Jenkins JDK 21 兼容性,统一 E2E 目录,修复 storageState 冲突 - 搭建后端测试基类 BaseContractTest + Testcontainers PostgreSQL - 创建 TestDataFactory 链式构造,完善 Vitest 基座与 Playwright fixtures - 建立 docker-compose.test.yml 与测试数据隔离方案 W4-W5: 单元测试补齐(阶段 2) - 补齐 gym-member/gym-groupCourse/gym-checkIn/gym-payment 核心模块单元测试 - 补齐 gym-coach/manage-sys 模块单元测试 - 前端 utils/composables/stores 单元测试,37 文件 502 项测试 - JaCoCo 覆盖率门禁从 30% 调整至 55%,21 模块全部通过 W6-W7: 集成与契约测试(阶段 3) - Repository 集成测试:会员/团课/签到/支付关键表,Testcontainers 100% 通过 - Handler 集成测试:WebTestClient 覆盖正向/异常/权限路径 - 网关集成测试:JWT/RBAC/签名/限流/重试 - Flyway 迁移测试:验证迁移脚本可重复执行 - OpenAPI 契约测试:覆盖 ≥80% P0 接口,202 项契约测试 0 失败 - 跨模块契约测试:会员-支付-团课数据一致性 W8-W9: E2E 与用户旅程测试(阶段 4) - 管理员 Web 核心流程 E2E:用户/角色/菜单/字典/配置 - 小程序会员端核心页面 E2E:购卡/预约/签到 - 5 条 P0 用户旅程全链路自动化,60 条 journey 测试 0 失败 W10: 变异测试与质量门禁(阶段 5) - 后端 PIT 配置:pitest-maven 1.19.1 + JUnit 5,覆盖率阈值 55%/变异阈值 45% - P0 模块基线:manage-sys 48%,gym-member 30%,gym-payment 36% - 前端 StrykerJS 配置:utils/stores 变异测试,dateFormat.ts 70.83% - Jenkins 质量门禁:JaCoCo/PIT/E2E 统一检查,不达标阻断构建 W11: 持续运行与改进(阶段 6) - 测试指标收集脚本 scripts/collect-test-metrics.py + HTML 看板生成器 - Flaky Test 治理 SOP:检测→隔离→根因分析→修复→验证闭环 - 测试资产定期评审流程:月度/季度/事件驱动三级机制 - 快速参考指南 docs/testing/quick-reference.md - 累计 10 份测试文档,7 个里程碑全部达成
552 lines
22 KiB
Groovy
552 lines
22 KiB
Groovy
pipeline {
|
||
agent any
|
||
|
||
environment {
|
||
// 项目配置
|
||
PROJECT_NAME = 'gym-manage-system'
|
||
FRONTEND_DIR = 'gym-manage-web'
|
||
BACKEND_DIR = 'gym-manage-api'
|
||
|
||
// Node.js 配置
|
||
NODE_VERSION = '20'
|
||
PNPM_VERSION = '8.15.0'
|
||
|
||
// Java 配置(项目使用 Java 21,与 gym-manage-api/pom.xml 保持一致)
|
||
JAVA_VERSION = '21'
|
||
MAVEN_VERSION = '3.9.0'
|
||
|
||
// Docker 配置
|
||
DOCKER_REGISTRY = credentials('docker-registry')
|
||
DOCKER_IMAGE_FRONTEND = "${PROJECT_NAME}-frontend"
|
||
DOCKER_IMAGE_BACKEND = "${PROJECT_NAME}-backend"
|
||
|
||
// 数据库配置(用于E2E测试)
|
||
DB_HOST = 'localhost'
|
||
DB_PORT = '5432'
|
||
DB_NAME = 'gym_test'
|
||
DB_USER = credentials('db-user')
|
||
DB_PASSWORD = credentials('db-password')
|
||
|
||
// 测试配置
|
||
TEST_TIMEOUT = '30'
|
||
RETRY_COUNT = '2'
|
||
|
||
// ===== 质量门禁阈值 =====
|
||
// 后端 JaCoCo 指令覆盖率(与 pom.xml 保持一致)
|
||
JACOCO_INSTRUCTION_THRESHOLD = '0.55'
|
||
// 后端 PIT 变异分数
|
||
PIT_MUTATION_THRESHOLD = '45'
|
||
// 前端 StrykerJS 变异分数
|
||
STRYKE_MUTATION_THRESHOLD = '40'
|
||
// E2E P0 通过率要求
|
||
E2E_PASS_THRESHOLD = '100'
|
||
// flaky 率阈值(百分比)
|
||
FLAKY_THRESHOLD = '3'
|
||
}
|
||
|
||
tools {
|
||
nodejs "NodeJS-${NODE_VERSION}"
|
||
maven "Maven-${MAVEN_VERSION}"
|
||
jdk "JDK-${JAVA_VERSION}" // Jenkins 管理节点需预先配置 JDK-21 工具
|
||
}
|
||
|
||
stages {
|
||
stage('环境准备') {
|
||
steps {
|
||
echo '🔧 准备构建环境...'
|
||
sh '''
|
||
# 安装 pnpm
|
||
npm install -g pnpm@${PNPM_VERSION}
|
||
|
||
# 验证工具版本
|
||
node --version
|
||
pnpm --version
|
||
java -version
|
||
mvn --version
|
||
'''
|
||
}
|
||
}
|
||
|
||
stage('代码检查') {
|
||
parallel {
|
||
stage('前端代码检查') {
|
||
steps {
|
||
dir(FRONTEND_DIR) {
|
||
echo '🔍 执行前端代码检查...'
|
||
sh '''
|
||
pnpm install
|
||
pnpm run lint
|
||
pnpm run type-check
|
||
'''
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('后端代码检查') {
|
||
steps {
|
||
dir(BACKEND_DIR) {
|
||
echo '🔍 执行后端代码检查...'
|
||
sh 'mvn clean compile -DskipTests'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('单元测试') {
|
||
parallel {
|
||
stage('前端单元测试') {
|
||
steps {
|
||
dir(FRONTEND_DIR) {
|
||
echo '🧪 执行前端单元测试...'
|
||
sh 'pnpm run test:unit'
|
||
}
|
||
}
|
||
post {
|
||
always {
|
||
dir(FRONTEND_DIR) {
|
||
// 发布测试报告
|
||
publishHTML(target: [
|
||
allowMissing: false,
|
||
alwaysLinkToLastBuild: true,
|
||
keepAll: true,
|
||
reportDir: 'coverage',
|
||
reportFiles: 'index.html',
|
||
reportName: '前端单元测试覆盖率报告'
|
||
])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('后端单元测试') {
|
||
steps {
|
||
dir(BACKEND_DIR) {
|
||
echo '🧪 执行后端单元测试...'
|
||
sh 'mvn test'
|
||
}
|
||
}
|
||
post {
|
||
always {
|
||
dir(BACKEND_DIR) {
|
||
// 发布测试报告
|
||
junit '**/target/surefire-reports/*.xml'
|
||
|
||
// 收集 Allure 结果到统一目录
|
||
sh '''
|
||
mkdir -p target/allure-results
|
||
find . -path '*/target/allure-results/*.json' -exec cp {} target/allure-results/ \;
|
||
'''
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('构建') {
|
||
parallel {
|
||
stage('前端构建') {
|
||
steps {
|
||
dir(FRONTEND_DIR) {
|
||
echo '📦 构建前端项目...'
|
||
sh '''
|
||
pnpm run build:prod
|
||
|
||
# 创建构建产物归档
|
||
tar -czf frontend-dist.tar.gz dist/
|
||
'''
|
||
}
|
||
}
|
||
post {
|
||
success {
|
||
archiveArtifacts artifacts: "${FRONTEND_DIR}/frontend-dist.tar.gz", fingerprint: true
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('后端构建') {
|
||
steps {
|
||
dir(BACKEND_DIR) {
|
||
echo '📦 构建后端项目...'
|
||
sh '''
|
||
mvn clean package -DskipTests
|
||
|
||
# 创建构建产物归档
|
||
tar -czf backend-jars.tar.gz */target/*.jar
|
||
'''
|
||
}
|
||
}
|
||
post {
|
||
success {
|
||
archiveArtifacts artifacts: "${BACKEND_DIR}/backend-jars.tar.gz", fingerprint: true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('报告聚合') {
|
||
steps {
|
||
echo '📊 生成聚合测试报告...'
|
||
dir(BACKEND_DIR) {
|
||
sh 'mvn verify -DskipTests -pl manage-test-report -am'
|
||
}
|
||
}
|
||
post {
|
||
always {
|
||
dir(BACKEND_DIR) {
|
||
// 发布 JaCoCo 聚合覆盖率报告
|
||
publishHTML(target: [
|
||
allowMissing: false,
|
||
alwaysLinkToLastBuild: true,
|
||
keepAll: true,
|
||
reportDir: 'manage-test-report/target/site/jacoco-aggregate',
|
||
reportFiles: 'index.html',
|
||
reportName: '后端覆盖率聚合报告'
|
||
])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('E2E测试') {
|
||
steps {
|
||
echo '🎭 执行E2E测试...'
|
||
dir(FRONTEND_DIR) {
|
||
sh '''
|
||
# 安装Playwright浏览器
|
||
pnpm exec playwright install --with-deps chromium
|
||
|
||
# 执行E2E测试
|
||
pnpm run test:e2e:journeys
|
||
'''
|
||
}
|
||
}
|
||
post {
|
||
always {
|
||
dir(FRONTEND_DIR) {
|
||
// 发布 JUnit 格式结果
|
||
junit 'test-results/junit.xml'
|
||
|
||
// 发布E2E测试报告
|
||
publishHTML(target: [
|
||
allowMissing: false,
|
||
alwaysLinkToLastBuild: true,
|
||
keepAll: true,
|
||
reportDir: 'test-results',
|
||
reportFiles: 'custom-report.html',
|
||
reportName: 'E2E测试报告'
|
||
])
|
||
|
||
// 归档测试失败截图和视频
|
||
archiveArtifacts artifacts: 'test-results/**/*.png, test-results/**/*.webm', allowEmptyArchive: true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 阶段 5:变异测试(仅在 main 分支或 nightly 构建中执行) =====
|
||
stage('变异测试') {
|
||
when {
|
||
anyOf {
|
||
branch 'main'
|
||
branch 'develop'
|
||
expression { return params.NIGHTLY_BUILD ?: false }
|
||
}
|
||
}
|
||
parallel {
|
||
stage('后端 PIT 变异测试') {
|
||
steps {
|
||
echo '🧬 执行后端 PIT 变异测试(manage-sys 模块)...'
|
||
dir(BACKEND_DIR) {
|
||
sh '''
|
||
mvn org.pitest:pitest-maven:mutationCoverage -pl manage-sys \\
|
||
-Dmaven.test.failure.ignore=true 2>&1 | tee pit-report.log
|
||
|
||
# 提取变异分数
|
||
PIT_SCORE=$(grep -oP 'Generated \\d+ mutations Killed \\d+ \\(\\K\\d+' pit-report.log | tail -1)
|
||
echo "PIT Mutation Score: ${PIT_SCORE}%"
|
||
|
||
# 检查是否达到阈值
|
||
if [ "${PIT_SCORE}" -lt ${PIT_MUTATION_THRESHOLD} ]; then
|
||
echo "WARNING: PIT mutation score ${PIT_SCORE}% is below threshold ${PIT_MUTATION_THRESHOLD}%"
|
||
else
|
||
echo "PIT mutation score ${PIT_SCORE}% meets threshold ${PIT_MUTATION_THRESHOLD}%"
|
||
fi
|
||
'''
|
||
}
|
||
}
|
||
post {
|
||
always {
|
||
dir("${BACKEND_DIR}/manage-sys/target/pit-reports") {
|
||
publishHTML(target: [
|
||
allowMissing: true,
|
||
alwaysLinkToLastBuild: true,
|
||
keepAll: true,
|
||
reportDir: '.',
|
||
reportFiles: 'index.html',
|
||
reportName: '后端 PIT 变异测试报告'
|
||
])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('前端 StrykerJS 变异测试') {
|
||
steps {
|
||
echo '🧬 执行前端 StrykerJS 变异测试...'
|
||
dir(FRONTEND_DIR) {
|
||
sh '''
|
||
pnpm run test:mutation 2>&1 | tee stryker-report.log
|
||
|
||
# 提取变异分数
|
||
STRYKE_SCORE=$(grep -oP 'Mutation score.*?\\K\\d+\\.\\d+' stryker-report.log | tail -1)
|
||
echo "StrykerJS Mutation Score: ${STRYKE_SCORE}%"
|
||
'''
|
||
}
|
||
}
|
||
post {
|
||
always {
|
||
dir("${FRONTEND_DIR}/reports/mutation") {
|
||
publishHTML(target: [
|
||
allowMissing: true,
|
||
alwaysLinkToLastBuild: true,
|
||
keepAll: true,
|
||
reportDir: '.',
|
||
reportFiles: 'mutation.html',
|
||
reportName: '前端 StrykerJS 变异测试报告'
|
||
])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 质量门禁:统一检查所有质量标准 =====
|
||
stage('质量门禁') {
|
||
steps {
|
||
echo '🛡️ 执行质量门禁检查...'
|
||
script {
|
||
def qualityGatePassed = true
|
||
def gateMessages = []
|
||
|
||
// 1. 检查后端 JaCoCo 覆盖率
|
||
dir(BACKEND_DIR) {
|
||
def jacocoReport = "manage-test-report/target/site/jacoco-aggregate/jacoco.csv"
|
||
if (fileExists(jacocoReport)) {
|
||
// 解析 JaCoCo 报告提取指令覆盖率
|
||
def totalInstr = sh(
|
||
script: "awk -F',' 'NR>1{sum+=\$5;covered+=\$6}END{printf \"%.2f\", covered/sum}' ${jacocoReport}",
|
||
returnStdout: true
|
||
).trim()
|
||
echo "后端 JaCoCo 指令覆盖率: ${totalInstr}"
|
||
def threshold = JACOCO_INSTRUCTION_THRESHOLD.toDouble()
|
||
if (totalInstr.toDouble() < threshold) {
|
||
qualityGatePassed = false
|
||
gateMessages.add("❌ 后端指令覆盖率 ${totalInstr}% 低于阈值 ${threshold * 100}%")
|
||
} else {
|
||
gateMessages.add("✅ 后端指令覆盖率 ${totalInstr}% 达标(阈值 ${threshold * 100}%)")
|
||
}
|
||
} else {
|
||
gateMessages.add("⚠️ JaCoCo 报告未找到,跳过覆盖率检查")
|
||
}
|
||
}
|
||
|
||
// 2. 检查 PIT 变异分数(仅当变异测试执行过)
|
||
if (fileExists("${BACKEND_DIR}/manage-sys/target/pit-reports/mutations.xml")) {
|
||
def pitScore = sh(
|
||
script: "grep -oP 'mutationCoverage=\"\\K\\d+\\.?\\d*' ${BACKEND_DIR}/manage-sys/target/pit-reports/mutations.xml | head -1",
|
||
returnStdout: true
|
||
).trim()
|
||
if (pitScore) {
|
||
echo "后端 PIT 变异分数: ${pitScore}%"
|
||
def pitThreshold = PIT_MUTATION_THRESHOLD.toInteger()
|
||
if (pitScore.toInteger() < pitThreshold) {
|
||
qualityGatePassed = false
|
||
gateMessages.add("❌ 后端 PIT 变异分数 ${pitScore}% 低于阈值 ${pitThreshold}%")
|
||
} else {
|
||
gateMessages.add("✅ 后端 PIT 变异分数 ${pitScore}% 达标(阈值 ${pitThreshold}%)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 检查 E2E 测试通过率
|
||
def e2eResults = "${FRONTEND_DIR}/test-results/junit.xml"
|
||
if (fileExists(e2eResults)) {
|
||
def e2ePassRate = sh(
|
||
script: "python3 -c \"
|
||
import xml.etree.ElementTree as ET
|
||
tree = ET.parse('${e2eResults}')
|
||
root = tree.getroot()
|
||
total = int(root.get('tests', '0'))
|
||
failures = int(root.get('failures', '0'))
|
||
errors = int(root.get('errors', '0'))
|
||
pass_rate = ((total - failures - errors) / total * 100) if total > 0 else 0
|
||
print(f'{pass_rate:.1f}')
|
||
\"",
|
||
returnStdout: true
|
||
).trim()
|
||
echo "E2E 测试通过率: ${e2ePassRate}%"
|
||
if (e2ePassRate.toDouble() < E2E_PASS_THRESHOLD.toDouble()) {
|
||
qualityGatePassed = false
|
||
gateMessages.add("❌ E2E 通过率 ${e2ePassRate}% 未达到 100%")
|
||
} else {
|
||
gateMessages.add("✅ E2E 测试全部通过")
|
||
}
|
||
}
|
||
|
||
// 4. 汇总结果
|
||
echo '=== 质量门禁检查结果 ==='
|
||
for (msg in gateMessages) {
|
||
echo msg
|
||
}
|
||
|
||
if (!qualityGatePassed) {
|
||
error('❌ 质量门禁未通过,阻断构建!')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 测试指标收集与看板 =====
|
||
stage('测试指标收集') {
|
||
steps {
|
||
echo '📊 收集测试指标并生成看板...'
|
||
sh '''
|
||
# 收集测试指标
|
||
python3 scripts/collect-test-metrics.py \
|
||
--project-dir . \
|
||
--build-number "${BUILD_NUMBER}" \
|
||
--output-dir target/test-metrics \
|
||
--history-dir target/test-metrics/history
|
||
|
||
# 生成看板
|
||
python3 scripts/generate-dashboard.py \
|
||
--project-dir . \
|
||
--history-dir target/test-metrics/history \
|
||
--output target/test-metrics/dashboard.html
|
||
'''
|
||
}
|
||
post {
|
||
always {
|
||
// 归档历史指标数据
|
||
archiveArtifacts artifacts: 'target/test-metrics/**/*', allowEmptyArchive: true
|
||
|
||
// 发布看板
|
||
publishHTML(target: [
|
||
allowMissing: true,
|
||
alwaysLinkToLastBuild: true,
|
||
keepAll: true,
|
||
reportDir: 'target/test-metrics',
|
||
reportFiles: 'dashboard.html',
|
||
reportName: '测试质量看板'
|
||
])
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('构建Docker镜像') {
|
||
when {
|
||
branch 'develop'
|
||
}
|
||
steps {
|
||
echo '🐳 构建Docker镜像...'
|
||
|
||
// 构建前端镜像
|
||
dir(FRONTEND_DIR) {
|
||
sh """
|
||
docker build -t ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER} .
|
||
docker tag ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER} ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:latest
|
||
"""
|
||
}
|
||
|
||
// 构建后端镜像
|
||
dir(BACKEND_DIR) {
|
||
sh """
|
||
docker build -t ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER} .
|
||
docker tag ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER} ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:latest
|
||
"""
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('推送Docker镜像') {
|
||
when {
|
||
branch 'develop'
|
||
}
|
||
steps {
|
||
echo '📤 推送Docker镜像到仓库...'
|
||
sh """
|
||
docker push ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER}
|
||
docker push ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:latest
|
||
docker push ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER}
|
||
docker push ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:latest
|
||
"""
|
||
}
|
||
}
|
||
|
||
stage('部署到测试环境') {
|
||
when {
|
||
branch 'develop'
|
||
}
|
||
steps {
|
||
echo '🚀 部署到测试环境...'
|
||
sh """
|
||
echo "部署前端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER}"
|
||
echo "部署后端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER}"
|
||
"""
|
||
}
|
||
}
|
||
|
||
stage('部署到生产环境') {
|
||
when {
|
||
branch 'main'
|
||
}
|
||
steps {
|
||
echo '🚀 部署到生产环境...'
|
||
input message: '确认部署到生产环境?', ok: '确认部署'
|
||
|
||
sh """
|
||
echo "部署前端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER}"
|
||
echo "部署后端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER}"
|
||
"""
|
||
}
|
||
}
|
||
}
|
||
|
||
post {
|
||
always {
|
||
echo '📊 聚合 Allure 报告...'
|
||
allure([
|
||
includeProperties: false,
|
||
jdk: '',
|
||
properties: [],
|
||
reportBuildPolicy: 'ALWAYS',
|
||
results: [
|
||
[path: 'gym-manage-api/target/allure-results'],
|
||
[path: 'gym-manage-web/allure-results']
|
||
]
|
||
])
|
||
|
||
echo '🧹 清理工作空间...'
|
||
cleanWs()
|
||
}
|
||
|
||
success {
|
||
echo '✅ 流水线执行成功!'
|
||
// 可以添加通知,例如发送邮件或Slack消息
|
||
}
|
||
|
||
failure {
|
||
echo '❌ 流水线执行失败!'
|
||
// 可以添加失败通知
|
||
}
|
||
|
||
unstable {
|
||
echo '⚠️ 流水线执行不稳定!'
|
||
// 可以添加不稳定状态通知
|
||
}
|
||
}
|
||
} |