不能只靠defer清理,因为defer在testmain函数返回时才执行,而m.run()若panic或被中断会导致defer不触发,造成资源泄漏;正确做法是将defer cleanup()写在m.run()前,并确保清理函数可重入、容忍重复执行。

TestMain 是集成测试环境初始化的起点,但真正决定可靠性的,是清理逻辑怎么分层、在哪执行、是否能兜住异常。
为什么不能只靠 defer 在 TestMain 里清理?
因为 defer 只在函数返回时触发,而 TestMain 里的 m.Run() 一旦 panic 或被信号中断(比如 Ctrl+C),后续 defer 就不会执行——数据库连接没关、临时目录没删、端口没释放,下次测试直接失败。
常见错误现象:go test -v 第二次跑报 listen tcp :8080: bind: address already in use,或 SQLite 报 database is locked。
-
defer cleanup()必须写在m.Run()之前,不是之后 - 清理函数本身要能容忍“重复执行”或“资源已不存在”,比如
os.RemoveAll对空路径不报错,db.Close()多次调用无副作用 - 若清理可能失败(如 HTTP server 已断连),用
recover()或忽略错误,别让清理失败掩盖真实测试失败
t.Cleanup 和 TestMain 的分工必须明确
t.Cleanup 不是 TestMain 的替代品,而是补位:前者管子测试粒度的隔离,后者管全局依赖的生命周期。
典型误用:在 TestMain 里启动一个 httptest.Server,却把 srv.Close() 放进某个 t.Cleanup ——结果只有那个子测试结束时关服务,其他测试全连不上。
- 全局资源(DB 连接池、mock HTTP server、临时文件根目录)→ 初始化和销毁都放在
TestMain - 子测试专属资源(每个
t.Run里新建的临时文件、监听的随机端口、单独的内存表)→ 必须用t.Cleanup,不能用defer - 闭包变量捕获是高频翻车点:循环注册
t.Cleanup时,记得显式复制变量,例如name := name; t.Cleanup(func(){ os.Remove(name) })
环境变量、配置、全局状态的清理最容易被忽略
Go 测试默认并发执行,os.Setenv 修改的是进程级环境变量,一个测试改了,其他测试就继承了——这不是 bug,是设计如此。
常见错误现象:测试 A 设置了 DEBUG=1,测试 B 读到它并输出大量日志,导致超时或断言失败;或者配置解析器缓存了旧值,后续测试读错配置。
- 所有
os.Setenv必须配对os.Unsetenv,且Unsetenv要放在TestMain的清理段,不是子测试里 - 避免在
init()函数里加载配置或初始化全局变量——它无法被清理,且在测试中执行时机不可控 - 如果必须用全局变量(比如
var db *sql.DB),初始化时用sync.Once包裹,清理时确保只关一次
SQLite 内存数据库不是万能解,:memory: 有陷阱
很多人以为 sqlite3 的 :memory: 能自动隔离,其实不然:每个 sql.Open 创建的是独立连接,但事务、锁、表结构并不跨连接共享——这反而容易掩盖真实 DB 并发问题。
更严重的是,:memory: 数据库在连接关闭时自动销毁,但如果测试中忘了 db.Close(),或用了连接池(db.SetMaxOpenConns),内存泄漏会累积,最终 OOM。
- 集成测试优先用真实 SQLite 文件(
tempfile创建),便于调试和复现 - 若坚持用
:memory:,务必在TestMain清理段显式db.Close(),且不要复用同一个*sql.DB给多个子测试(除非你明确控制事务边界) - 检查连接健康:在
setup后加db.Ping(),失败直接os.Exit(1),别让测试在运行时才发现 DB 不可用
go test 都像第一次那样干净。资源没释放、环境变量残留、全局状态污染——这些不会立刻报错,但会让 CI 偶发失败、本地调试反复踩坑。盯住 TestMain 的 defer 位置、t.Cleanup 的作用域、以及所有“只做一次”的假设是否真成立。golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











