应采用运行时替换法:在测试前用反射覆盖可寻址的导出包级变量(如config.global),测试后恢复原值;需规避init冲突、并发问题及不可寻址变量,禁用unsafe方案。

测试时如何避免改源码来注入 mock 配置
Go 里全局配置(比如 config.Global 或 db.DefaultClient)常被直接引用,导致单元测试无法隔离。硬改源码加接口或参数传递,既破坏原有逻辑,又增加维护成本——这不是测试该干的事。
真正可行的路是「运行时替换」:利用 Go 的包级变量可写性,在测试前用真实 mock 值覆盖原变量,测试后恢复。前提是该变量不是未导出、不是 const、没被编译器内联(这点后面会踩坑)。
- 只对包级导出变量生效,
var DB *sql.DB可以,var db *sql.DB不行 - 不能是初始化时就确定值的常量表达式,比如
var Timeout = time.Second * 30可以改,const Timeout = 30不行 - 如果变量在 init 函数里被赋值且依赖外部状态(如读文件),mock 后需确保 init 不重复执行或提前干预
用 unsafe.Pointer 强制替换不可寻址变量?别试
有些配置变量看似是包级 var,但实际被编译器优化成只读,或者定义在非主模块中(如 vendor 下),reflect.ValueOf(&v).Elem().Set() 会 panic 报 cannot set unaddressable value。这时候有人翻文档想用 unsafe.Pointer 绕过——真这么干,CI 跑一半 core dump 是大概率事件。
安全替代方案只有两个:
立即学习“go语言免费学习笔记(深入)”;
- 确认变量是否真的「可寻址」:在测试里加
fmt.Printf("canAddr: %v\n", reflect.ValueOf(&config.Global).CanAddr()),输出 false 就放弃反射方案 - 改用「函数包装层」:在原包里加一个导出的 setter,如
func SetGlobalConfig(c Config),只在测试 build tag 下暴露(//go:build test),不污染生产代码 - 若变量来自第三方库且不可改,用接口抽象 + 依赖注入兜底,哪怕只在测试文件里补个
var _DB = db.DefaultClient然后全用这个别名,也比 unsafe 稳定
testify/mock 对全局配置无效?它本来就不该管这个
testify/mock 是为接口 mock 设计的,而全局配置多数是结构体指针或基础类型变量,不是接口。试图给 config.Config 结构体生成 mock,只会得到一堆无意义的空方法,跟实际读取逻辑完全脱节。
正确做法是「跳过 mock 框架,直击变量本身」:
- 备份原值:
orig := config.Global - 替换新值:
config.Global = &config.Config{Timeout: time.Millisecond} - 测试完立刻恢复:
defer func() { config.Global = orig }() - 注意并发:如果测试并行执行(
t.Parallel()),多个 goroutine 同时改同一个包变量会冲突,必须加锁或改用 per-test 实例(比如把配置塞进 test helper 函数参数)
为什么 init 函数会让 mock 失效
很多配置包在 init() 里做首次加载,比如读环境变量、解析 YAML。一旦 init 执行完,后续对变量的赋值可能被 init 里的逻辑覆盖(尤其用了 sync.Once 的场景)。你 mock 完发现测试里还是旧值,八成是 init 在作祟。
解法不是禁用 init(做不到),而是控制它的触发时机:
- 把 init 里重的逻辑拆出来,变成显式函数
LoadConfig(),测试时先调用再 mock - 用 build tag 隔离 init 行为:
//go:build !test包裹原 init,测试时用空 init 或 stub init - 检查是否有
sync.Once包裹的加载逻辑,如果有,mock 必须在 Once.Do 之前完成;否则只能靠重新 import 包(用import _ "xxx/testinit"触发重载,但仅限于未被其他包导入过的包)
最麻烦的情况是:配置变量被多个包 import,其中某个包的 init 已经触发了加载。这时 mock 不是“能不能做”,而是“在哪个时间点做才来得及”——往往得把 mock 放到 TestMain 里,甚至用 go:linkname 黑魔法,但那已经超出“不改源码”的边界了。










