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 字段,一个参数的事。