technical-communication
TDD 不只是先写测试:用 Go 走完三个真实的反馈循环
用一个逐步生长的 Go 订单计价例子,讲清 TDD 的测试接缝、Red–Green、纵向切片、Mock 边界和常见失败方式。
很多团队说自己在做 TDD,实际流程却是:功能已经写完,再补几条测试,让 CI 变绿。这当然比完全没有测试好,但测试没有参与设计,也没有告诉我们下一步应该写什么。
TDD(Test-Driven Development,测试驱动开发)的关键不是“测试文件比生产代码先保存”,而是让一个可观察行为先以失败测试的形式出现,再用最小而完整的实现让它通过。这个短反馈循环会不断逼问代码:公共接口是否好用,职责边界是否清楚,依赖是否放在正确的位置。
下面不用字符串反转或加法函数,而是用一个逐步长出来的 Go 订单计价例子,走完三个循环。金额使用整数最小单位,避免把浮点数误差带进示例;最终结果以整数日元返回。
TDD 真正控制的是什么
Red–Green–Refactor 常被画成一个圆,但只记住三种颜色不够。真正有用的循环是:
选择一个可观察行为
-> 写一条会失败的测试(Red)
-> 只实现当前行为(Green)
-> 在全绿状态下审视结构
-> 再选择下一条行为
这个顺序控制了三件事。
第一,测试必须证明自己真的能失败。如果测试从来没有红过,就不知道它是在验证需求,还是无论代码怎样都会通过。
第二,一次只处理一个行为。Green 不是把脑中想到的未来功能一起写进去,而是完成当前例子要求的最小闭环。
第三,结构调整只能在全绿状态下进行。这样一旦行为改变,失败会指向刚才的结构修改,而不是混在尚未完成的新功能里。
TDD 因此更接近“用例子持续校准设计”,而不是“提高覆盖率的测试技巧”。覆盖率可以告诉你哪些代码执行过,却不能告诉你测试是否观察了正确的行为。
先确认测试接缝
写第一条测试前,要先决定从哪里观察系统。这个公共边界就是测试接缝(test seam)。
我们的接缝是订单计价的公开函数,后来随着外部汇率依赖出现,它会演化成公开方法:
func (Calculator) TotalJPY(context.Context, Order) (int64, error)
测试只通过这个入口提交订单并读取结果,不调用私有 helper,也不查询某个内部变量。只要输入相同、可观察结果相同,内部代码可以从一个循环改成多个模块,测试仍然应该通过。
这也是为什么接缝要先确认。一个系统不可能在所有层都用相同强度测试。把主要精力放在稳定的公共行为上,才能避免重构一次就碎一地测试。
这次要实现的行为
我们只接受三个行为,而且一次只看一个:
- 普通日元订单返回所有商品小计之和。
- Gold 等级订单享受 10% 折扣。
- 美元订单通过汇率边界转换成日元。
这里故意不提前设计 Silver 等级、优惠券叠加、税率、银行家舍入或汇率缓存。它们可能以后很重要,但现在没有任何失败测试要求它们存在。
Slice 1:先让普通订单算对
第一条测试使用一个手算得出的例子:两件 1200 日元的商品,加上一件 600 日元的商品,合计 3000 日元。
func TestTotalJPYReturnsSumForRegularOrder(t *testing.T) {
order := Order{
Currency: "JPY",
Items: []LineItem{
{UnitPriceMinor: 1200, Quantity: 2},
{UnitPriceMinor: 600, Quantity: 1},
},
}
got, err := TotalJPY(order)
if err != nil {
t.Fatalf("TotalJPY() error = %v", err)
}
if got != 3000 {
t.Fatalf("TotalJPY() = %d; want 3000", got)
}
}
此时类型和函数都不存在。运行 go test,先看到编译失败:
undefined: Order
这是有效的 Red。它证明测试被 Go 工具链发现并执行到了编译阶段。Go 通过 _test.go 文件、TestXxx 函数和 go test 命令提供内置测试机制;官方教程也建议主动破坏实现,确认测试确实会失败。1
让这条测试变绿,只需要订单类型和求和逻辑:
type LineItem struct {
UnitPriceMinor int64
Quantity int64
}
type Order struct {
Currency string
Items []LineItem
}
func TotalJPY(order Order) (int64, error) {
var total int64
for _, item := range order.Items {
total += item.UnitPriceMinor * item.Quantity
}
return total, nil
}
这里的 3000 很重要。它来自实现之前的手算结果,是独立的判断依据。如果测试也写一个同样的循环算出 expected,测试和实现可能犯同一个错误,最后只是证明“两段相同逻辑得到了相同答案”。这叫同义反复测试。
Slice 2:增加一个折扣行为
下一条需求是 Gold 订单打九折。我们先给 Order 增加 CustomerTier,然后写一个金额足够直观的例子:12000 日元打九折,应为 10800。
func TestTotalJPYAppliesGoldDiscount(t *testing.T) {
order := Order{
Currency: "JPY",
CustomerTier: "gold",
Items: []LineItem{{
UnitPriceMinor: 12000,
Quantity: 1,
}},
}
got, err := TotalJPY(order)
if err != nil {
t.Fatalf("TotalJPY() error = %v", err)
}
if got != 10800 {
t.Fatalf("TotalJPY() = %d; want 10800", got)
}
}
旧实现可以编译,但测试会给出更有信息量的 Red:
TotalJPY() = 12000; want 10800
Green 只增加当前规则:
if order.CustomerTier == "gold" {
total = total * 90 / 100
}
此时没有理由把等级改成复杂策略表,也没有理由增加配置中心。TDD 不禁止抽象;它要求抽象由已经出现的变化压力来证明。一个分支目前足以清楚表达规则。
Slice 3:外部汇率迫使接口演化
第三个行为改变了问题性质。日元订单是纯计算,美元转日元却需要外部汇率。外部调用可能阻塞、失败或被取消,因此 context.Context 和返回错误现在有了具体理由。
我们没有在第一步“预见未来”塞入这些结构。直到测试需要外部边界,函数才演化成持有依赖的 Calculator:
type Rate struct {
Numerator int64
Denominator int64
}
type ExchangeRateProvider interface {
Rate(ctx context.Context, from, to string) (Rate, error)
}
type Calculator struct {
rates ExchangeRateProvider
}
测试在系统边界放一个很小的 fake。它不模拟我们自己的计价模块,只代替真实汇率服务:
type stubRates struct {
rate Rate
}
func (s stubRates) Rate(
_ context.Context,
_, _ string,
) (Rate, error) {
return s.rate, nil
}
func TestCalculatorConvertsUSDOrderToJPY(t *testing.T) {
calculator := NewCalculator(stubRates{
rate: Rate{Numerator: 150, Denominator: 100},
})
order := Order{
Currency: "USD",
Items: []LineItem{{
UnitPriceMinor: 2000,
Quantity: 1,
}},
}
got, err := calculator.TotalJPY(context.Background(), order)
if err != nil {
t.Fatalf("TotalJPY() error = %v", err)
}
if got != 3000 {
t.Fatalf("TotalJPY() = %d; want 3000", got)
}
}
示例里的 150/100 表示把美元最小单位换成整数日元:2000 美分乘以 150,再除以 100,得到 3000 日元。这只是为了把重点留在 TDD 边界上;真实支付系统还必须明确币种精度、舍入方式、汇率时间点和溢出策略。
测试没有断言 Rate 被调用了几次,也没有检查内部 helper。返回结果 3000 已经证明边界提供的数据参与了公开行为。
三轮之后的完整实现
三个行为完成后,生产代码是这样:
package pricing
import "context"
type LineItem struct {
UnitPriceMinor int64
Quantity int64
}
type Order struct {
Currency string
CustomerTier string
Items []LineItem
}
type Rate struct {
Numerator int64
Denominator int64
}
type ExchangeRateProvider interface {
Rate(ctx context.Context, from, to string) (Rate, error)
}
type Calculator struct {
rates ExchangeRateProvider
}
func NewCalculator(rates ExchangeRateProvider) Calculator {
return Calculator{rates: rates}
}
func (c Calculator) TotalJPY(
ctx context.Context,
order Order,
) (int64, error) {
var total int64
for _, item := range order.Items {
total += item.UnitPriceMinor * item.Quantity
}
if order.CustomerTier == "gold" {
total = total * 90 / 100
}
if order.Currency == "" || order.Currency == "JPY" {
return total, nil
}
rate, err := c.rates.Rate(ctx, order.Currency, "JPY")
if err != nil {
return 0, err
}
return total * rate.Numerator / rate.Denominator, nil
}
在全绿状态下,可以把三个例子整理成 table-driven subtests。Go 的 t.Run 会给每个子测试独立名称,也允许用 -run 精确执行某个场景。2
package pricing
import (
"context"
"testing"
)
type stubRates struct {
rate Rate
}
func (s stubRates) Rate(
_ context.Context,
_, _ string,
) (Rate, error) {
return s.rate, nil
}
func TestCalculatorTotalJPY(t *testing.T) {
tests := []struct {
name string
calculator Calculator
order Order
want int64
}{
{
name: "regular JPY order",
calculator: NewCalculator(nil),
order: Order{Currency: "JPY", Items: []LineItem{
{UnitPriceMinor: 1200, Quantity: 2},
{UnitPriceMinor: 600, Quantity: 1},
}},
want: 3000,
},
{
name: "Gold discount",
calculator: NewCalculator(nil),
order: Order{Currency: "JPY", CustomerTier: "gold", Items: []LineItem{
{UnitPriceMinor: 12000, Quantity: 1},
}},
want: 10800,
},
{
name: "USD conversion",
calculator: NewCalculator(stubRates{
rate: Rate{Numerator: 150, Denominator: 100},
}),
order: Order{Currency: "USD", Items: []LineItem{
{UnitPriceMinor: 2000, Quantity: 1},
}},
want: 3000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.calculator.TotalJPY(
context.Background(),
tt.order,
)
if err != nil {
t.Fatalf("TotalJPY() error = %v", err)
}
if got != tt.want {
t.Fatalf("TotalJPY() = %d; want %d", got, tt.want)
}
})
}
}
把测试整理成表格是全绿之后的结构审视,不是提前一次写出所有未来测试。行为没有变,测试的表达更紧凑了。
为什么这些测试比较耐用
这组测试有四个值得保留的特征:
- 测试名描述业务行为,而不是内部调用过程。
- 所有结果都从公开接缝读取。
- 期望值来自事先算好的具体例子。
- 只有汇率这个系统边界被替换。
如果以后把折扣提取到私有函数、把商品求和改成其他算法,或者调整文件结构,只要三个公开行为没变,测试就不该失败。
三种常见的伪 TDD
测试实现细节
例如断言 discountService.Apply 必须调用一次,或者直接测试私有方法。这样的测试记录的是当前结构,不是产品行为。重构没有改变结果,测试却会失败。
边界交互有时本身就是行为,例如必须向支付服务发送幂等键。但在本例中,用户关心的是订单总价,不是内部函数调用次数。
同义反复的期望值
如果生产代码循环累加,测试也复制同样的循环计算 want,两边很可能一起错。更可靠的做法是使用规格里的固定例子、人工算式或另一个独立可信来源。
横向切片
先写十几条测试,再一次性实现全部功能,看起来“测试优先”,实际是在验证想象中的系统形状。测试尚未从任何实现反馈中学习,接口和 fixture 很容易过度设计。
纵向切片则是一条测试、一个实现、一次反馈。第二条测试会利用第一轮学到的接口,第三条再让真实的新约束推动边界变化。
Green 不是故意写烂代码
“只写足够通过测试的代码”经常被误解为硬编码或不顾可读性。更准确的说法是:写出当前行为所需的最小而完整的实现。
Green 阶段仍然要有清楚的命名、正确的错误传播和合理的数据类型。不能做的是没有行为证据的扩展:预建插件系统、抽象未来折扣、增加没有测试的配置开关。
Mock 应该停在系统边界
适合替换的通常是进程之外或不可控的东西:
- 第三方 API
- 数据库或消息队列
- 时钟与随机数
- 文件系统
- 支付、邮件和汇率服务
自己控制的模块尽量用真实实现组合起来。大量 Mock 内部对象,会让测试变成一张调用关系图;代码一重构,图就过期。
Mock 也不是目的。测试数据库、内存实现或可控时钟有时比行为脚本更接近真实边界。选择标准仍然是:怎样用最少耦合观察目标行为。
哪些场景不值得强行 TDD
TDD 不是所有工作的默认答案。下面几类任务常常需要先用别的方法降低不确定性:
- 一次性探索或技术 spike,连可行路径都还不知道。
- 纯视觉细节调整,需要通过实际画面比较。
- 由工具生成、几乎没有业务判断的胶水代码。
- 行为尚不能说清,只能通过原型和用户反馈发现需求。
这并不等于这些代码永远不测试。可以先做一个明确会丢弃的 spike,学到接口和约束后,再从稳定行为开始 TDD。不要把探索代码偷偷变成生产代码,就是这条边界的关键。
团队落地检查表
开始下一轮前,可以快速检查:
- 我们正在观察哪个公开行为?
- 测试接缝是否已由团队确认?
- 是否亲眼看到测试因为正确原因失败?
- 这一轮是否只有一个新行为?
- 期望值是否来自独立依据?
- Mock 是否只位于不可控的系统边界?
- Green 之后,结构调整是否保持全套测试通过?
Kent Beck 的 Test-Driven Development: By Example 用连续的小例子系统化展示了这种工作方式。3 真正需要练习的不是记住循环名称,而是把步子缩小到每一次失败都能说明问题、每一次通过都只关闭一个反馈回路。
References
- Go Documentation, Add a test
- Go Blog, Using Subtests and Sub-benchmarks
- Kent Beck, Test-Driven Development: By Example, Addison-Wesley