🧪 ユニットテスト:Go言語におけるコマンドパターン vs モック 🔗

Go言語でユニットテストを書く際、ファイルシステムやシステムクロック、外部コマンドといった外部依存の扱いは厄介になりがちです。この記事では、この課題に対処する3つのアプローチを比較し、複雑なモックに代わるクリーンでテストしやすい選択肢として「コマンドパターン(Command Pattern)」がいかに優れているかを解説します。

例として、インクリメンタルビルドのベンチマークを計測する関数を見てみましょう。

func Benchmark(binary string, sourcePath string, resultPath string, numBuilds int) error {
	if err := build(binary); err != nil {
		return err
	}
	source, err := read(sourcePath)
	if err != nil {
		return err
	}
	
	deltas := []time.Duration{}
	for i := 0; i < numBuilds; i++ {
		if err := write(sourcePath, edit(source, i)); err != nil {
			return err
		}
		
		start := now()
		if err := build(binary); err != nil {
			return err
		}
		
		delta := now().Sub(start)
		deltas = append(deltas, delta)
		
		if err := revert(sourcePath); err != nil {
			return err
		}
	}
	return write(resultPath, report(deltas))
}

📑 目次 🔗


⚠️ 問題点 🔗

この Benchmark 関数は、次の複数の処理を行っています。

  1. バイナリをビルドする。
  2. ソースファイルを読み込む。
  3. ループを回してソースを編集し、ビルド時間を計測し、ファイルを元に戻す(リバートする)。
  4. 最終的なレポートを書き出す。

この関数を直接テストするのは困難です。なぜなら、次の4つの外部依存に頼っているからです。

多くの開発者は、外部に依存しない純粋な関数である edit と report だけをテストし、中心となるオーケストレーション(処理全体の制御)ロジックをテストしないまま放置してしまいがちです。ここからは、プロセス全体をテスト可能にするための設計改善アプローチを見ていきましょう。


1️⃣ アプローチ1:コードを Main に移動する 🔗

場合によっては、依存処理を上位の呼び出し元(多くの場合は main)へと押し上げることができます。

func Benchmark(binary string, sourcePath string, source string, numBuilds int) (string, error) {
	// ... core loop logic remains ...
	// returning the report string instead of writing it
	return report(deltas), nil
}

func Main() error {
	// setup and initial build moved here
	source, _ := read(sourcePath)
	result, _ := Benchmark(binary, sourcePath, source, 3)
	return write(resultPath, result)
}

評価: これは多少助けにはなりますが、今回のケースでは不十分です。Benchmark のループ自体が、依然として毎回のイテレーションでビルドシステム、クロック、ファイルシステム、バージョン管理とやり取りする必要があるからです。


2️⃣ アプローチ2:依存性の注入(モック / フェイク) 🔗

伝統的なオブジェクト指向のアプローチは、依存性の注入(Dependency Injection: DI)です。すべての外部アクションを表すインターフェースを定義します。

type Delegate interface {
	Build(binary string) error
	Read(path string) (string, error)
	Write(path string, content string) error
	Now() time.Time
	Revert(path string) error
}

type BenchmarkRunner struct {
	Delegate Delegate
}

func (b *BenchmarkRunner) Run(binary string, sourcePath string, resultPath string, numBuilds int) error {
	// ... implementation using b.Delegate.Build(), b.Delegate.Now(), etc ...
}

評価: この方法はうまく機能し、テスト時にはフェイクの委譲先(Delegate)に差し替えることができます。しかし、副作用の種類ごとにインターフェースのメソッドとモック実装を定義する必要があり、インターフェースの肥大化を招きやすいという欠点があります。


3️⃣ アプローチ3:コマンドパターン(Command Pattern) 🔗

あらゆるアクションに対して個別のインターフェースメソッドを定義する代わりに、すべての副作用を「中央の『Runner』によって実行される汎用的なコマンド」として扱うことができます。

まず、コマンドを実行するとはどういうことかを定義します。

type Runner interface {
	Run(cmd Command) (Result, error)
}

次に、具体的な各コマンドをシンプルな構造体(または定数)として定義します。

type BuildCmd struct { Binary string }
type ReadCmd struct { Path string }
type WriteCmd struct { Path string; Content string }
type NowCmd struct {}
type RevertCmd struct { Path string }

そして、ベンチマークのロジックをリファクタリングし、実行したいコマンドを返すか、Runnerに制御を委ねるようにします。

type BenchmarkProcess struct {
	runner Runner
}

func (p *BenchmarkProcess) Execute(binary, sourcePath, resultPath string, numBuilds int) error {
	_, err := p.runner.Run(BuildCmd{Binary: binary})
	if err != nil { return err }
	
	readRes, err := p.runner.Run(ReadCmd{Path: sourcePath})
	if err != nil { return err }
	source := readRes.(string) // Type assertion needed here

	// ... rest of the loop using p.runner.Run() ...
}

💡 なぜコマンドパターンの方がテストに適しているのか 🔗

このパターンの真の威力は、ユニットテストで発揮されます。あらゆる依存関係に対して複雑なモックインターフェースを実装する代わりに、FakeRunner を1つだけ書けば済むのです。

テストコードは、「期待されるコマンド」と「モックされたレスポンス」のシンプルなリストになります。

func TestBenchmark(t *testing.T) {
	// 1. Define the script: "When you see this command, return this result"
	fakeRunner := NewFakeRunner([]Step{
		{Cmd: BuildCmd{"binary"}, Err: nil},
		{Cmd: ReadCmd{"source_path"}, Res: "source code", Err: nil},
		{Cmd: WriteCmd{"source_path", "edited code"}, Err: nil},
		{Cmd: NowCmd{}, Res: time.Unix(0, 0), Err: nil},
		// ... and so on ...
	})

	p := BenchmarkProcess{runner: fakeRunner}
	err := p.Execute("binary", "source_path", "result_path", 1)
	
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	
	// 2. Verify the runner consumed all expected steps
	if !fakeRunner.IsDone() {
		t.Fatalf("did not execute all expected commands")
	}
}

🎯 まとめ 🔗

関数呼び出しをデータ構造(Commands)に変換することで、ビジネスロジックを副作用からきれいに分離できます。


📚 無料電子書籍をダウンロード 🔗

カウエの生き方のオフライン版を入手しましょう——🍏 健康、💰 富、❤️ 愛、🧘 意味を最適化するための実践ガイドです。

カウエの生き方

カウエの生き方

📬 ニュースレターを購読する 🔗

新しいエッセイ、本の要約、より良く生きるための実践ガイドを公開した際にお知らせします。スパムはありません——いつでも配信停止できます。


💬 コメントとディスカッション 🔗

質問、ご意見、提案はありますか?ぜひ下であなたの考えを共有してください。コメント欄は GitHub Discussions(広告なし・スパムなし)を利用しています——下の GitHub でサインイン をクリックしてコメントやリアクションを残してください(無料の GitHub アカウントは1分未満で作成できます)。



最終更新日: 2026/10/11.

免責事項:当サイトのコンテンツは情報提供および教育のみを目的としており、医療、心理、金融、投資、税務、または法律上の助言を構成するものではありません。ここに掲載されている考え、キュレーション、意見はすべて私個人のものであり、雇用主の見解を代表するものではありません。書籍、講演、記事の要約は独立した教育的な解説であり、それぞれの著者の知的財産です。編集、フォーマット、要約の補助としてAIツールを使用する場合がありますが、正確性を期すよう努めているものの誤りが含まれる可能性があります。プライバシー:当サイトでは、任意のニュースレター購読(follow.it)、コメント機能(giscus / GitHub Discussions)、および埋め込みメディア(YouTube)に外部サービスを利用しており、これらのサービスはそれぞれのプライバシーポリシーに従って訪問者のIPアドレスやCookieを処理する場合があります。