technical-communication

TDD はテストを先に書くだけではない:Go で回す三つのフィードバックループ

段階的に成長する Go の料金計算例で、TDD の seam、Red–Green、vertical slice、Mock の境界、失敗パターンを具体的に整理する。

Jul 14, 2026
TDDGoTestingSoftware Design

TDD を実践していると言いながら、実際には機能を完成させ、後からテストを足し、CI を green にしているチームは少なくありません。テストがない状態よりは良いのですが、そのテストは設計に参加していません。次に何を書くべきかを教えてもいません。

Test-Driven Development で重要なのは、test file を production code より先に保存したかどうかではありません。まず観測可能な振る舞いを失敗する例として置き、その例を通す最小で一貫した実装を書くことです。短い loop を繰り返すと、public interface は使いやすいか、責務の境界は明確か、制御できない依存が正しい場所に出ているかを継続的に確認できます。

ここでは文字列反転や足し算ではなく、小さな Go の注文料金計算を三つの cycle で成長させます。金額は浮動小数点の誤差を避けるため整数の最小通貨単位で持ち、public result は整数の日本円です。

TDD が本当に制御するもの

Red–Green–Refactor は円で描かれることが多いですが、三つの色を覚えるだけでは足りません。実際に役立つ loop は次のようなものです。

観測可能な振る舞いを一つ選ぶ
  -> 失敗するテストを書く(Red)
  -> 今の振る舞いだけを実装する(Green)
  -> 全て green の状態で構造を見直す
  -> 次の振る舞いを選ぶ

この順序は三つのことを制御します。

第一に、テストは失敗できることを証明しなければなりません。Red を一度も見ていなければ、必要な振る舞いがない時に失敗するテストなのか、実装に関係なく通るテストなのか判断できません。

第二に、一つの cycle では一つの振る舞いだけを扱います。Green は思いついた将来機能をまとめて実装する段階ではありません。現在の例を満たす、小さくても一貫した解を完成させる段階です。

第三に、構造変更は suite が green の時に行います。その後に振る舞いが変われば、失敗は未完成の機能ではなく、直前の構造変更を指します。

つまり TDD は coverage を増やす技法というより、具体例で設計を継続的に校正する方法です。Coverage はある行が実行されたことを示せても、正しい振る舞いを観測したかまでは示しません。

最初に testing seam を決める

最初のテストを書く前に、system をどこから観測するか決めます。この public boundary を testing seam と呼びます。

今回の seam は最初は export された料金計算関数です。外部為替レートが必要になると、次の public method に発展します。

func (Calculator) TotalJPY(context.Context, Order) (int64, error)

テストはこの入口から注文を渡し、結果を読み取ります。private helper を直接呼ばず、内部状態も見ません。同じ入力から同じ観測結果が得られるなら、一つの loop を複数 module に置き換えてもテストは通るべきです。

Seam を先に合意する理由もここにあります。全 layer を同じ強度でテストすることはできません。安定した public behavior に力を集めれば、通常の refactoring で大量のテストが壊れる事態を避けられます。

今回扱う三つの振る舞い

対象は次の三つだけで、一つずつ進めます。

  1. 通常の JPY 注文は明細合計を返す。
  2. Gold 注文には 10% discount を適用する。
  3. USD 注文は為替レート境界を使って JPY を返す。

Silver tier、coupon の併用、税、banker’s rounding、rate cache は先に設計しません。将来必要になる可能性はありますが、今それを要求する failing test はありません。

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)
	}
}

この時点では type も関数もありません。先に go test を実行すると compile error になります。

undefined: Order

これは有効な Red です。Go toolchain がテストを見つけ、compile stage まで実行したことを証明しています。Go は _test.go file、TestXxx function、go test command という built-in の仕組みを提供しています。公式 tutorial でも実装を意図的に壊し、テストが失敗を検出できることを確認しています。1

このテストを green にするために必要なのは、注文の type と合計処理だけです。

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
}

Literal の 3000 が重要です。実装前に手で計算した独立の oracle だからです。Test の中でも production code と同じ loop を書いて expected を計算すると、両方が同じ間違いを持つ可能性があります。それでは同じ algorithm のコピー同士が一致することしか証明できません。これは tautological test です。

Slice 2:discount を一つ追加する

次の requirement は Gold 注文の 10% discount です。OrderCustomerTier を追加し、分かりやすい例を置きます。12000 円の 10% 引きは 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)
	}
}

古い実装は compile できますが、Red はより具体的になります。

TotalJPY() = 12000; want 10800

Green では今の rule だけを追加します。

if order.CustomerTier == "gold" {
	total = total * 90 / 100
}

現時点で configurable な strategy table にする理由はありません。TDD は abstraction を禁止しているのではなく、実際に発生した変更圧力で abstraction を正当化します。今は一つの branch が rule を十分明確に表しています。

Slice 3:外部 rate によって interface が変わる

三つ目の振る舞いは問題の性質を変えます。JPY の料金計算は pure calculation ですが、USD から JPY への変換には外部 rate が必要です。外部処理は block、failure、cancel があり得るため、ここで context.Context と error result に具体的な理由が生まれます。

Slice 1 で未来を予測してこの構造を入れたわけではありません。外部 boundary を必要とするテストが現れた時に初めて、public function を依存を持つ 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
}

テストでは system boundary にだけ小さな fake を置きます。自分たちの料金計算 module ではなく、実際の為替サービスを置き換えます。

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 で USD の最小単位を整数 JPY に変換します。2000 cent に 150 を掛けて 100 で割ると 3000 円です。実際の payment system では通貨精度、rounding policy、rate timestamp、overflow も定義する必要があります。ここでは testing boundary に集中するため対象外にしています。

テストは Rate の call count を assert せず、helper の中身も見ません。返された 3000 が、boundary の値が public behavior に使われたことを既に証明しています。

三つの cycle を終えた実装

三つの振る舞いを完成させた production code は次の形です。

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
}

全て green の状態で、三つの例を table-driven subtests に整理できます。Go の t.Run は各 case に独立した名前を与え、-run で一つの case だけを実行できます。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)
			}
		})
	}
}

Table に整理するのは suite が green になった後の構造レビューです。将来のテストを最初にまとめて書くこととは違います。Behavior を変えずに表現だけを小さくしています。

このテストが壊れにくい理由

残しておきたい特徴は四つあります。

  1. Test name は内部 call sequence ではなく business behavior を表す。
  2. 全ての結果を public seam から読む。
  3. Expected value は独立に計算した具体例から来る。
  4. 置き換えるのは為替レートという system boundary だけ。

Discount を private function に切り出しても、合計 algorithm を変えても、file structure を整理しても、三つの public behavior が同じならテストは通るべきです。

よくある三つの偽 TDD

実装詳細をテストする

discountService.Apply が必ず一回呼ばれることを要求したり、private method を直接テストしたりする例です。この種のテストは product behavior ではなく現在の構造を記録します。結果を変えない refactoring でも壊れます。

Boundary interaction 自体が behavior の場合もあります。例えば payment request に idempotency key が必須なら、その送信は contract です。しかし今回、caller が必要としているのは注文合計であり、内部 method の call count ではありません。

同義反復の expected value

Production code が loop で合計し、テストも同じ loop で want を計算すると、同じ形で間違える可能性があります。Specification の固定例、手計算、別の独立した source of truth の方が強い oracle です。

Horizontal slicing

最初に十数個のテストを書き、その後で機能全体を実装する方法は test-first に見えます。しかし実際には想像上の system shape を検証しています。実装からの feedback を一度も受けていないので、interface と fixture が過剰になりやすいです。

Vertical slice は一つのテスト、一つの実装、一つの feedback です。二つ目のテストは最初の cycle で分かった interface を使い、三つ目は新しい制約によって本当に boundary を変えます。

Green はわざと悪いコードを書く段階ではない

「テストを通すだけのコードを書く」は、hard-code や読みにくいコードを許す言葉として誤解されます。より正確には、現在の振る舞いに必要な最小で一貫した実装を書く、という意味です。

Green でも明確な naming、適切な data type、正しい error propagation は必要です。避けるのは、behavioral evidence のない拡張です。Plugin system、将来の discount abstraction、テストのない config switch は今は要りません。

Mock は system boundary に止める

置き換える価値があるのは、process の外側や自分で制御できないものです。

  • third-party API
  • database と message queue
  • clock と randomness
  • filesystem
  • payment、email、exchange-rate service

自分たちが管理する module は、通常は real implementation のまま組み合わせます。内部 object を大量に Mock すると、テストは call graph の写しになります。Code を整理した瞬間にその graph は古くなります。

Mock 自体が目的ではありません。Test database、in-memory implementation、controlled clock の方が現実の boundary に近い場合もあります。判断基準は同じです。構造への coupling を最小限にして、対象 behavior を観測できるかどうかです。

TDD を無理に使わない方がよい場面

TDD は全作業への default answer ではありません。次のような task は、先に別の方法で不確実性を下げた方がよいことがあります。

  • 実現可能な path 自体が不明な disposable technical spike
  • 実画面で比較する必要がある純粋な visual iteration
  • business decision がほとんどない generated glue
  • prototype と user feedback で発見しなければ記述できない behavior

だから production code を永遠にテストしない、という意味ではありません。破棄する前提を明確にした spike で interface と制約を学び、安定した最初の behavior から TDD を始めます。探索コードをいつの間にか production にしないことが重要です。

チームで使う checklist

次の cycle を始める前に確認します。

  1. どの public behavior を観測しているか。
  2. Team は testing seam に合意したか。
  3. 期待した理由でテストが失敗するのを見たか。
  4. この cycle で追加する behavior は一つだけか。
  5. Expected value は独立した根拠から来ているか。
  6. 置き換えは制御できない system boundary に限られているか。
  7. Green 後の構造変更でも suite 全体が通るか。

Kent Beck の Test-Driven Development: By Example は、小さな例を連続させながらこの進め方を展開しています。3 練習すべきなのは loop の名前を覚えることではありません。一つの失敗が一つの問題を説明し、一つの Green が一つの feedback loop だけを閉じるところまで、step を小さくすることです。

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