53.2 单元测试

一句话

单元测试只测一个函数 / 方法的行为,不碰任何外部依赖,追求快、独立、可重复。

测什么、不测什么

✅ 该测 ❌ 不该测
纯逻辑(计算、校验、转换、排序) 外部依赖(数据库、HTTP、文件、时钟)
分支与边界条件(空值、零值、极值) 框架 / 库的自身行为(别人测过了)
错误处理路径(参数非法、状态异常) 私有实现细节(内部变量、私有函数名)

Go 怎么写:表驱动测试

表驱动是 Go 社区的标配——一个测试函数覆盖多组用例:

func Add(a, b int) int { return a + b }

func TestAdd(t *testing.T) {
    tests := []struct {
        name string
        a, b int
        want int
    }{
        {"正数相加", 1, 2, 3},
        {"负数相加", -1, -2, -3},
        {"零参与", 0, 5, 5},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Add(tt.a, tt.b); got != tt.want {
                t.Errorf("Add(%d,%d) = %d, want %d", tt.a, tt.b, got, tt.want)
            }
        })
    }
}

两个要点:

  • t.Run 把每组用例变成子测试,跑挂了可以单独 go test -run 'TestAdd/正数相加' 复现。
  • 失败信息带上输入和期望——只写 "not equal" 的测试,等于没写。

FIRST 五原则

原则 含义 违反的后果
Fast 毫秒级,一个包测完 < 1 秒 慢 → 人不跑
Independent 测试间不共享状态、不依赖顺序 互相污染,时好时坏
Repeatable 任何时候跑结果一样 依赖时间/随机 → flaky
Self-validating 自己断言,不靠人肉看输出 "看打印日志"不算测试
Timely 和功能代码一起写 事后补 = 没人补

好测试 vs 坏测试

好测试 坏测试
测行为(给输入,看输出) 测实现(断言调了哪个函数)
一个测试一个断言意图 一个测试里塞十件事
失败信息一眼看出错在哪 失败只有一句 "not equal"

最普遍的反模式:测了实现细节

// ❌ 测实现:验证内部调用了 save(),断言 saveCalled == true
// 后果:你一重构(改函数名、改内部结构),测试就挂一大片,
//       即使对外行为完全没变。测试成了重构的"人质"。

🔑 记住:改实现不该让测试挂,改行为才该。 如果每次重构都要连带改测试,说明测试和实现耦合了——这是单元测试里最普遍的坏味道。

落到数字:单测的成本与并行

项 量级 说明
一个纯逻辑单测 0.1–1ms 无 IO、无 sleep
带 mock 的单测 1–10ms 反射/序列化开销
t.Parallel() 后 接近核数倍加速 前提:无共享状态
1000 个单测 1–10s 超过 30s 就该查 “谁在 sleep/IO”
func TestParseAmount(t *testing.T) {
    t.Parallel()
    cases := []struct{ in string; want int64; ok bool }{
        {"19.90", 1990, true},
        {"-1", 0, false},
        {"1e3", 0, false},        // 明确不接受的输入
    }
    for _, c := range cases {
        c := c
        t.Run(c.in, func(t *testing.T) {
            t.Parallel()
            got, err := ParseAmount(c.in)
            if (err == nil) != c.ok || got != c.want {
                t.Fatalf("ParseAmount(%q) = %d, %v; want %d, ok=%v", c.in, got, err, c.want, c.ok)
            }
        })
    }
}

用模糊测试兜住 “没想到的输入”

go test -fuzz=FuzzParseAmount -fuzztime=30s ./...

模糊测试不替代表驱动,但它能自动找出让解析器 panic 的输入——这类 bug 手写用例几乎覆盖不到。

自检

Q1 你重构了一个函数——行为完全不变,只改了内部实现。单元测试应该怎样?

▸ 答案:**全部照常通过。**如果挂了一片,说明测试耦合了实现细节 (断言了内部函数调用/内部状态),需要重写成 “测行为”。

Q2 一个单元测试失败信息只有 got != want,够吗?缺什么?

▸ 答案:不够。缺输入是什么、期望多少、实际多少。没有这三样,你每次都要先复现、再加打印才能知道错哪——测试白写了。

Q3 为什么单测里不要 time.Sleep?

▸ 答案:因为 sleep 让测试又慢又不稳:慢是确定的(每个用例至少几十 ms),不稳是随机的(机器负载高时又不够)。需要等待时用 “轮询 + 超时” 或注入可控时钟,而不是固定 sleep。

Q4 单测要不要连数据库?

▸ 答案:纯逻辑单测不连(连了就是集成测试,见 53.3 集成测试)。但 “仓储层的 SQL 正确性” 必须由集成测试覆盖——把它塞进单测,只会得到 “用内存 fake 验证了一个假契约”。

Q5 一个测试方法里断言 20 个字段,是好还是坏?

▸ 答案:通常是坏:失败时只知道 “有一处不对”,定位成本高。更好的做法是一个测试一个行为,必要时用子测试拆分;但如果 20 个字段属于 “同一个不可分割的结构”,整体断言是可以接受的(如快照测试)。