前几天,我在项目里看到这样一段执行说明:
重新运行根目录的
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,不会急着删步骤。我会先问三件事:这一步保护什么、谁需要它、它应该在什么时候跑。
只要这三个问题能回答清楚,半小时也可能花得很值。
参考资料与图片说明
- Go command documentation: Test packages
- GitHub Docs: Dependency caching
- Testing Pyramid.svg,作者 Abbe98,CC BY-SA 4.0
- Continuous Integration.jpg,作者 Pratik89Roy,CC BY-SA 4.0
项目写到后来,为什么跑一次 verify 要半小时
https://wangling.hauchet.cn/archives/why-project-verification-takes-half-an-hour
评论