现象

四个包里各有一个集成测试,都靠环境变量 REKIT_MYSQL_DSN 指向同一个 MySQL 库。单独跑每个都过:

go test ./internal/db/                 # ok
go test ./internal/domain/notify/      # ok

一起跑就翻车:

--- FAIL: TestNotifyEndToEndWithFakeWechat
    建表失败:Error 1824 (HY000): Failed to open the referenced table 'wechat_configs'

原因

go test 默认按包并行。并行度是 -p,默认值等于 GOMAXPROCS,通常就是你的 CPU 核数。同一个包内的测试函数默认串行(除非显式 t.Parallel()),但不同包之间是同时跑的

而这四个测试共用同一个数据库。其中 internal/db 那个的职责是「验证建表链路」,它一上来就把库里所有表 DROP 掉,再从 DDL 真源重新建一遍:

// 清空残留表,保证建表从真源全新拉起
full.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS = 0")
for _, tbl := range tables {
    full.ExecContext(ctx, "DROP TABLE IF EXISTS "+tbl)
}

于是就有了这个竞态:notify 的测试正在建带外键的表,internal/db 的测试在隔壁把 wechat_configs 抽走了。外键找不到被引用的表,MySQL 报 1824。

报错信息完全没有提示这是并发问题,看起来就像 DDL 写错了——所以第一反应会去查建表语句的顺序,方向完全错了。

解法

REKIT_MYSQL_DSN='...' go test -p 1 ./...

-p 1 让包之间串行执行。

值得强调的是:这不是「跑得慢一点更保险」,而是这批测试的正确性前提。 只要它们共用一个库、并且其中有一个会做破坏性 DDL,并行跑就是错的。这一点应该写进文档和检查脚本,而不是靠人记住。

我的做法是在统一的检查脚本里把它固化下来,让人没有忘记的机会:

test_flags=""
if [ -n "${REKIT_MYSQL_DSN:-}" ]; then
    # 共用一个库,必须串行;-p 1 不是性能取舍,是正确性要求
    test_flags="-p 1"
fi
go test $test_flags ./...

顺带说说共用库的其它代价

-p 1 解决了并发,但「多个测试共用一个库」这个设计本身还有别的坑:

测试之间会互相看见对方的数据。 比如某个测试的逻辑是「如果已经存在管理员就跳过」,那它是否执行就取决于前面哪个测试跑过——测试结果依赖执行顺序,这是很难调试的一类问题。

破坏性测试会吃掉真实数据。 我们后来把一份配置数据导进了这个测试库,忘了它同时还是集成测试的目标。下次谁跑一遍带 DSN 的测试,那份配置就没了。

理想的做法当然是每个测试包用独立的 schema,或者每次跑之前起一个临时容器。但如果因为权限(比如测试账号没有 CREATE DATABASE)或者环境限制做不到,那至少要把约束写清楚:

  • 检查脚本里强制 -p 1
  • 文档里写明「这批测试会 DROP 掉目标库所有表」;
  • 绝对不要把 DSN 指向任何有价值的库

一个容易忽略的细节

如果测试代码里有「只允许在名为 xxx 的库里操作」这类保护,别轻易把它删掉。我们因为测试账号没有建库权限,把那个守卫改成了「用 DSN 里指定的库」——灵活性上去了,但也就此失去了「指错库时当场拒绝」的保护。

改完之后,唯一的防线就只剩「不设 REKIT_MYSQL_DSN 就自动跳过」。这条防线是够用的,但它要求每个人都清楚:设了这个环境变量,就等于授权删库。这件事必须写在显眼的地方。