Go:单元测试怎么写,才不是只测 happy path
学 Go 学到测试这一段时,最容易先形成一种很危险的“完成错觉”:
- 会写
go test - 会建
xxx_test.go - 会把一个函数输进去、一个结果拿出来
- 绿色通过了,就觉得测试已经补完
问题在于,这类测试经常只覆盖了一条最顺的路径:
- 输入合法
- 下游不报错
- 时间刚好正常
- 配置也刚好完整
- 结果结构完全符合预期
也就是通常所说的 happy path。
happy path 当然要测,但如果单元测试只剩这一条线,它在真实项目里会很快失去大部分价值。
因为真实问题往往出现在这些地方:
- 参数缺失
- 零值处理错误
- 边界值判断偏一位
- 下游返回错误但没被包装
- 同样的测试今天过、明天不过
- 代码和时间、随机数、网络、文件系统绑得太死,根本不好测
- 为了追求覆盖率,测试写成一堆复制粘贴和脆弱断言
这一篇不按零碎技巧来讲,而是围绕一个真实一点的小场景来讲:做一个服务巡检结果的告警规则评估器。
这个评估器要做几件事:
- 接收巡检结果
- 按失败次数和响应时间判断告警级别
- 生成一条告警消息
- 把结果交给通知发送器
- 对参数错误、状态错误和发送错误做区分
这个场景不大,但足够把 Go 单元测试里最常见、也最容易被忽略的问题串起来。
一、这篇文章要解决什么问题
读完这一篇,应该能独立回答这些问题:
- 单元测试到底为什么不能只测 happy path
- 一个 Go 函数至少应该从哪些维度设计测试用例
- 错误断言、边界断言和结构断言分别该怎么写
- 什么叫可重复、可预测、可确定的测试
- 怎样写代码,测试才不至于又臭又长
- Go 测试里最常见的几种反模式是什么
如果这些问题能答清,后面你写 CLI、任务执行器、HTTP 服务、规则引擎、平台工具时,测试质量会稳很多。
二、先把一句话结论说清楚
Go 单元测试最重要的目标不是“证明代码能工作一次”,而是:
持续验证这段代码在正常输入、异常输入、边界输入和不稳定环境因素下,仍然符合你定义的行为。
这里面至少有五层意思:
- 不只测正常路径,也测失败路径
- 不只测一个例子,也测边界和零值
- 不只看返回值,也看错误类型、状态和副作用
- 不只让它跑过一次,也要让它稳定可重复
- 不只会写测试,还要把代码设计成容易测试
很多“测试写了等于没写”的问题,本质上都不是测试框架问题,而是这五层里漏了两三层。
三、先看这篇文章贯穿始终的小项目
假设你在做一个最小的巡检告警模块。
平台每分钟会收到一批巡检结果,每条结果至少包含:
- 服务名
- 是否健康
- 连续失败次数
- 平均响应时间
- 检查时间
然后你要根据规则输出告警级别:
- 健康且响应时间正常,返回
Info - 不健康但失败次数还少,返回
Warn - 连续失败达到阈值,返回
Critical - 输入数据非法,直接返回错误
- 发送通知失败,要把错误向上返回并补上下文
这个模块看上去不复杂,但已经足够暴露大多数测试问题:
- 连续失败次数到底从几次开始升级
- 响应时间阈值是大于还是大于等于
Service为空怎么办CheckedAt是不是允许零值- 发送器报错时,错误有没有被保留下来
- 使用
time.Now()直接拼消息时,测试会不会变得不稳定
后面所有示例,都围绕这个场景展开。
四、先给一个最小可运行版本
先看一个最小版本的评估器:
1 | package alert |
这个版本已经能写测试,但如果你只测下面这个例子:
1 | func TestEvaluateSuccess(t *testing.T) { |
那这份测试只能说明:
- 某一个正常输入可以得到
Info
它还说明不了其他更重要的事情。
五、为什么只测 happy path 几乎一定不够
因为单元测试不是为了证明“作者当时想的那条路能跑通”,而是为了防住后续改动把行为改坏。
只测 happy path 时,最容易漏掉下面几类回归:
1. 参数校验被删掉了
比如后续有人重构时删掉了:
1 | if result.Service == "" { |
happy path 测试仍然会绿,因为它本来就传了合法参数。
2. 边界条件悄悄变了
例如原来规定:
FailureCount >= 3是Critical
后来有人误改成:
FailureCount > 3才是Critical
如果你没有专门测“恰好等于 3”,这个 bug 很容易溜过去。
3. 错误被吞了
例如把包装错误的 %w 改没了,或者直接 return nil。
happy path 一样看不出来。
4. 测试本身不稳定
如果你在代码里直接用 time.Now()、rand.Intn()、真实网络请求、真实文件路径,今天能过,不代表明天还能过。
5. 行为对了,但断言太松
比如你只断言“返回了一个非空消息”,却没断言消息里包含关键信息。
后续有人把 service 丢了,测试还是过。
所以真正有用的测试,会主动去覆盖“代码最容易坏掉的地方”。
六、单元测试至少该覆盖哪些维度
对大多数 Go 业务函数来说,更稳的做法是至少从下面七个维度想测试,而不是只想“来一条正常例子”。
1. 正常路径
先验证主要功能确实能跑通。
例如:
- 健康服务返回
Info - 非健康服务返回
Warn
2. 错误路径
要明确哪些输入应该报错,哪些下游失败应该返回错误。
例如:
Service为空FailureCount为负数CheckedAt为零值
3. 边界值
边界值比“普通值”更容易出错。
例如:
FailureCount == 2FailureCount == 3LatencyMS == 799LatencyMS == 800
4. 零值和空值
Go 的零值语义很强,很多 bug 恰恰出在“没显式赋值也能跑”。
例如:
- 空字符串
- 零时间
nil依赖
5. 错误断言
不是只断言“有错”,而是要断言:
- 是不是预期那类错误
- 错误链有没有保留
- 文本上下文是否足够定位
6. 副作用
如果函数除了返回值,还会写日志、发通知、写缓存、修改状态,就要测副作用。
例如:
- 只在
Warn或Critical时发送通知 Info不发送通知
7. 可重复性
同样输入重复跑,结果应当稳定。
例如:
- 固定时间源
- 不依赖随机顺序
- 不共享外部状态
这七类覆盖不一定每次都全部拉满,但只要你开始这样思考,测试质量会明显高于“一个 happy path + 一个失败 path 就收工”。
七、断言到底应该断什么
很多测试写得脆弱,不是因为场景选错了,而是断言对象选错了。
Go 测试里更稳的思路是:断行为,断协议,断边界,少断实现细节。
1. 优先断业务行为
比如这类断言是高价值的:
1 | if got.Level != LevelCritical { |
因为它直接对应业务规则。
2. 错误用 errors.Is 或明确文本片段
比如:
1 | if !errors.Is(err, ErrInvalidProbe) { |
如果还需要补上下文,再断一段关键文本:
1 | if !strings.Contains(err.Error(), "empty service") { |
3. 结构体断言不要一把梭地偷懒
这里常见的偷懒写法是直接比较整个结构体:
1 | if !reflect.DeepEqual(got, want) { |
这不是不能用,但要小心两个问题:
- 一旦结构体新增字段,测试会成片破
- 某些字段本来就不是本测试关心的重点
更稳的做法是断关键字段:
1 | if got.Service != "order-api" { |
4. 少断内部实现细节
比如为了验证结果,不应该去断“内部调用了第 3 行的某个私有函数”。
测试应该更关心:
- 最终返回了什么
- 依赖有没有被调用
- 错误有没有按约定传播
如果测试过度依赖内部实现,一重构就会全碎。
八、用表驱动测试把核心分支一次讲清
Go 里表驱动测试很常见,不是为了显得规范,而是因为它特别适合承载“同一个行为的多个边界用例”。
例如 Evaluate 的规则判断,很适合写成这样:
1 | func TestEvaluateLevel(t *testing.T) { |
这个写法的好处不是“形式整齐”,而是你可以很容易看出规则覆盖了哪些格子、缺了哪些格子。
九、错误场景应该怎么测,才不是只写一句 expected error
很多测试会写成这样:
1 | _, err := Evaluate(ProbeResult{}, time.Now) |
这只能证明“有错误”,但信息量太低。
更稳的错误测试,至少要回答两件事:
- 错误是不是对的类型
- 错误上下文是不是足够定位
例如:
1 | func TestEvaluateInvalidProbe(t *testing.T) { |
如果你的系统后续还要基于错误类型做重试、统计或状态分流,那这类断言就不是“锦上添花”,而是核心保障。
十、边界值测试为什么经常比正常值更重要
很多逻辑 bug 根本不出现在“明显错误输入”上,而是出现在边界上。
就拿这篇文章的小规则来说:
LatencyMS >= 800算慢FailureCount >= 3算严重
那最值得测的就不是 120 或 2000,而是:
- 799
- 800
- 2
- 3
边界值测试最容易直接拦住“比较符号改错”的回归。
例如:
1 | func TestEvaluateLatencyBoundary(t *testing.T) { |
这类测试看起来不花哨,但在工程里非常值钱。
十一、真实项目里,单元测试还要覆盖副作用和依赖失败
到这里如果只测 Evaluate,还不够像真实项目。
因为真实代码通常不只是返回一个结构体,还会调用依赖。
比如这里会有一个通知发送器:
1 | package alert |
这个函数至少要测三件事:
Info不发送Warn或Critical会发送- 发送失败时错误链被保留
可以写一个最小假对象:
1 | type fakeSender struct { |
测试:
1 | func TestNotifyIfNeeded(t *testing.T) { |
再测错误链:
1 | func TestNotifyIfNeededWrapsError(t *testing.T) { |
到这里可以看到,单元测试不只是测“纯函数结果”,还要测:
- 依赖有没有被调用
- 被调用了几次
- 传了什么参数
- 失败时错误有没有被正确传播
十二、想让测试稳定,先把不确定性从设计里拆出去
很多 Go 测试不稳定,不是 testing 包不行,而是代码把不确定因素写死了。
最常见的几个来源是:
time.Now()time.Sleep()- 随机数
- 真实网络
- 真实文件系统
- 真实环境变量
- map 迭代顺序
先看一个不太适合测试的写法:
1 | func Evaluate(result ProbeResult) (Alert, error) { |
这样一来,测试如果比较整个结构体,要么每次都手动绕开 TriggeredAt,要么变得非常脆弱。
更可测的做法是把时间源作为依赖传进来:
1 | func Evaluate(result ProbeResult, now func() time.Time) (Alert, error) { |
同样的思路也适用于:
- 把 HTTP 客户端抽成接口
- 把文件读取抽成
io.Reader - 把随机数生成抽成函数注入
- 把环境变量读取集中到配置层,而不是散在业务逻辑里
可测试设计,本质上就是把业务规则和外部世界拆开。
十三、什么样的代码天生更容易写单元测试
测试写得顺不顺,往往在你开始写 xxx_test.go 之前就已经决定了一半。
更容易测试的 Go 代码通常有这些特征:
1. 输入明确,输出明确
例如一个函数明确接收参数,明确返回结果和错误,而不是偷偷读全局状态。
2. 纯逻辑和副作用分层
例如:
Evaluate只负责规则判断NotifyIfNeeded只负责通知边界
这样一来,规则和依赖调用可以分开测。
3. 依赖从外部传入
不是在函数里直接 http.Get、os.Open、time.Now,而是把依赖注入进来。
4. 错误有分类
这样你才有机会在测试里用 errors.Is 做稳定断言,而不是全靠字符串碰运气。
5. 单个函数职责不要太杂
如果一个函数同时做参数校验、读配置、发请求、组装消息、写数据库,那单元测试一定很累,最后不是样板很多,就是测试粒度变形。
十四、看一个更完整但仍然够小的项目骨架
把前面的纯逻辑和副作用拼起来,可以得到一个最小但很像真实工程的骨架:
1 | package alert |
针对这个骨架,测试可以分成三层:
Evaluate
测规则、边界、错误输入NotifyIfNeeded
测副作用、调用次数、错误包装Handle
测流程编排、上下文取消、依赖组合
这样的分层比“直接上来测一个最终大函数”更稳,也更容易定位失败原因。
十五、常见反模式:这些测试看起来写了,其实价值很低
下面这些写法在 Go 项目里非常常见,但长期看都不划算。
1. 只测 happy path
这是最常见的反模式。
结果就是线上真正会出的错误,全没被覆盖。
2. 断言只有 err != nil
这种测试太弱,既无法确认错误类型,也无法确认上下文是否保留。
3. 直接依赖真实时间和睡眠
例如:
1 | time.Sleep(2 * time.Second) |
这类测试通常慢、不稳定,还容易在 CI 上偶发失败。
4. 为了测方便,把一堆逻辑塞进 main
main 不是不能测,而是往往不值得承载核心业务逻辑。
核心逻辑应该下沉到可直接调用的包函数或类型方法。
5. 对内部实现绑定过深
例如重构后只是把一个私有函数拆成两个,业务行为没变,结果测试全红。
这说明测试断得太细了,断到了实现而不是行为。
6. 一份测试里塞太多断言和太多场景
这样一失败,定位成本很高。
更好的方式是:
- 用表驱动收同一类规则
- 用不同
t.Run切分不同性质场景
7. 为了追覆盖率,写没有业务价值的测试
比如 getter、纯搬运代码、没有分支且没风险的样板,全补一遍。
覆盖率数字会变好看,但故障预防能力不一定更强。
十六、测试与验证:这篇文章里的代码至少该怎么验
如果本地环境有 Go,最小验证动作通常是:
1 | go test ./... |
如果这个包里以规则判断为主,还可以优先关注:
- 表驱动测试是否把关键边界都覆盖到了
- 错误测试是否用了
errors.Is - 时间相关断言是否全部固定了时间源
- fake 依赖是否只实现了测试真正关心的那部分行为
如果你开始看到这些信号,说明测试已经从“会写”往“写得稳”迈进了。
十七、排障时先查什么,别一上来就怀疑测试框架
测试失败时,更稳的排查顺序通常是:
1. 先看失败是业务规则变了,还是测试假设错了
很多失败不是代码错,而是规则调整了,但测试没更新。
2. 再看是否混入了不确定因素
例如:
- 当前时间
- 时区
- map 顺序
- 并发写入
- 环境变量污染
3. 再看断言是不是过强
比如你本来只关心 Level,却把整条完整消息全文匹配了。
消息文案微调后,测试就会假红。
4. 最后看测试数据是不是太远离真实场景
如果测试数据全是“完美对象”,那很多真实 bug 根本触发不出来。
一个很实用的方法是:
把线上或联调里出现过的真实异常输入,收敛成最小测试样本。
十八、单元测试的边界在哪里
讲到这里,也要把边界说清楚。
单元测试很重要,但它不是万能的。
下面这些问题,通常不应该指望只靠单元测试解决:
- 真正的数据库连接兼容性
- HTTP 服务之间的真实集成行为
- 配置文件加载和部署环境差异
- 并发竞态、资源泄漏、性能瓶颈
- 端到端链路是否全通
这些更适合集成测试、契约测试、接口测试、端到端测试或性能测试。
单元测试最擅长的是:
- 快速反馈
- 精准定位
- 保护局部规则不被改坏
- 把边界和错误语义固定住
不要让它越位,也不要因为它不能解决全部问题,就把它降格成“只跑 happy path”。
十九、给你一组练习,检验自己是不是真的会写
如果你想把这一篇吃透,建议自己补下面几组测试:
- 给
Evaluate补一个FailureCount == 2返回Warn的测试 - 给
Evaluate补一个CheckedAt为零值的错误测试 - 给
NotifyIfNeeded补一个Critical发送两次不应该发生的保护测试 - 给
Handle补一个ctx已取消时直接返回context.Canceled的测试 - 把
buildMessage改成包含时间格式后,重构测试,确保它仍然稳定
如果这些练习能比较顺地写出来,说明你已经开始真正理解:
- 什么该断
- 什么不该断
- 什么属于 happy path 之外的高价值覆盖
二十、最后收个尾
Go 单元测试真正难的地方,从来不是 testing 包语法,而是你能不能建立这样一套判断:
- 哪些行为最值得保护
- 哪些输入最容易把代码打坏
- 哪些错误必须被精确识别
- 哪些外部因素必须被隔离
- 哪些设计方式能让测试长期可维护
所以,“单元测试怎么写才不是只测 happy path” 的答案并不是多写几个 t.Run,而是要把下面这条线建立起来:
正常路径、错误路径、边界条件、副作用、确定性、可测试设计,这几个维度要一起考虑。
这样写出来的测试,才不是交作业式的“绿色截图”,而是真正能陪代码一起演进的工程资产。