Go:单元测试怎么写,才不是只测 happy path

学 Go 学到测试这一段时,最容易先形成一种很危险的“完成错觉”:

  • 会写 go test
  • 会建 xxx_test.go
  • 会把一个函数输进去、一个结果拿出来
  • 绿色通过了,就觉得测试已经补完

问题在于,这类测试经常只覆盖了一条最顺的路径:

  • 输入合法
  • 下游不报错
  • 时间刚好正常
  • 配置也刚好完整
  • 结果结构完全符合预期

也就是通常所说的 happy path。

happy path 当然要测,但如果单元测试只剩这一条线,它在真实项目里会很快失去大部分价值。

因为真实问题往往出现在这些地方:

  • 参数缺失
  • 零值处理错误
  • 边界值判断偏一位
  • 下游返回错误但没被包装
  • 同样的测试今天过、明天不过
  • 代码和时间、随机数、网络、文件系统绑得太死,根本不好测
  • 为了追求覆盖率,测试写成一堆复制粘贴和脆弱断言

这一篇不按零碎技巧来讲,而是围绕一个真实一点的小场景来讲:做一个服务巡检结果的告警规则评估器

这个评估器要做几件事:

  1. 接收巡检结果
  2. 按失败次数和响应时间判断告警级别
  3. 生成一条告警消息
  4. 把结果交给通知发送器
  5. 对参数错误、状态错误和发送错误做区分

这个场景不大,但足够把 Go 单元测试里最常见、也最容易被忽略的问题串起来。

一、这篇文章要解决什么问题

读完这一篇,应该能独立回答这些问题:

  1. 单元测试到底为什么不能只测 happy path
  2. 一个 Go 函数至少应该从哪些维度设计测试用例
  3. 错误断言、边界断言和结构断言分别该怎么写
  4. 什么叫可重复、可预测、可确定的测试
  5. 怎样写代码,测试才不至于又臭又长
  6. Go 测试里最常见的几种反模式是什么

如果这些问题能答清,后面你写 CLI、任务执行器、HTTP 服务、规则引擎、平台工具时,测试质量会稳很多。

二、先把一句话结论说清楚

Go 单元测试最重要的目标不是“证明代码能工作一次”,而是:

持续验证这段代码在正常输入、异常输入、边界输入和不稳定环境因素下,仍然符合你定义的行为。

这里面至少有五层意思:

  1. 不只测正常路径,也测失败路径
  2. 不只测一个例子,也测边界和零值
  3. 不只看返回值,也看错误类型、状态和副作用
  4. 不只让它跑过一次,也要让它稳定可重复
  5. 不只会写测试,还要把代码设计成容易测试

很多“测试写了等于没写”的问题,本质上都不是测试框架问题,而是这五层里漏了两三层。

三、先看这篇文章贯穿始终的小项目

假设你在做一个最小的巡检告警模块。

平台每分钟会收到一批巡检结果,每条结果至少包含:

  • 服务名
  • 是否健康
  • 连续失败次数
  • 平均响应时间
  • 检查时间

然后你要根据规则输出告警级别:

  • 健康且响应时间正常,返回 Info
  • 不健康但失败次数还少,返回 Warn
  • 连续失败达到阈值,返回 Critical
  • 输入数据非法,直接返回错误
  • 发送通知失败,要把错误向上返回并补上下文

这个模块看上去不复杂,但已经足够暴露大多数测试问题:

  • 连续失败次数到底从几次开始升级
  • 响应时间阈值是大于还是大于等于
  • Service 为空怎么办
  • CheckedAt 是不是允许零值
  • 发送器报错时,错误有没有被保留下来
  • 使用 time.Now() 直接拼消息时,测试会不会变得不稳定

后面所有示例,都围绕这个场景展开。

四、先给一个最小可运行版本

先看一个最小版本的评估器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
package alert

import (
"errors"
"fmt"
"time"
)

var ErrInvalidProbe = errors.New("invalid probe result")

type Level string

const (
LevelInfo Level = "info"
LevelWarn Level = "warn"
LevelCritical Level = "critical"
)

type ProbeResult struct {
Service string
Healthy bool
FailureCount int
LatencyMS int
CheckedAt time.Time
}

type Alert struct {
Service string
Level Level
Message string
TriggeredAt time.Time
}

func Evaluate(result ProbeResult, now func() time.Time) (Alert, error) {
if result.Service == "" {
return Alert{}, fmt.Errorf("%w: empty service", ErrInvalidProbe)
}
if result.FailureCount < 0 {
return Alert{}, fmt.Errorf("%w: negative failure count", ErrInvalidProbe)
}
if result.LatencyMS < 0 {
return Alert{}, fmt.Errorf("%w: negative latency", ErrInvalidProbe)
}
if result.CheckedAt.IsZero() {
return Alert{}, fmt.Errorf("%w: zero checked time", ErrInvalidProbe)
}

level := LevelInfo
switch {
case !result.Healthy && result.FailureCount >= 3:
level = LevelCritical
case !result.Healthy:
level = LevelWarn
case result.LatencyMS >= 800:
level = LevelWarn
}

return Alert{
Service: result.Service,
Level: level,
Message: buildMessage(result, level),
TriggeredAt: now(),
}, nil
}

func buildMessage(result ProbeResult, level Level) string {
return fmt.Sprintf(
"service=%s level=%s healthy=%t failures=%d latency_ms=%d",
result.Service,
level,
result.Healthy,
result.FailureCount,
result.LatencyMS,
)
}

这个版本已经能写测试,但如果你只测下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestEvaluateSuccess(t *testing.T) {
now := func() time.Time {
return time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
}

got, err := Evaluate(ProbeResult{
Service: "order-api",
Healthy: true,
FailureCount: 0,
LatencyMS: 120,
CheckedAt: time.Date(2024, 1, 1, 9, 59, 0, 0, time.UTC),
}, now)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got.Level != LevelInfo {
t.Fatalf("expected info, got %s", got.Level)
}
}

那这份测试只能说明:

  • 某一个正常输入可以得到 Info

它还说明不了其他更重要的事情。

五、为什么只测 happy path 几乎一定不够

因为单元测试不是为了证明“作者当时想的那条路能跑通”,而是为了防住后续改动把行为改坏。

只测 happy path 时,最容易漏掉下面几类回归:

1. 参数校验被删掉了

比如后续有人重构时删掉了:

1
2
3
if result.Service == "" {
return Alert{}, fmt.Errorf("%w: empty service", ErrInvalidProbe)
}

happy path 测试仍然会绿,因为它本来就传了合法参数。

2. 边界条件悄悄变了

例如原来规定:

  • FailureCount >= 3Critical

后来有人误改成:

  • 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 == 2
  • FailureCount == 3
  • LatencyMS == 799
  • LatencyMS == 800

4. 零值和空值

Go 的零值语义很强,很多 bug 恰恰出在“没显式赋值也能跑”。

例如:

  • 空字符串
  • 零时间
  • nil 依赖

5. 错误断言

不是只断言“有错”,而是要断言:

  • 是不是预期那类错误
  • 错误链有没有保留
  • 文本上下文是否足够定位

6. 副作用

如果函数除了返回值,还会写日志、发通知、写缓存、修改状态,就要测副作用。

例如:

  • 只在 WarnCritical 时发送通知
  • Info 不发送通知

7. 可重复性

同样输入重复跑,结果应当稳定。

例如:

  • 固定时间源
  • 不依赖随机顺序
  • 不共享外部状态

这七类覆盖不一定每次都全部拉满,但只要你开始这样思考,测试质量会明显高于“一个 happy path + 一个失败 path 就收工”。

七、断言到底应该断什么

很多测试写得脆弱,不是因为场景选错了,而是断言对象选错了。

Go 测试里更稳的思路是:断行为,断协议,断边界,少断实现细节。

1. 优先断业务行为

比如这类断言是高价值的:

1
2
3
if got.Level != LevelCritical {
t.Fatalf("expected critical, got %s", got.Level)
}

因为它直接对应业务规则。

2. 错误用 errors.Is 或明确文本片段

比如:

1
2
3
if !errors.Is(err, ErrInvalidProbe) {
t.Fatalf("expected ErrInvalidProbe, got %v", err)
}

如果还需要补上下文,再断一段关键文本:

1
2
3
if !strings.Contains(err.Error(), "empty service") {
t.Fatalf("expected detailed error, got %v", err)
}

3. 结构体断言不要一把梭地偷懒

这里常见的偷懒写法是直接比较整个结构体:

1
2
3
if !reflect.DeepEqual(got, want) {
t.Fatalf("not equal")
}

这不是不能用,但要小心两个问题:

  • 一旦结构体新增字段,测试会成片破
  • 某些字段本来就不是本测试关心的重点

更稳的做法是断关键字段:

1
2
3
4
5
6
if got.Service != "order-api" {
t.Fatalf("unexpected service: %s", got.Service)
}
if got.Level != LevelWarn {
t.Fatalf("unexpected level: %s", got.Level)
}

4. 少断内部实现细节

比如为了验证结果,不应该去断“内部调用了第 3 行的某个私有函数”。
测试应该更关心:

  • 最终返回了什么
  • 依赖有没有被调用
  • 错误有没有按约定传播

如果测试过度依赖内部实现,一重构就会全碎。

八、用表驱动测试把核心分支一次讲清

Go 里表驱动测试很常见,不是为了显得规范,而是因为它特别适合承载“同一个行为的多个边界用例”。

例如 Evaluate 的规则判断,很适合写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
func TestEvaluateLevel(t *testing.T) {
fixedNow := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
now := func() time.Time { return fixedNow }
checkedAt := time.Date(2024, 1, 1, 9, 59, 0, 0, time.UTC)

tests := []struct {
name string
input ProbeResult
level Level
}{
{
name: "healthy service returns info",
input: ProbeResult{
Service: "order-api",
Healthy: true,
FailureCount: 0,
LatencyMS: 120,
CheckedAt: checkedAt,
},
level: LevelInfo,
},
{
name: "high latency returns warn",
input: ProbeResult{
Service: "order-api",
Healthy: true,
FailureCount: 0,
LatencyMS: 800,
CheckedAt: checkedAt,
},
level: LevelWarn,
},
{
name: "three failures returns critical",
input: ProbeResult{
Service: "order-api",
Healthy: false,
FailureCount: 3,
LatencyMS: 200,
CheckedAt: checkedAt,
},
level: LevelCritical,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Evaluate(tt.input, now)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got.Level != tt.level {
t.Fatalf("expected %s, got %s", tt.level, got.Level)
}
if !got.TriggeredAt.Equal(fixedNow) {
t.Fatalf("unexpected time: %v", got.TriggeredAt)
}
})
}
}

这个写法的好处不是“形式整齐”,而是你可以很容易看出规则覆盖了哪些格子、缺了哪些格子。

九、错误场景应该怎么测,才不是只写一句 expected error

很多测试会写成这样:

1
2
3
4
_, err := Evaluate(ProbeResult{}, time.Now)
if err == nil {
t.Fatal("expected error")
}

这只能证明“有错误”,但信息量太低。

更稳的错误测试,至少要回答两件事:

  1. 错误是不是对的类型
  2. 错误上下文是不是足够定位

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestEvaluateInvalidProbe(t *testing.T) {
_, err := Evaluate(ProbeResult{
Service: "",
Healthy: true,
FailureCount: 0,
LatencyMS: 100,
CheckedAt: time.Date(2024, 1, 1, 9, 59, 0, 0, time.UTC),
}, time.Now)

if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrInvalidProbe) {
t.Fatalf("expected ErrInvalidProbe, got %v", err)
}
if !strings.Contains(err.Error(), "empty service") {
t.Fatalf("expected error context, got %v", err)
}
}

如果你的系统后续还要基于错误类型做重试、统计或状态分流,那这类断言就不是“锦上添花”,而是核心保障。

十、边界值测试为什么经常比正常值更重要

很多逻辑 bug 根本不出现在“明显错误输入”上,而是出现在边界上。

就拿这篇文章的小规则来说:

  • LatencyMS >= 800 算慢
  • FailureCount >= 3 算严重

那最值得测的就不是 120 或 2000,而是:

  • 799
  • 800
  • 2
  • 3

边界值测试最容易直接拦住“比较符号改错”的回归。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func TestEvaluateLatencyBoundary(t *testing.T) {
now := func() time.Time {
return time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
}
checkedAt := time.Date(2024, 1, 1, 9, 59, 0, 0, time.UTC)

got799, err := Evaluate(ProbeResult{
Service: "order-api",
Healthy: true,
FailureCount: 0,
LatencyMS: 799,
CheckedAt: checkedAt,
}, now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got799.Level != LevelInfo {
t.Fatalf("expected info for 799ms, got %s", got799.Level)
}

got800, err := Evaluate(ProbeResult{
Service: "order-api",
Healthy: true,
FailureCount: 0,
LatencyMS: 800,
CheckedAt: checkedAt,
}, now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got800.Level != LevelWarn {
t.Fatalf("expected warn for 800ms, got %s", got800.Level)
}
}

这类测试看起来不花哨,但在工程里非常值钱。

十一、真实项目里,单元测试还要覆盖副作用和依赖失败

到这里如果只测 Evaluate,还不够像真实项目。
因为真实代码通常不只是返回一个结构体,还会调用依赖。

比如这里会有一个通知发送器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package alert

import "fmt"

type Sender interface {
Send(Alert) error
}

func NotifyIfNeeded(sender Sender, alert Alert) error {
if alert.Level == LevelInfo {
return nil
}
if err := sender.Send(alert); err != nil {
return fmt.Errorf("send alert for %s: %w", alert.Service, err)
}
return nil
}

这个函数至少要测三件事:

  1. Info 不发送
  2. WarnCritical 会发送
  3. 发送失败时错误链被保留

可以写一个最小假对象:

1
2
3
4
5
6
7
8
9
10
11
type fakeSender struct {
calls int
alerts []Alert
err error
}

func (f *fakeSender) Send(alert Alert) error {
f.calls++
f.alerts = append(f.alerts, alert)
return f.err
}

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func TestNotifyIfNeeded(t *testing.T) {
t.Run("info does not send", func(t *testing.T) {
sender := &fakeSender{}
err := NotifyIfNeeded(sender, Alert{
Service: "order-api",
Level: LevelInfo,
})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if sender.calls != 0 {
t.Fatalf("expected no send, got %d", sender.calls)
}
})

t.Run("warn sends once", func(t *testing.T) {
sender := &fakeSender{}
err := NotifyIfNeeded(sender, Alert{
Service: "order-api",
Level: LevelWarn,
})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if sender.calls != 1 {
t.Fatalf("expected one send, got %d", sender.calls)
}
if len(sender.alerts) != 1 || sender.alerts[0].Service != "order-api" {
t.Fatalf("unexpected alerts: %#v", sender.alerts)
}
})
}

再测错误链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func TestNotifyIfNeededWrapsError(t *testing.T) {
expected := errors.New("network timeout")
sender := &fakeSender{err: expected}

err := NotifyIfNeeded(sender, Alert{
Service: "order-api",
Level: LevelCritical,
})
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, expected) {
t.Fatalf("expected wrapped error, got %v", err)
}
if !strings.Contains(err.Error(), "order-api") {
t.Fatalf("expected service context, got %v", err)
}
}

到这里可以看到,单元测试不只是测“纯函数结果”,还要测:

  • 依赖有没有被调用
  • 被调用了几次
  • 传了什么参数
  • 失败时错误有没有被正确传播

十二、想让测试稳定,先把不确定性从设计里拆出去

很多 Go 测试不稳定,不是 testing 包不行,而是代码把不确定因素写死了。

最常见的几个来源是:

  • time.Now()
  • time.Sleep()
  • 随机数
  • 真实网络
  • 真实文件系统
  • 真实环境变量
  • map 迭代顺序

先看一个不太适合测试的写法:

1
2
3
4
5
6
7
8
9
func Evaluate(result ProbeResult) (Alert, error) {
...
return Alert{
Service: result.Service,
Level: level,
Message: buildMessage(result, level),
TriggeredAt: time.Now(),
}, nil
}

这样一来,测试如果比较整个结构体,要么每次都手动绕开 TriggeredAt,要么变得非常脆弱。

更可测的做法是把时间源作为依赖传进来:

1
2
3
4
5
6
func Evaluate(result ProbeResult, now func() time.Time) (Alert, error) {
...
return Alert{
TriggeredAt: now(),
}, nil
}

同样的思路也适用于:

  • 把 HTTP 客户端抽成接口
  • 把文件读取抽成 io.Reader
  • 把随机数生成抽成函数注入
  • 把环境变量读取集中到配置层,而不是散在业务逻辑里

可测试设计,本质上就是把业务规则和外部世界拆开。

十三、什么样的代码天生更容易写单元测试

测试写得顺不顺,往往在你开始写 xxx_test.go 之前就已经决定了一半。

更容易测试的 Go 代码通常有这些特征:

1. 输入明确,输出明确

例如一个函数明确接收参数,明确返回结果和错误,而不是偷偷读全局状态。

2. 纯逻辑和副作用分层

例如:

  • Evaluate 只负责规则判断
  • NotifyIfNeeded 只负责通知边界

这样一来,规则和依赖调用可以分开测。

3. 依赖从外部传入

不是在函数里直接 http.Getos.Opentime.Now,而是把依赖注入进来。

4. 错误有分类

这样你才有机会在测试里用 errors.Is 做稳定断言,而不是全靠字符串碰运气。

5. 单个函数职责不要太杂

如果一个函数同时做参数校验、读配置、发请求、组装消息、写数据库,那单元测试一定很累,最后不是样板很多,就是测试粒度变形。

十四、看一个更完整但仍然够小的项目骨架

把前面的纯逻辑和副作用拼起来,可以得到一个最小但很像真实工程的骨架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package alert

import (
"context"
"fmt"
"time"
)

type Evaluator struct {
Now func() time.Time
Sender Sender
}

func (e Evaluator) Handle(ctx context.Context, result ProbeResult) (Alert, error) {
if err := e.Validate(); err != nil {
return Alert{}, err
}

alert, err := Evaluate(result, e.Now)
if err != nil {
return Alert{}, err
}

select {
case <-ctx.Done():
return Alert{}, ctx.Err()
default:
}

if err := NotifyIfNeeded(e.Sender, alert); err != nil {
return Alert{}, err
}

return alert, nil
}

func (e Evaluator) Validate() error {
if e.Now == nil {
return fmt.Errorf("now func can not be nil")
}
if e.Sender == nil {
return fmt.Errorf("sender can not be nil")
}
return nil
}

针对这个骨架,测试可以分成三层:

  1. Evaluate
    测规则、边界、错误输入

  2. NotifyIfNeeded
    测副作用、调用次数、错误包装

  3. 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 ./...

如果这个包里以规则判断为主,还可以优先关注:

  1. 表驱动测试是否把关键边界都覆盖到了
  2. 错误测试是否用了 errors.Is
  3. 时间相关断言是否全部固定了时间源
  4. fake 依赖是否只实现了测试真正关心的那部分行为

如果你开始看到这些信号,说明测试已经从“会写”往“写得稳”迈进了。

十七、排障时先查什么,别一上来就怀疑测试框架

测试失败时,更稳的排查顺序通常是:

1. 先看失败是业务规则变了,还是测试假设错了

很多失败不是代码错,而是规则调整了,但测试没更新。

2. 再看是否混入了不确定因素

例如:

  • 当前时间
  • 时区
  • map 顺序
  • 并发写入
  • 环境变量污染

3. 再看断言是不是过强

比如你本来只关心 Level,却把整条完整消息全文匹配了。
消息文案微调后,测试就会假红。

4. 最后看测试数据是不是太远离真实场景

如果测试数据全是“完美对象”,那很多真实 bug 根本触发不出来。

一个很实用的方法是:
把线上或联调里出现过的真实异常输入,收敛成最小测试样本。

十八、单元测试的边界在哪里

讲到这里,也要把边界说清楚。

单元测试很重要,但它不是万能的。

下面这些问题,通常不应该指望只靠单元测试解决:

  1. 真正的数据库连接兼容性
  2. HTTP 服务之间的真实集成行为
  3. 配置文件加载和部署环境差异
  4. 并发竞态、资源泄漏、性能瓶颈
  5. 端到端链路是否全通

这些更适合集成测试、契约测试、接口测试、端到端测试或性能测试。

单元测试最擅长的是:

  • 快速反馈
  • 精准定位
  • 保护局部规则不被改坏
  • 把边界和错误语义固定住

不要让它越位,也不要因为它不能解决全部问题,就把它降格成“只跑 happy path”。

十九、给你一组练习,检验自己是不是真的会写

如果你想把这一篇吃透,建议自己补下面几组测试:

  1. Evaluate 补一个 FailureCount == 2 返回 Warn 的测试
  2. Evaluate 补一个 CheckedAt 为零值的错误测试
  3. NotifyIfNeeded 补一个 Critical 发送两次不应该发生的保护测试
  4. Handle 补一个 ctx 已取消时直接返回 context.Canceled 的测试
  5. buildMessage 改成包含时间格式后,重构测试,确保它仍然稳定

如果这些练习能比较顺地写出来,说明你已经开始真正理解:

  • 什么该断
  • 什么不该断
  • 什么属于 happy path 之外的高价值覆盖

二十、最后收个尾

Go 单元测试真正难的地方,从来不是 testing 包语法,而是你能不能建立这样一套判断:

  • 哪些行为最值得保护
  • 哪些输入最容易把代码打坏
  • 哪些错误必须被精确识别
  • 哪些外部因素必须被隔离
  • 哪些设计方式能让测试长期可维护

所以,“单元测试怎么写才不是只测 happy path” 的答案并不是多写几个 t.Run,而是要把下面这条线建立起来:

正常路径、错误路径、边界条件、副作用、确定性、可测试设计,这几个维度要一起考虑。

这样写出来的测试,才不是交作业式的“绿色截图”,而是真正能陪代码一起演进的工程资产。