technical-communication

TDD 不只是先写测试:用 Go 走完三个真实的反馈循环

用一个逐步生长的 Go 订单计价例子,讲清 TDD 的测试接缝、Red–Green、纵向切片、Mock 边界和常见失败方式。

Jul 14, 2026
TDDGoTestingSoftware Design

很多团队说自己在做 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,也不查询某个内部变量。只要输入相同、可观察结果相同,内部代码可以从一个循环改成多个模块,测试仍然应该通过。

这也是为什么接缝要先确认。一个系统不可能在所有层都用相同强度测试。把主要精力放在稳定的公共行为上,才能避免重构一次就碎一地测试。

这次要实现的行为

我们只接受三个行为,而且一次只看一个:

  1. 普通日元订单返回所有商品小计之和。
  2. Gold 等级订单享受 10% 折扣。
  3. 美元订单通过汇率边界转换成日元。

这里故意不提前设计 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)
			}
		})
	}
}

把测试整理成表格是全绿之后的结构审视,不是提前一次写出所有未来测试。行为没有变,测试的表达更紧凑了。

为什么这些测试比较耐用

这组测试有四个值得保留的特征:

  1. 测试名描述业务行为,而不是内部调用过程。
  2. 所有结果都从公开接缝读取。
  3. 期望值来自事先算好的具体例子。
  4. 只有汇率这个系统边界被替换。

如果以后把折扣提取到私有函数、把商品求和改成其他算法,或者调整文件结构,只要三个公开行为没变,测试就不该失败。

三种常见的伪 TDD

测试实现细节

例如断言 discountService.Apply 必须调用一次,或者直接测试私有方法。这样的测试记录的是当前结构,不是产品行为。重构没有改变结果,测试却会失败。

边界交互有时本身就是行为,例如必须向支付服务发送幂等键。但在本例中,用户关心的是订单总价,不是内部函数调用次数。

同义反复的期望值

如果生产代码循环累加,测试也复制同样的循环计算 want,两边很可能一起错。更可靠的做法是使用规格里的固定例子、人工算式或另一个独立可信来源。

横向切片

先写十几条测试,再一次性实现全部功能,看起来“测试优先”,实际是在验证想象中的系统形状。测试尚未从任何实现反馈中学习,接口和 fixture 很容易过度设计。

纵向切片则是一条测试、一个实现、一次反馈。第二条测试会利用第一轮学到的接口,第三条再让真实的新约束推动边界变化。

Green 不是故意写烂代码

“只写足够通过测试的代码”经常被误解为硬编码或不顾可读性。更准确的说法是:写出当前行为所需的最小而完整的实现。

Green 阶段仍然要有清楚的命名、正确的错误传播和合理的数据类型。不能做的是没有行为证据的扩展:预建插件系统、抽象未来折扣、增加没有测试的配置开关。

Mock 应该停在系统边界

适合替换的通常是进程之外或不可控的东西:

  • 第三方 API
  • 数据库或消息队列
  • 时钟与随机数
  • 文件系统
  • 支付、邮件和汇率服务

自己控制的模块尽量用真实实现组合起来。大量 Mock 内部对象,会让测试变成一张调用关系图;代码一重构,图就过期。

Mock 也不是目的。测试数据库、内存实现或可控时钟有时比行为脚本更接近真实边界。选择标准仍然是:怎样用最少耦合观察目标行为。

哪些场景不值得强行 TDD

TDD 不是所有工作的默认答案。下面几类任务常常需要先用别的方法降低不确定性:

  • 一次性探索或技术 spike,连可行路径都还不知道。
  • 纯视觉细节调整,需要通过实际画面比较。
  • 由工具生成、几乎没有业务判断的胶水代码。
  • 行为尚不能说清,只能通过原型和用户反馈发现需求。

这并不等于这些代码永远不测试。可以先做一个明确会丢弃的 spike,学到接口和约束后,再从稳定行为开始 TDD。不要把探索代码偷偷变成生产代码,就是这条边界的关键。

团队落地检查表

开始下一轮前,可以快速检查:

  1. 我们正在观察哪个公开行为?
  2. 测试接缝是否已由团队确认?
  3. 是否亲眼看到测试因为正确原因失败?
  4. 这一轮是否只有一个新行为?
  5. 期望值是否来自独立依据?
  6. Mock 是否只位于不可控的系统边界?
  7. Green 之后,结构调整是否保持全套测试通过?

Kent Beck 的 Test-Driven Development: By Example 用连续的小例子系统化展示了这种工作方式。3 真正需要练习的不是记住循环名称,而是把步子缩小到每一次失败都能说明问题、每一次通过都只关闭一个反馈回路。

References

  1. Go Documentation, Add a test
  2. Go Blog, Using Subtests and Sub-benchmarks
  3. Kent Beck, Test-Driven Development: By Example, Addison-Wesley