系列专栏:测试工程师每日一博 · Day 7
日期:2026-07-13
关键词:CI/CD 流水线、测试分层、GitHub Actions、Jenkins、测试金字塔、flaky 测试、并行执行
阅读对象:已经写过单元/集成/接口/性能测试,但还没有把这些测试组织进 CI 流水线的工程师
开篇:为什么"测试金字塔"画得好看却落不了地
上一篇 Day 6 聊完性能测试,这个系列从单元 → Mockito → 覆盖率 → Testcontainers → REST Assured/WireMock → 性能测试,六层测试基本都集齐了。如果你跟着每篇都把代码跑起来了,手头应该已经有一整套测试套件了。
但接下来这个问题最常见:这些测试散落在 IDE 里手动点 Run,什么时候跑、谁负责跑、跑多久、跑挂了怎么办,全都靠"想起来"。聊到 CI 流水线,大多数团队的现状是这三种之一:
- 没有 CI------还是手工打包部署,事故全在生产现场爆破。
- CI 一条龙------所有测试串成一个 job,跑 40 分钟,大家都不等了直接 merge。
- CI 是个门面------有流水线,但配置混乱,挂在哪一层、为什么挂、要不要 block merge,没人能说清楚。
画金字塔很容易,让金字塔在 CI 里"真正运转起来"才是测试工程师真正的活儿。这一篇就来拆解------分层测试怎么分层地塞进 CI。
一、CI 中测试的金字塔重构:不是"跑测试",是"分层门控"
回到测试金字塔本身:70% 单元 / 20% 集成 / 10% E2E。这个比例在 CI 语境里有两个隐藏含义:
- 运行速度差异巨大------单元测试应该秒级,集成测试应该分钟级,E2E/性能测试十分钟级。
- 价值密度差异巨大------单元测试最便宜发现 bug,集成测试贵但更接近真实,E2E 最贵但最可靠。
所以 CI 里的测试组织原则就一句话:
越底层的测试越早跑、越快跑、越是被 merge 门控;越上层的测试越晚跑、越慢跑、越是异步/夜间跑。
这条原则有个落地动作------把所有测试按"层 "组织,每一层都用一个独立的 job / stage ,带着独立的触发条件 和独立的失败后果。这才叫"分层"。
二、第一步:测试套件分类约定(没这步后面全乱)
CI 里能分层的前提是------代码里就是分层的。Java 生态里,这是用 Maven Surefire vs Failsafe 的命名约定来做的,但很多人忽略了它:
| 测试类型 | 命名约定 | 执行插件 | Maven 命令 |
|---|---|---|---|
| 单元测试 | *Test.java、Test*.java |
maven-surefire-plugin |
mvn test |
| 集成测试 | *IT.java、IT*.java |
maven-failsafe-plugin |
mvn verify |
| 性能/E2E | 自定义后缀,如 *PT.java |
自定义或 Bender/JMeter Maven Plugin | 自定义 goal |
关键点:
- Surefire 默认绑定
test阶段,跑*Test - Failsafe 默认绑定
integration-test+verify阶段,跑*IT(且默认不 在test阶段执行) - Gradle 可以用
Testtask 拆分:unitTest { include '**/*Test.class' }/integrationTest { include '**/*IT.class' }
命名约定是契约。一旦你在 CI 里写了 if 跑慢了就跳过 IT,前提就是"IT 类必须严格以 IT 结尾"。这是 CI 分层的第一块基石。
三、第一层门控:pre-commit 与本地预跑(让单元测试快到看不见)
CI 不是测试最早发生的地方。最早应该是开发者本地。
一个理想的反馈链长这样:
保存文件 → IDE 增量测试(<2 秒)
↓
git commit → pre-commit hook 跑当前模块单元测试(<10 秒)
↓
git push → CI 触发:全量单元测试(<60 秒)
↓
PR 创建 → CI 触发:单元 + 集成测试(<10 分钟)
↓
merge → main 分支 → CI 触发:部署 + 冒烟(自动)
↓
每晚 → CI 触发:性能测试 + 全量 E2E(异步,30-60 分钟)
每层之间,反馈时间放大 5~10 倍,但跑的内容也放大 5~10 倍。前两层是"开发者必须马上知道",后两层是"明天起来再说"。
pre-commit hook 一个最朴素的样子(Git Hook + Maven):
bash
# .git/hooks/pre-commit
#!/bin/sh
echo "[pre-commit] 跑当前模块单元测试..."
# 只跑有改动的 Maven 模块的单元测试
CHANGED_MODULES=$(git diff --cached --name-only | grep "^[^/]*" | cut -d/ -f1 | sort -u)
for mod in $CHANGED_MODULES; do
if [ -f "$mod/pom.xml" ]; then
(cd "$mod" && mvn -B -q test -Dtest='*Test' \
-Dsurefire.failIfNoSpecifiedTests=false)
if [ $? -ne 0 ]; then
echo "❌ 单元测试失败,commit 被拒"
exit 1
fi
fi
done
echo "✅ 单元测试通过,允许 commit"
这段 hook 不能太慢 ,否则开发者会忍不住 git commit --no-verify。一个铁律:pre-commit hook 的执行时间不能超过 15 秒。如果你的单元测试套件跑不了这么快,就只跑"和改动文件相关的部分",而不是全量。
四、第二层门控:PR 触发的 CI 主流程(GitHub Actions 实战)
下面是一个真实可用的 GitHub Actions 工作流,做了分层并行 + 失败门控两件事:
yaml
# .github/workflows/pr.yml
name: PR Check
on:
pull_request:
branches: [main, develop]
jobs:
# ------ 第一层:单元测试(并行 + 矩阵)------
unit-test:
name: Unit Test (JDK ${{ matrix.java }})
runs-on: ubuntu-latest
strategy:
matrix:
java: [17, 21]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java }}
distribution: temurin
cache: maven
- name: 跑单元测试
run: mvn -B test
- name: 上传覆盖率报告
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-jdk${{ matrix.java }}
path: '**/target/site/jacoco/'
# ------ 第二层:集成测试(依赖单元测试通过)------
integration-test:
name: Integration Test
needs: unit-test
runs-on: ubuntu-latest
services:
# 用 Testcontainers 时不需要这;性能优化见后
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports: [5432:5432]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: 17, distribution: temurin, cache: maven }
- name: 跑集成测试(只跑 *IT)
run: mvn -B verify -DskipUnitTests \
-DfailIfNoTests=false
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/postgres
SPRING_DATASOURCE_USERNAME: postgres
SPRING_DATASOURCE_PASSWORD: test
# ------ 第三层:覆盖率门控(独立检查)------
coverage-gate:
name: Coverage Gate
needs: unit-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: 17, distribution: temurin, cache: maven }
- name: 跑测试 + 检查覆盖率阈值
run: |
mvn -B verify -DskipITs
# JaCoCo 阈值检查
awk '/Total/ { linecov=$NF; exit }' target/site/jacoco/jacoco.csv
- name: 检查覆盖率是否下降
run: |
mvn -B jacoco:check
# pom.xml 里配置 <rule> 行覆盖 >= 70% 才 pass
# ------ 最终门控:把所有 job 综合成一个 status check ------
pr-gate:
name: PR Gate
needs: [unit-test, integration-test, coverage-gate]
runs-on: ubuntu-latest
steps:
- run: echo "所有分层测试通过,允许 merge"
这份 yml 里有几个关键设计:
- 并行矩阵:JDK 17 + 21 同时跑,验证升级兼容性,但只跑单元测试,因为快。
needs:显式依赖:集成测试必须等单元测试通过------避免"集成测试挂了,才发现是单元测试早该挂的问题",省 CI 时间。- 覆盖率门控独立 job:覆盖率不达标是另一种失败,与单元测试逻辑无关。
pg用services:而非 Testcontainers :CI 跑 Postgres 容器比 Testcontainers 启动快一倍,因为是 GitHub Actions 的 Docker-in-Docker 优化路径。这跟 Day 4 推荐的"本地必用 Testcontainers"不矛盾------本地和 CI 用什么容器策略,可以是不同的。- pr-gate job:把 3 个 job 的状态汇成一个,方便在 GitHub 的 Branch Protection 里只挂一个必须 status check。
五、第三层门控:夜间跑性能测试 + 全量 E2E
性能测试和 E2E 测试不适合放在 PR 流程里------它们太慢,而且一个 PR 不应该被性能回归阻塞 (性能回归通常是累积效应,不是单 PR 引入)。所以正确做法是异步在夜间跑。
yaml
# .github/workflows/nightly.yml
name: Nightly Full Test
on:
schedule:
- cron: '0 18 * * *' # UTC 18:00,北京时间凌晨 2 点
workflow_dispatch: # 也允许手动触发
jobs:
performance-test:
name: Performance Test
runs-on: ubuntu-latest-4-core # 性能测试需要更强机器
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: 17, distribution: temurin, cache: maven }
- name: 启动测试环境
run: docker compose up -d
- name: 冒烟压测(确认服务起来了)
run: |
for i in {1..30}; do
curl -fs http://localhost:8080/actuator/health && break
sleep 2
done
- name: 跑 JMeter 测试计划
run: |
jmeter -n -t perf/load_test.jmx \
-l target/jmeter-results.jtl \
-e -o target/jmeter-report
- name: 解析结果并检查 SLO
run: |
# 解析 jtl,检查 P99 < 500ms 这条 SLO
P99=$(awk -F, 'NR>1 {print $1}' target/jmeter-results.jtl | \
sort -n | awk 'NR==int('$COUNT'*0.99)')
if [ $(echo "$P99 > 500" | bc) -eq 1 ]; then
echo "❌ P99 ($P99 ms) 超出 SLO (500 ms)"
exit 1
fi
- name: 失败时建 Issue
if: failure()
uses: peter-evans/create-issue-from-file@v5
with:
title: '[Nightly] 性能测试回归 --- $(date +%Y-%m-%d)'
content-filepath: target/jmeter-report/index.html
labels: perf-regression,priority/P0
夜间 job 的设计原则:
- 失败要可见:不能默默挂在 GitHub Actions,要主动建 Issue。性能回归是 P0,值得 Issue + 通知。
- 失败要保留现场:HTML 报告必须 upload artifact,否则周一上班根本不知道为什么挂。
- 失败不阻塞 main:不要因为夜间性能回归就 lock main 分支,但要有清晰的"48 小时内必须修复"流程。
六、各层运行时长:有没有"应该 X 分钟以内"这种事
下面这张表是我用过的一个经验阈值,不是行业标准,但你拿来当起点是合适的:
| 层 | 触发 | 目标时长 | 超过就告警 |
|---|---|---|---|
| pre-commit hook | 本地 commit | < 15s | 改 hook 配置 |
| 单元测试(PR) | PR 创建/更新 | < 90s | 加并行 + 删慢测试 |
| 集成测试(PR) | 单元测试通过后 | < 8min | 拆模块或换容器策略 |
| 覆盖率检查(PR) | 单元测试通过后 | < 2min | 缓存 Maven 依赖 |
| 性能测试(Nightly) | 每天凌晨 | < 30min | 拆场景或减少 throughput |
一个非常重要的纪律:这些阈值要 CI 里实时监控,超了就告警。GitHub Actions 有个简单做法------给每个 job 末尾加一个时间检查:
yaml
- name: 检查单元测试是否超时
run: |
DURATION=$(echo "${{ steps.unit-test.outputs.time }}")
if [ "$DURATION" -gt 90 ]; then
echo "::warning::单元测试耗时 ${DURATION}s,超过 90s 阈值"
fi
或者改用 Datadog/Prometheus 监控 CF/CD 指标,做成时序图。"CI 跑 40 分钟没人等"的根本原因不是 CI 慢,而是没有人在监控 CI 的时长趋势。
七、CI 反模式四连击(自己拍拍脑袋看看有没有中)
反模式 1:所有测试一个 job
yaml
# ❌ 反面教材
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: mvn verify # 包含单元 + 集成 + 性能
后果:一个挂全挂,定位痛苦;并行不了一点;PR 反馈 20 分钟起。
反模式 2:测试失败不阻塞 merge
yaml
# ❌ 反面教材
- run: mvn verify || true # 失败也继续
或 Branch Protection 没勾选必须 status check。CI 变成了门面装饰。
反模式 3:覆盖率只看一眼,不门控
yaml
# ❌ 反面教材
- run: mvn verify
- name: 查看覆盖率
run: cat target/site/jacoco/index.html
人类才懒得点开看 HTML。覆盖率必须 jacoco:check 强制阈值,否则就是装饰。
反模式 4:在 PR 流程里跑 E2E/性能测试
PR 反馈 MUST 在 10 分钟内,E2E + 性能 MUST > 10 分钟。把它们塞一起必然互相拖累。
八、最难的题:flaky 测试
聊 CI 不能不聊 flaky。flaky 测试的定义:同一个 commit 在 CI 上跑,有时过有时不过。
flaky 的常见根因(下列都是见过真事):
- 测试依赖时间------
assertEquals(LocalDateTime.now(), entity.getCreatedAt()) - 测试依赖并发顺序------多线程测试用
Thread.sleep(100)而不是awaitility - 测试依赖外部状态------集成测试改了数据库但没回滚
- 测试依赖网络------调用了真实第三方 API
- 测试相互依赖------
testA改的 static 变量污染了testB
flaky 测试的 CI 政策必须事先定下来,不然会演化成两种死法:
死法 A:flaky 测试没人管。 一周后大家都开始 @Ignore 或在 CI 末尾加 || true,测试套件整体失信,等于没测试。
死法 B:flaky 测试每次失败就阻塞 merge。 开发者花一天时间排查"为什么这条测试在我电脑上 100 次过,在 CI 上 30% 概率过",最后怒删测试。
健康的 flaky 政策长这样:
- 检测 ------CI 自动记录"过去 7 天,这条测试在同一个 commit 上失败过但重试过了"。GitHub Actions 有
re-actor/automerge-for-flaky-tests之类的 Action 可以做。 - 隔离 ------把 flaky 测试自动移到
flaky/目录,从主流程剥离,加@Tag("flaky")标签。 - 修复期限 ------flaky 测试必须在 7 天内 fix,否则删除。Fix or remove,没有第三条路。
- 重试 ≠ 修复 ------很多人加
@Retry(3)让它"碰运气过",这是把问题塞到地毯下面。重试只能用在测试本身已正确但偶发外部不稳定的情况(如真实网络调用),且必须搭配失败日志。
九、把金字塔接回 CI:一张总图
把这一篇所有内容拼起来,理想的 CI 测试金字塔长这样:
┌──────────────┐
│ Nightly │ 性能/E2E/全量回归 (<30min)
└──────┬───────┘
│ 每晚定时 + 手动触发
┌──────▼───────┐
│ PR Verify │ 集成测试(<8min) + 覆盖率门控
└──────┬───────┘
│ PR 创建/更新触发
┌──────▼───────┐
│ PR Pre-merge│ 单元测试(<90s)+ 静态扫描
└──────┬───────┘
│ push 触发
┌──────▼───────┐
│ Local Hook │ 当前模块单元测试(<15s)
└──────┬───────┘
│ pre-commit 触发
┌──────▼───────┐
│ IDE Incremental│ 单文件增量(<2s)
└──────────────┘
真实测试金字塔的"运行时倒影"
关键比喻:这是金字塔的"运行时倒影" ------画图时三角形底大顶小,但运行时金字塔是底矮顶高 的------越底层的测试频率越高、延迟越低 ,越顶层的测试频率越低、延迟越高。
画图维度上的"底层测试多" = 时间维度上的"底层测试快"。这才是金字塔的真正奥义。
十、思考题
- 你团队 CI 跑全套测试要多久?哪些测试必须在 PR 流程里,哪些其实可以挪到 nightly?
- pre-commit hook 在你们团队是否启用?如果没有,是因为不知道,还是因为套件太慢?
- 测试名约定是否清晰?能不能一眼看出某个测试属于哪一层?如果不能,你能不能定一个命名规范?
- 团队里有没有"被默许 flaky"的测试?它在持续多久了?
- 如果你只有 30 分钟重构 CI,你会先做什么?(评论区聊聊)
TL;DR
- CI 里的测试分层 = 测试套件分类命名 → 每层独立 job / stage → 每层独立触发 + 独立门控。
- 反馈链:IDE 增量 → pre-commit hook → PR 单元测试 → PR 集成测试 → nightly 性能测试。每层放大 5~10 倍时间和价值。
- PR 流程必跑单元测试(<90s)+ 集成测试(<8min);性能测试/E2E 必须挪到 nightly。
- 测试命名的契约(Maven
*Testvs*ITvs*PT)是分层的基石。 - 覆盖率必须
jacoco:check强制门控,不能人工巡查。 - flaky 测试健康政策:检测 → 隔离 → 7 天修复 → fix or remove。
- "CI 跑 40 分钟没人等"的根因不是 CI 慢,是没人监控 CI 时长趋势。
明天 Day 8:《测试驱动开发(TDD)实战------红绿重构循环真的有那么神吗》 ------
这套理论里我们一直在用 assertThrows、在想"应该测什么",但 TDD 那套"先写测试再写代码"到底应不应该硬性推?它适合什么场景、不适合什么场景?过高估计 TDD 和过低估计 TDD 各有什么代价?
如果这篇对你有帮助,欢迎点赞收藏 ⭐。下篇见。