前几天,我在项目里看到这样一段执行说明:

重新运行根目录的 task verify,它会再次执行代码生成、静态检查、单元测试、契约测试、安全检查,以及真实依赖和 NATS 轮换链。预计耗时 25~30 分钟。

第一反应很直接:怎么又要半小时?

这个项目早期执行一次测试只要几十秒。后来加了数据库、消息队列、多个服务、容器镜像和供应链检查,verify 也跟着越长越胖。每一项单独拿出来都有理由,堆到一个入口里以后,改一行代码和准备正式发布付出的时间几乎一样。

一个典型的持续集成流程

图:Pratik89Roy / Wikimedia Commons,CC BY-SA 4.0。

半小时到底花到哪儿去了

很多人看到测试慢,会先怀疑单元测试写得太多。实际项目里,最耗时的部分经常藏在测试之外。

一次完整验证可能包含这些工作:

阶段 常见内容 一次示例耗时
生成与静态检查 重新生成代码、格式检查、Lint、依赖检查 2 分钟
普通测试 单元测试、契约测试、权限测试 5 分钟
容器构建 构建多个服务镜像、扫描镜像 7 分钟
真实依赖测试 启动数据库、NATS、对象存储并执行集成测试 9 分钟
发布证据 SBOM、来源信息、签名、轮换与残留检查 5 分钟

这张表只是一个例子,但它很接近我遇到的情况。真正的测试可能几分钟就结束,剩下的时间花在拉镜像、启动容器、等待健康检查、下载依赖和生成证据上。

所以我做的第一件事很朴素:给每个阶段单独计时。

measure() {
  local name="$1"
  shift

  printf '\n===== %s =====\n' "$name"
  /usr/bin/time -f 'elapsed=%E  cpu=%P  maxrss=%MKB' "$@"
}

measure "unit tests" go test ./...
measure "compose startup" docker compose --profile test up -d --wait
measure "image build" docker compose build

别只看总耗时。连续记录几次以后,通常能发现某个阶段突然变慢,或者每次都在重复做没有必要的工作。

比如镜像构建一直很慢,原因可能是 Dockerfile 把经常变化的源码复制到了依赖安装之前,导致缓存层反复失效。关于 Compose 项目的目录、健康检查和镜像管理,我之前写过一篇《为什么你的 Docker Compose 项目越写越乱》,两篇可以放在一起看。

我后来把入口拆成了四个

以前只有一个 task verify,所有人都从这里进。后来我把它拆成了四个入口:

version: '3'

tasks:
  quick:
    desc: 写代码时频繁运行
    cmds:
      - golangci-lint run --fast
      - go test ./internal/...

  test:
    desc: 提交前运行完整的普通测试
    cmds:
      - go test ./...
      - go test -race ./internal/...

  verify:
    desc: 合并前运行
    deps: [test]
    cmds:
      - task: generated-check
      - task: contract-test
      - task: integration-test
      - task: security-check

  release-check:
    desc: 发布候选版本运行
    deps: [verify]
    cmds:
      - task: build-images
      - task: rotation-test
      - task: backup-restore-test
      - task: sbom
      - task: provenance

名字并不重要,关键是让每个入口表达清楚成本和用途。

我写代码时会反复跑 quick。准备提交时跑 test。合并请求交给 CI 执行 verify。涉及发布的分支或标签再执行 release-check

这样做以后,完整验证依然要二十多分钟,我平时却很少被它打断。低级错误几十秒就能暴露,昂贵检查留给真正需要它们的时机。

如果项目使用 Codex、Claude Code 一类工具批量修改代码,这种分层会更有用。AI 一次可能改动很多文件,先跑便宜的检查,确认方向没有偏,再投入完整验证。站内的《OpenAI Codex 安装与使用全指南》介绍了工具本身,这里补的是工程侧的收尾方式。

Go 项目里有个容易漏掉的细节

go test 自带测试缓存,但调用方式会影响缓存是否生效。

Go 的命令文档说明,像 go test ./... 这样显式给出包列表时,会缓存成功的测试结果。只在当前目录执行不带包参数的 go test,属于本地目录模式,测试缓存不会启用。

可以用下面的命令确认缓存是否命中:

go test ./...
go test ./...

第二次输出里出现 (cached),说明对应包复用了结果。

有些脚本为了“确保干净”,每次开头都执行 go clean -testcache。这会把 Go 已经做好的优化全部抹掉。真正需要排除缓存影响时再清理,日常验证没必要固定加这一句。

同样的道理也适用于 CI。GitHub Actions 把 cache 和 artifact 分成两类:cache 用来复用依赖或昂贵的中间结果,artifact 用来保存构建产物和日志。两者混着用,流程容易变慢,也容易让人误判某次构建到底用了什么。

测试数量也要讲结构

单元测试快,适合覆盖大量分支;集成测试会启动真实依赖,数量应当克制;端到端测试最接近用户,但维护成本最高。

测试金字塔

图:Abbe98 / Wikimedia Commons,CC BY-SA 4.0。

有段时间我也想过,把所有服务全部拉起来,再从入口一路测到底,似乎最让人安心。后来发现这种测试失败时很难定位:网络、容器、数据初始化、时序和业务逻辑都可能出错。它适合保留少量关键链路,承担不了日常反馈的主要工作。

测试金字塔并非硬性比例。它至少提醒了一件事:越昂贵、越容易受环境影响的测试,数量越需要控制。

有些慢流程值得留下

下面这些检查,我不会为了把时间压漂亮而删掉:

  • 数据库迁移后能否正确回滚;
  • 备份文件能否在一套干净环境中恢复;
  • 密钥或消息系统凭据轮换后,旧凭据是否真的失效;
  • 多个镜像能否生成完整的 SBOM 和来源信息;
  • 发布包里有没有残留临时目录、测试私钥或调试配置。

它们执行频率低,但出问题时往往代价很大。放进发布检查或夜间任务更合适。

环境本身也会拖慢验证。WSL 文件放在 Windows 挂载盘、代理残留、国内网络反复下载依赖,都可能把几分钟的任务拖到十几分钟。遇到这类情况可以参考站内的《Windows 11 + WSL2 开发环境搭建全教程》,先把文件系统、镜像和代理处理好,再谈测试优化。

最后

完整验证跑 25 分钟,我可以接受。改一段 README 也必须等 25 分钟,我接受不了。

项目规模上来以后,验证链变长很正常。真正需要整理的是入口、执行时机和失败信息。开发者应该很快知道自己有没有犯低级错误;准备合并时,再为跨模块风险付出几分钟;到了发布前,慢慢检查备份、轮换和供应链证据。

我现在看到一条很长的 verify,不会急着删步骤。我会先问三件事:这一步保护什么、谁需要它、它应该在什么时候跑。

只要这三个问题能回答清楚,半小时也可能花得很值。


参考资料与图片说明