CI 中的测试分层:让金字塔真正落地

系列专栏:测试工程师每日一博 · Day 7

日期:2026-07-13

关键词:CI/CD 流水线、测试分层、GitHub Actions、Jenkins、测试金字塔、flaky 测试、并行执行

阅读对象:已经写过单元/集成/接口/性能测试,但还没有把这些测试组织进 CI 流水线的工程师


开篇:为什么"测试金字塔"画得好看却落不了地

上一篇 Day 6 聊完性能测试,这个系列从单元 → Mockito → 覆盖率 → Testcontainers → REST Assured/WireMock → 性能测试,六层测试基本都集齐了。如果你跟着每篇都把代码跑起来了,手头应该已经有一整套测试套件了。

但接下来这个问题最常见:这些测试散落在 IDE 里手动点 Run,什么时候跑、谁负责跑、跑多久、跑挂了怎么办,全都靠"想起来"。聊到 CI 流水线,大多数团队的现状是这三种之一:

  1. 没有 CI------还是手工打包部署,事故全在生产现场爆破。
  2. CI 一条龙------所有测试串成一个 job,跑 40 分钟,大家都不等了直接 merge。
  3. 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.javaTest*.java maven-surefire-plugin mvn test
集成测试 *IT.javaIT*.java maven-failsafe-plugin mvn verify
性能/E2E 自定义后缀,如 *PT.java 自定义或 Bender/JMeter Maven Plugin 自定义 goal

关键点:

  • Surefire 默认绑定 test 阶段,跑 *Test
  • Failsafe 默认绑定 integration-test + verify 阶段,跑 *IT(且默认test 阶段执行)
  • Gradle 可以用 Test task 拆分: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 里有几个关键设计:

  1. 并行矩阵:JDK 17 + 21 同时跑,验证升级兼容性,但只跑单元测试,因为快。
  2. needs: 显式依赖:集成测试必须等单元测试通过------避免"集成测试挂了,才发现是单元测试早该挂的问题",省 CI 时间。
  3. 覆盖率门控独立 job:覆盖率不达标是另一种失败,与单元测试逻辑无关。
  4. pgservices: 而非 Testcontainers :CI 跑 Postgres 容器比 Testcontainers 启动快一倍,因为是 GitHub Actions 的 Docker-in-Docker 优化路径。这跟 Day 4 推荐的"本地必用 Testcontainers"不矛盾------本地和 CI 用什么容器策略,可以是不同的
  5. 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 政策长这样:

  1. 检测 ------CI 自动记录"过去 7 天,这条测试在同一个 commit 上失败过但重试过了"。GitHub Actions 有 re-actor/automerge-for-flaky-tests 之类的 Action 可以做。
  2. 隔离 ------把 flaky 测试自动移到 flaky/ 目录,从主流程剥离,加 @Tag("flaky") 标签。
  3. 修复期限 ------flaky 测试必须在 7 天内 fix,否则删除。Fix or remove,没有第三条路
  4. 重试 ≠ 修复 ------很多人加 @Retry(3) 让它"碰运气过",这是把问题塞到地毯下面。重试只能用在测试本身已正确但偶发外部不稳定的情况(如真实网络调用),且必须搭配失败日志。

九、把金字塔接回 CI:一张总图

把这一篇所有内容拼起来,理想的 CI 测试金字塔长这样:

复制代码
                 ┌──────────────┐
                 │   Nightly    │ 性能/E2E/全量回归 (<30min)
                 └──────┬───────┘
                        │ 每晚定时 + 手动触发
                 ┌──────▼───────┐
                 │   PR Verify  │ 集成测试(<8min) + 覆盖率门控
                 └──────┬───────┘
                        │ PR 创建/更新触发
                 ┌──────▼───────┐
                 │  PR Pre-merge│ 单元测试(<90s)+ 静态扫描
                 └──────┬───────┘
                        │ push 触发
                 ┌──────▼───────┐
                 │ Local Hook   │ 当前模块单元测试(<15s)
                 └──────┬───────┘
                        │ pre-commit 触发
                 ┌──────▼───────┐
                 │ IDE Incremental│ 单文件增量(<2s)
                 └──────────────┘
                                 真实测试金字塔的"运行时倒影"

关键比喻:这是金字塔的"运行时倒影" ------画图时三角形底大顶小,但运行时金字塔是底矮顶高 的------越底层的测试频率越高、延迟越低 ,越顶层的测试频率越低、延迟越高

画图维度上的"底层测试多" = 时间维度上的"底层测试快"。这才是金字塔的真正奥义。


十、思考题

  1. 你团队 CI 跑全套测试要多久?哪些测试必须在 PR 流程里,哪些其实可以挪到 nightly?
  2. pre-commit hook 在你们团队是否启用?如果没有,是因为不知道,还是因为套件太慢?
  3. 测试名约定是否清晰?能不能一眼看出某个测试属于哪一层?如果不能,你能不能定一个命名规范?
  4. 团队里有没有"被默许 flaky"的测试?它在持续多久了?
  5. 如果你只有 30 分钟重构 CI,你会先做什么?(评论区聊聊)

TL;DR

  • CI 里的测试分层 = 测试套件分类命名 → 每层独立 job / stage → 每层独立触发 + 独立门控。
  • 反馈链:IDE 增量 → pre-commit hook → PR 单元测试 → PR 集成测试 → nightly 性能测试。每层放大 5~10 倍时间和价值。
  • PR 流程必跑单元测试(<90s)+ 集成测试(<8min);性能测试/E2E 必须挪到 nightly。
  • 测试命名的契约(Maven *Test vs *IT vs *PT)是分层的基石。
  • 覆盖率必须 jacoco:check 强制门控,不能人工巡查。
  • flaky 测试健康政策:检测 → 隔离 → 7 天修复 → fix or remove
  • "CI 跑 40 分钟没人等"的根因不是 CI 慢,是没人监控 CI 时长趋势。

明天 Day 8:《测试驱动开发(TDD)实战------红绿重构循环真的有那么神吗》 ------

这套理论里我们一直在用 assertThrows、在想"应该测什么",但 TDD 那套"先写测试再写代码"到底应不应该硬性推?它适合什么场景、不适合什么场景?过高估计 TDD 和过低估计 TDD 各有什么代价?

如果这篇对你有帮助,欢迎点赞收藏 ⭐。下篇见。

相关推荐
不加糖4351 小时前
CI/CD 与 GitHub Actions 入门
ci/cd·github
菩提树下的打坐1 小时前
flaky 测试治理:用工程化方式根治“上次过这次不过“
学习
Z5998178411 小时前
c#软件开发学习笔记--事务、索引
笔记·学习·c#
十月的皮皮2 小时前
C语言学习笔记20260716-编译与链接
c语言·笔记·学习
这个需求做不了2 小时前
Jenkins自动化构建与CI/CD流水线,并配置GitLab
java·ci/cd·自动化·gitlab·jenkins
私人珍藏库12 小时前
[Android] 会计快题库 -财会职称考试刷题学习
android·人工智能·学习·app·软件·多功能
qq_4523962317 小时前
第六篇:《GitLab CI 进阶:多环境部署与环境变量管理》
git·ci/cd·gitlab
从零开始的代码生活_17 小时前
C++ list 原理与实践:双向链表、迭代器与简化实现
开发语言·c++·后端·学习·算法·链表·list
二炮手亮子18 小时前
记录AI学习之路Day13:小模型(AI生成)
学习