40.1 可测试性
一句话
可测试性 = 可控 (输入、时间、随机、外部依赖都能被替换)+ 可观测 (结果通过返回值/状态/事件暴露)+ 可隔离 (不启数据库、不连网络也能验证);它由依赖方向决定,不由测试数量决定。
三个属性,缺一个就写不出快测试
| 属性 | 具体含义 | 缺失时的症状 |
|---|---|---|
| 可控 | 时间、随机数、ID、外部服务、并发时序都能由测试指定 | 只能 sleep 等、断言只能写「大于 0」 |
| 可观测 | 业务结果通过返回值、显式状态、发出的事件表达 | 要查数据库、要读日志才能验证 |
| 可隔离 | 依赖是接口,测试里可换内存实现 | 每个用例都要 Docker + 建表 + 造数据 |
🔑 判定标准很简单:**这段逻辑能不能在 1 毫秒内、不启动任何外部进程的情况下被断言?**能 → 可测试;不能 → 问题在代码结构,不在测试工具。
七个 “不可测信号”,看到就说明结构有问题
| 代码里的味道 | 根因 | 改法 |
|---|---|---|
time.Now() 直接写在业务逻辑里 | 时间是不可控输入 | 注入 Clock 接口或 now func() time.Time |
rand.Intn() / uuid.New() 直接调用 | 随机不可复现 | 注入 IDGen,测试用固定序列 |
全局单例 db.Get()、包级变量 | 隐式依赖,无法替换 | 构造器注入,依赖显式化为字段 |
函数体内 http.Get(...) | 网络不可隔离 | 抽出 Gateway 接口,测试用 fake |
init() 里连数据库 / 读配置 | 包一加载就产生副作用 | 延迟到显式初始化(NewXxx + 返回值错误) |
| 业务计算与 IO 交织在一个 300 行函数里 | 无法单独验证规则 | 拆出纯函数(规则)+ 编排(IO) |
用 time.Sleep 等异步结果 | 时序不可控 | 用 channel / 条件等待(eventually) |
四种手段,按性价比排序
| 手段 | 做法 | 适用 | 成本 |
|---|---|---|---|
| ① 纯函数化 | 把规则从 IO 中剥离,输入输出全是值 | 计价、折扣、状态流转判断 | 最低,收益最高 |
| ② 构造器注入 | 依赖作为字段传入 NewService(repo, clock) | 服务层、用例层 | 低 |
| ③ 参数注入 | 单次行为差异用参数传(如 now func()) | 少量场景、工具函数 | 最低 |
| ④ 端口 + 适配器 | 定义接口,生产实现 / 测试实现并列 | 数据库、消息、第三方 API | 中(多一层接口) |
优先级很明确:能把逻辑做成纯函数,就不要先想着 mock。一个 func fee(amount int64, rate float64) int64 不需要任何测试替身,跑一次 40 纳秒。
反例与正例
// ❌ 不可测:三个外部依赖写死在函数体内,测试必须真连库、真发 HTTP、真等时间
func PayV1(orderID string, amount int64) (PaidOrder, error) {
now := time.Now() // 时间不可控
var rate float64
err := db.QueryRow("SELECT rate FROM cfg").Scan(&rate) // 数据库不可替换
if err != nil {
return PaidOrder{}, err
}
resp, err := http.Post("https://pay.example.com/charge", "application/json", body)
if err != nil {
return PaidOrder{}, err
}
return PaidOrder{OrderID: orderID, Amount: amount, PaidAt: now}, nil
}
这段代码要测 “金额非法时是否拒绝”,你得先起 MySQL、灌配置、起一个假支付服务——一个用例 2 秒起,跑 200 个用例就是 7 分钟。
// ✅ 可测:规则是纯函数,依赖从构造器进来
package main
import (
"errors"
"math"
"time"
)
// fee 是纯函数:没有 IO、没有时间、没有随机,测试不需要任何替身。
func fee(amount int64, rate float64) int64 {
return int64(math.Round(float64(amount) * rate))
}
// 端口:由使用方定义(见 [26.3 整洁架构](../../L2-架构风格/26-分层与依赖/26.3-整洁架构.md)),测试里换内存实现。
type Clock interface{ Now() time.Time }
type Gateway interface {
Charge(orderID string, amount int64) (txnID string, err error)
}
type PaidOrder struct {
OrderID string
TxnID string
Amount int64
PaidAt time.Time
}
type PaymentService struct {
clock Clock
gateway Gateway
}
func NewPaymentService(clock Clock, gateway Gateway) *PaymentService {
return &PaymentService{clock: clock, gateway: gateway}
}
func (s *PaymentService) Pay(orderID string, amount int64, rate float64) (PaidOrder, error) {
if amount <= 0 {
return PaidOrder{}, errors.New("金额必须大于 0")
}
total := amount + fee(amount, rate)
txnID, err := s.gateway.Charge(orderID, total)
if err != nil {
return PaidOrder{}, err // 失败不落单,直接透传
}
return PaidOrder{OrderID: orderID, TxnID: txnID, Amount: total, PaidAt: s.clock.Now()}, nil
}
// ===== 以下是生产实现,只在 main 里组装,测试用不到 =====
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
type fixedClock struct{ t time.Time }
func (c fixedClock) Now() time.Time { return c.t }
type fakeGateway struct {
charges []int64
err error
}
func (g *fakeGateway) Charge(orderID string, amount int64) (string, error) {
g.charges = append(g.charges, amount)
if g.err != nil {
return "", g.err
}
return "txn-" + orderID, nil
}
func main() {
clk := fixedClock{t: time.Date(2026, 10, 24, 15, 0, 0, 0, time.UTC)}
gw := &fakeGateway{}
svc := NewPaymentService(clk, gw)
got, err := svc.Pay("A1001", 1000, 0.1)
println("PaidOrder:", got.OrderID, got.TxnID, got.Amount, got.PaidAt.Format(time.RFC3339))
println("err:", err == nil)
println("实际扣款金额:", gw.charges[0])
}
同一套代码,测试不需要数据库、不需要网络、不需要等待:
package main
import (
"errors"
"testing"
"time"
)
// ① 纯函数:表驱动,零依赖
func TestFee(t *testing.T) {
cases := []struct {
amount int64
rate float64
want int64
}{
{1000, 0.1, 100},
{0, 0.1, 0},
{999, 0.006, 6}, // 5.994 四舍五入
{100, 0, 0},
}
for _, c := range cases {
if got := fee(c.amount, c.rate); got != c.want {
t.Fatalf("fee(%d, %v) = %d, want %d", c.amount, c.rate, got, c.want)
}
}
}
// ② 时间可控:断言精确到秒,而不是「大于 0」
func TestPayUsesInjectedClock(t *testing.T) {
want := time.Date(2026, 10, 24, 15, 0, 0, 0, time.UTC)
svc := NewPaymentService(fixedClock{t: want}, &fakeGateway{})
got, err := svc.Pay("A1001", 1000, 0.1)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !got.PaidAt.Equal(want) {
t.Fatalf("PaidAt = %v, want %v", got.PaidAt, want)
}
if got.Amount != 1100 {
t.Fatalf("Amount = %d, want 1100", got.Amount)
}
}
// ③ 外部失败可模拟:不用真的让支付网关挂掉
func TestPayPropagatesGatewayError(t *testing.T) {
gw := &fakeGateway{err: errors.New("网关超时")}
svc := NewPaymentService(fixedClock{t: time.Unix(0, 0)}, gw)
if _, err := svc.Pay("A1001", 1000, 0.1); err == nil {
t.Fatal("期望返回错误,实际 nil")
}
}
// ④ 边界:非法金额在调用网关之前就被拦住
func TestPayRejectsNonPositiveAmount(t *testing.T) {
gw := &fakeGateway{}
svc := NewPaymentService(fixedClock{t: time.Unix(0, 0)}, gw)
if _, err := svc.Pay("A1001", 0, 0.1); err == nil {
t.Fatal("期望返回错误,实际 nil")
}
if len(gw.charges) != 0 {
t.Fatalf("非法金额不应调用网关,实际调用 %d 次", len(gw.charges))
}
}
$ go test -v
=== RUN TestFee
--- PASS: TestFee (0.00s)
=== RUN TestPayUsesInjectedClock
--- PASS: TestPayUsesInjectedClock (0.00s)
=== RUN TestPayPropagatesGatewayError
--- PASS: TestPayPropagatesGatewayError (0.00s)
=== RUN TestPayRejectsNonPositiveAmount
--- PASS: TestPayRejectsNonPositiveAmount (0.00s)
PASS
ok payment 0.23s ← 0.23s 全是编译与链接,四个用例本身都是 (0.00s)
四个用例的执行时间都小于 1 毫秒 (输出里的 (0.00s))。同样的四个断言,用 V1 的写法需要 MySQL + 假支付服务,20 秒起步,还要处理端口冲突、数据清理与并发跑测试时的互相干扰。
可测试性的时间账,值得算
| 一个用例的耗时 | 1000 个用例 | 开发者一天跑 20 次 | 一年浪费(10 人团队) |
|---|---|---|---|
| 5ms(纯单测) | 5 秒 | 2 分钟 | ~1.6 人天 |
| 2s(需启 DB) | 33 分钟 | 11 小时 | ~275 人天 |
| 60s(E2E 一条链路) | 16.7 小时 | 不可能跑 | 测试被绕过 |
第三行才是真正的成本:慢到没人跑,等于没有测试 (见 53.1 测试金字塔)。
设计信号:你的代码 “可测” 长什么样
- [ ] 一个用例的测试能在 10ms 内跑完,不需要 Docker / 网络 / 磁盘
- [ ] 测试里
t.Fatal的断言是精确值,不是 “不为空” “大于 0” - [ ] 造一个依赖只要 1–3 行 (
&fakeGateway{}),不需要 30 行 mock 配置 - [ ] 每个用例可单独运行、可乱序运行、可并行运行 (
t.Parallel()) - [ ] 时间、随机、ID 全部可注入;测试里没有
time.Sleep - [ ] 业务规则函数不带
error之外的 IO 副作用,参数与返回值都是可比较的值
与整洁架构的关系
“可测试性” 不是测出来的,是画依赖方向时决定的 (26.3 整洁架构):
不可测:handler → service → MySQL(gorm) 业务逻辑内嵌 IO,测试必须带 DB
可测: handler → use case → 端口 ← 适配器 use case 依赖接口,测试换内存实现
依赖倒置的直接收益就是可测试性:外层的技术实现可替换,测试只是 “另一个外层实现”。
反模式
| 反模式 | 后果 |
|---|---|
| 为了测试把私有字段/函数导出 | 生产 API 被测试污染,封装失效 |
生产代码里塞 if testing 分支 | 测试的是另一个程序,不是你的代码 |
| 用 monkey patch / 修改全局变量替换依赖 | 语言层面越来越难做,测试间互相污染,并行即失败 |
| 测试依赖执行顺序或共享可变状态 | 单跑绿、全跑红,团队逐渐不敢加测试 |
用 time.Sleep(2s) 等异步结果 | CI 偶发失败(flaky),最终被注释掉 |
| 大量 mock 框架配置(20 行只为一个返回值) | 测试比实现还难读,没人愿意维护 |
| 把「覆盖率 100%」当可测试性 | 覆盖率可以由无断言的调用堆出来 |
| 只测 happy path | 异常分支(网关超时、金额非法)才是事故高发区 |
自检
Q1 一个函数 func Pay(orderID string, amount int64) error,测试里必须 time.Sleep(3*time.Second) 才能断言结果。问题出在哪一层?给出最小改动的修法。
▸ 答案:出在副作用是异步的、且没有可观测完成信号。函数返回 nil 只代表 “提交成功”,不代表 “支付完成”。最小修法不是加长 sleep,而是让完成信号可观测:① 返回 Result / Done() <-chan struct{},测试用 select + 超时等待;② 或者把 “发起” 与 “完成处理” 拆成两个函数,测试直接调完成处理的那个纯逻辑。加 sleep 是把不确定性转嫁给 CI。
Q2 团队引入了 mock 框架,每个用例 30 行配置,覆盖率从 60% 涨到 85%,但线上事故没减少。为什么?
▸ 答案:mock 配置的 30 行里,绝大多数在重复描述实现细节 (期望调用顺序、参数匹配),测试实际验证的是 “代码是否按我写的顺序调用了依赖”,而不是 “业务结果对不对”。这种测试在重构时成批变红,于是团队用 AnyTimes() 一路放宽,最后退化成 “只要不 panic 就算过”。可测试性的核心是纯函数化 + 依赖注入,让断言落在返回值上;覆盖率只是副产品。
Q3 为什么 “时间可注入” 比 “数据库可 mock” 更常被忽略,后果却更严重?
▸ 答案:数据库依赖很显眼 (要连、要建表),团队通常会处理;时间依赖是隐式的——time.Now() 在代码里看起来像个普通函数调用。后果有三:① 无法断言精确值,测试只能写 “时间不为零” 这种假断言;② 跨天、跨月、闰年、时区、夏令时的边界逻辑永远测不到 (对账日切、优惠券过期、订阅续费都在这里出事故);③ 涉及 “过期” 的逻辑只能用 sleep 制造,测试变慢且不稳定。修法极便宜:Clock 接口或 now func() time.Time 字段,一个参数的事。