项目规模一上去,测试套件的运行时间就成了整个团队的噩梦。之前我们把所有的单元测试和集成测试一股脑塞进一个 CI 任务里跑,随着测试用例累积到 3000 多个,CI 阶段的耗时直接飙到了半小时以上。每次提个紧急 Hotfix 想要合并,光是干等 CI 绿就需要怀疑人生,严重阻碍了日常发布。为了干掉这个卡脖子的性能瓶颈,我们决定对 GitLab CI 的管道进行大刀阔斧的重构,利用 parallel:matrix 语法配合测试工具的 Sharding(分片)机制,将测试分发给 4 个 Runner 同时跑,把时间直接缩短了四分之三。

很多人在尝试优化测试时,最容易想到的办法是在单台机器上开启多线程(比如 Jest 的 --maxWorkers)。但在 CI 容器这种 CPU 和内存资源本就非常受限的环境下,强行开多个 worker 往往会导致内存直接爆掉(OOM),或者 CPU 争抢剧烈反而导致运行速度比单线程更慢。真正有效的方式是把任务分摊到不同的物理 Runner 节点上。

现代测试框架(比如 Vitest、Playwright、Jest)大多原生支持 --shard=index/total 参数。只要我们告诉它“总共有 4 个分片,当前是第 2 个”,它就会自动计算哈希并运行该分片对应的测试子集,完全不需要我们手工去写复杂的 shell 脚本来切割文件列表。

以下是我们在 .gitlab-ci.yml 中实现分片并行测试的完整配置:

stages:
  - install
  - test
  - deploy

default:
  cache:
    key:
      files:
        - pnpm-lock.yaml
    paths:
      - .pnpm-store/

install-deps:
  stage: install
  image: node:18-alpine
  script:
    - corepack enable
    - pnpm config set store-dir .pnpm-store
    - pnpm install --frozen-lockfile
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour

run-tests-parallel:
  stage: test
  image: node:18-alpine
  dependencies:
    - install-deps
  parallel:
    matrix:
      # 定义分片矩阵,这里启动 4 个并发 Runner 任务
      - TEST_SHARD_INDEX: [1, 2, 3, 4]
        TEST_SHARD_TOTAL: [4]
  variables:
    NODE_ENV: test
    # 针对集成测试,为每个分片动态指定隔离的数据库服务
    DATABASE_URL: "postgresql://test_user:test_pass@postgres-db-${TEST_SHARD_INDEX}:5432/test_db_${TEST_SHARD_INDEX}"
  services:
    # 动态为每个 Runner 绑定独立的 PostgreSQL 容器,避免多任务并发写入冲突
    - name: postgres:15-alpine
      alias: postgres-db-${TEST_SHARD_INDEX}
      command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off"]
  script:
    - corepack enable
    - pnpm config set store-dir .pnpm-store
    # 使用 Vitest 原生的分片能力进行测试分流
    - pnpm exec vitest run --shard=${TEST_SHARD_INDEX}/${TEST_SHARD_TOTAL}
  artifacts:
    when: always
    reports:
      junit:
        - junit-report-${TEST_SHARD_INDEX}.xml
    paths:
      - coverage/
    expire_in: 7 days

在这套配置中,有几个非常关键的实践细节:

  1. 测试数据库隔离:集成测试最忌讳并发写入同一个数据库实例。我们利用 GitLab CI 的 services 别名功能,动态拼接 ${TEST_SHARD_INDEX}。每个并行任务都在自己独立的 Postgres 容器中读写,天然避免了脏数据污染和死锁大坑。
  2. 临时关闭 Postgres 刷盘:我们在 services 启动命令中传入了 -c fsync=off,这虽然在生产环境下极度危险,但在测试环境能极大提高数据库写入性能,毕竟 CI 跑完容器就扔了,根本不需要数据持久化安全。
  3. 合并测试报告:每个分片运行完毕后会生成对应的 JUnit 报告,GitLab 会自动收集并合并这些报告,在 Merge Request 页面统一展示失败的用例,体验非常丝滑。

重构之后,整个测试阶段在 CI 上的运行时间成功被压到了 7 分钟以内。这对于快速迭代的敏捷团队来说,开发心智负担得到了前所未有的释放。

大家在做 CI/CD 优化时,除了利用矩阵分片,还有没有尝试过基于测试用例的历史耗时做动态平衡负载均衡?对于那种执行时间超长的“害群之马”测试用例,有什么好的调优手段?