Goにおけるモックのマスター:gomock vs インターフェースベースのフェイク
Olivia Novak
Dev Intern · Leapcell

テストはソフトウェア開発に不可欠であり、コードの信頼性と保守性の確保に役立ちます。Goでは、他の言語と同様に、単体テストではテスト対象のコンポーネントをその依存関係から分離する必要があります。この分離は、予測可能で集 中的なテストを行う上で非常に重要です。ここで「モッキング」が登場します。モッキングを使用すると、実際の依存関係の動作をシミュレートし、制御された応答を提供して対話を確認できます。この手法は、Goの並行処理で高度にモジュール化されたエコシステムにおいて特に重要です。しかし、適切なモッキング戦略の選択は、テストスイートの明瞭性、保守性、効率に大きな影響を与える可能性があります。この記事では、Goにおける2つの主要なモッキングアプローチを探ります。1つは強力なコード生成ツールであるgomock
、もう1つはよりGoらしい「インターフェースベースのフェイク」です。それぞれの仕組みを掘り下げ、実践的な適用例を示し、それぞれの長所と短所を議論して、情報に基づいた意思決定を支援します。
主要なモッキングコンセプトの理解
詳細に入る前に、議論の中心となるモッキングに関連するいくつかの基本的な用語を明確にしておきましょう。
- 依存関係 (Dependency): ソフトウェアにおいて、依存関係とは、他のコンポーネントがその機能を実行するために依存するコンポーネント、モジュール、またはサービスのことです。たとえば、データベースからデータを取得するサービスは、データベースクライアントに依存します。
- 単体テスト (Unit Testing): ソースコードの個々のユニット、およびそれに関連する制御データ、使用手順、運用手順のセットをテストして、使用可能かどうかを判断するソフトウェアテスト方法です。
- モック (Mock): モックオブジェクトとは、実際のオブジェクトの動作を制御された方法で模倣するシミュレートされたオブジェクトのことです。しばしば、以下のような実際の依存関係を置き換えるために使用されます。
- 遅い(例:ネットワーク呼び出し、データベース操作)。
- 予測不可能(例:外部API)。
- セットアップが難しい(例:複雑なインフラストラクチャ)。
- 利用できない(例:開発中のサービス)。 モックを使用すると、これらの依存関係の「出力」を制御し、コードがそれらと正しく対話していることを確認できます。
- スタブ (Stub): モックに似ていますが、スタブは事前定義されたデータを持つダミーオブジェクトで、テスト中に呼び出しに応答するために使用されます。スタブは主に固定応答の提供に焦点を当てていますが、モックは対話(例:特定の引数でメソッドが呼び出されたか)を検証することもできます。
- フェイク (Fake): テスト目的で実際の依存関係を置き換える任意のオブジェクトに対する一般的な用語です。フェイクは、単純なスタブから、より高度なモックオブジェクト、さらには実際のサービスの単純化されたインメモリバージョンまで多岐にわたります。私たちが議論するインターフェースベースのフェイクは、しばしばこのカテゴリに属します。
- インターフェース (Interface): Goでは、インターフェースはメソッドシグネチャのセットを定義します。インターフェースのすべてのメソッドを実装する任意の型は、そのインターフェースを満たしていると言われます。インターフェースはGoのポリモーフィズムの基本であり、
gomock
とインターフェースベースのフェイクの両方の動作の中心です。
gomock を使用したモッキング
gomock
は、Goチームの公式サポートを受けている、Goで人気のあるモッキングフレームワークです。インターフェースのモック実装を生成することで機能します。このアプローチは、強力な型安全性を提供し、洗練された動作定義と対話検証を可能にします。
gomock の仕組み
- インターフェースの定義: コードは、依存関係の具体的な型ではなく、インターフェースに依存する必要があります。これは、モッキングフレームワークに関係なく、テスト可能性のためのベストプラクティスです。
- モックの生成:
gomock
の一部であるmockgen
ツールを使用して、インターフェースのモック実装を含むGoソースファイルのモックを生成します。 - テストでのモックの使用: 単体テストでは、これらの生成されたモックのインスタンスを作成し、期待される動作(どのメソッドがどの引数で呼び出されるべきか、何を返すか)を定義してから、テスト対象のコードを実行します。
gomock
は、対話が期待どおりに行われたことを確認します。
gomock を使用した実践的な例
データを取得するFetcher
インターフェースと、Fetcher
を使用するProcessor
サービスがあると想像してみましょう。
// main.go package main import ( "fmt" ) // Fetcher インターフェースはデータの取得方法を定義します type Fetcher interface { Fetch(id string) (string, error) } // DataProcessor サービスは Fetcher を使用します type DataProcessor struct { f Fetcher } // NewDataProcessor は新しい DataProcessor を作成します func NewDataProcessor(f Fetcher) *DataProcessor { return &DataProcessor{f: f} } // ProcessData はデータを取得し、それに対して何かを行います func (dp *DataProcessor) ProcessData(id string) (string, error) { data, err := dp.f.Fetch(id) if err != nil { return "", fmt.Errorf("failed to fetch data: %w", err) } // 実際のシナリオでは、データを処理します return "Processed: " + data, nil }
次に、DataProcessor
のテストをgomock
を使用して記述しましょう。
まず、gomock
とmockgen
をインストールします。
go install github.com/golang/mock/mockgen@latest
次に、Fetcher
インターフェースのモックを生成します。main.go
が現在のディレクトリにあると仮定します。
mockgen -source=main.go -destination=mock_fetcher_test.go -package=main_test
このコマンドは、同じディレクトリにmock_fetcher_test.go
を生成します。-package=main_test
引数は、生成されたモックが別の_test
パッケージに配置されることを意味します。これはGoテストで一般的なプラクティスです。
次に、processor_test.go
でテストを記述しましょう。
// processor_test.go package main_test import ( "errors" "testing" "github.com/golang/mock/gomock" "main" ) func TestDataProcessor_ProcessData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() // すべての期待される呼び出しが行われたことをアサートします mockFetcher := NewMockFetcher(ctrl) // 生成されたモックを使用します // 期待される動作を定義します: Fetch は "123" で呼び出され、"test-data" を返す必要があります mockFetcher.EXPECT().Fetch("123").Return("test-data", nil).Times(1) processor := main.NewDataProcessor(mockFetcher) result, err := processor.ProcessData("123") if err != nil { t.Fatalf("expected no error, got %v", err) } expected := "Processed: test-data" if result != expected { t.Errorf("expected %q, got %q", expected, result) } } func TestDataProcessor_ProcessData_Error(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockFetcher := NewMockFetcher(ctrl) expectedErr := errors.New("network error") // エラーケースの期待される動作を定義します mockFetcher.EXPECT().Fetch("456").Return("", expectedErr).Times(1) processor := main.NewDataProcessor(mockFetcher) _, err := processor.ProcessData("456") if err == nil { t.Fatal("expected an error, got nil") } if !errors.Is(err, expectedErr) { t.Errorf("expected error containing %v, got %v", expectedErr, err) } }
gomock
の長所:
- 強力な型安全性:
gomock
はコードを生成するため、型の一致しない場合や不正なメソッド呼び出しはコンパイル時に検出されます。 - 豊富なDSL(ドメイン固有言語): 期待値の定義、引数マッチング(
gomock.Any()
、gomock.Eq()
)、呼び出し回数の期待値(Times()
、MinTimes()
、MaxTimes()
)、順次呼び出し(InOrder()
)などを提供する強力で表現力豊かなAPIを提供します。 - 対話検証: 値を返すだけでなく、
gomock
は期待されるメソッドが正しい引数で呼び出されたかどうかを検証します。 - 自動生成: 複雑なインターフェースのボイラープレートコードを削減します。
gomock
の短所:
- ビルドステップ: 追加の
mockgen
ステップが必要であり、適切に統合されていない場合、開発ループがわずかに遅くなったり、CI/CDパイプラインが複雑になったりする可能性があります。 - コードの肥大化: 生成されたモックファイルは、特にメソッドが多いインターフェースの場合、大きくなる可能性があり、プロジェクトを散らかしてしまう可能性があります。
- 学習曲線: DSLは強力ですが、新規参入者には学習曲線があります。
- Goらしくない: 一部のGo開発者は、生成されたコードよりも明示的な手書きコードを好みます。
インターフェースベースのフェイク(手書きフェイク)
このアプローチは、インターフェースを実装する構造体を手動で作成することです。これらの「フェイク」実装は、通常、テストファイルまたは専用のtestutil
パッケージに直接記述され、特定のテストに必要な動作を正確に提供します。
インターフェースベースのフェイクの仕組み
- インターフェースの定義:
gomock
と同様に、コードはインターフェースに依存します。 - フェイク実装の作成: インターフェースを満たす
struct
を手動で記述します。この構造体は通常、期待される戻り値を格納するためのフィールド、検証のために引数をキャプチャするためのフィールド、またはカスタムロジックを注入するためのフィールドを含みます。 - テストでのフェイクの使用: フェイクをインスタンス化し、その動作を直接設定してから、テスト対象のコードに渡します。その後、フェイクの状態(例:キャプチャされた引数)またはテストされた関数の直接の結果に対してアサーションを行います。
インターフェースベースのフェイクを使用した実践的な例
Fetcher
とDataProcessor
の例を再利用しましょう。
// main.go(変更なし) package main import ( "fmt" ) type Fetcher interface { Fetch(id string) (string, error) } type DataProcessor struct { f Fetcher } func NewDataProcessor(f Fetcher) *DataProcessor { return &DataProcessor{f: f} } func (dp *DataProcessor) ProcessData(id string) (string, error) { data, err := dp.f.Fetch(id) if err != nil { return "", fmt.Errorf("failed to fetch data: %w", err) } return "Processed: " + data, nil }
次に、フェイクFetcher
を記述し、processor_test.go
でテストします。
// processor_test.go package main_test import ( "errors" "testing" "main" ) // FakeFetcher は Fetcher インターフェースの手書きフェイク実装です type FakeFetcher struct { FetchFunc func(id string) (string, error) FetchedID string // Fetch に渡された引数をキャプチャするため FetchCallCount int // Fetch が呼び出された回数をカウントするため } // Fetch は FakeFetcher の Fetcher インターフェースを実装します func (ff *FakeFetcher) Fetch(id string) (string, error) { ff.FetchCallCount++ ff.FetchedID = id // 引数をキャプチャします if ff.FetchFunc != nil { return ff.FetchFunc(id) } // 特定の FetchFunc が提供されていない場合のデフォルトの動作 return "default-fake-data", nil } func TestProcessor_ProcessData_Fake(t *testing.T) { // 成功シナリオのために FakeFetcher を設定します fakeFetcher := &FakeFetcher{ // Fetch の動作を明示的に定義します FetchFunc: func(id string) (string, error) { if id == "123" { return "test-data", nil } return "", errors.New("unexpected ID") }, } processor := main.NewDataProcessor(fakeFetcher) result, err := processor.ProcessData("123") if err != nil { t.Fatalf("expected no error, got %v", err) } expected := "Processed: test-data" if result != expected { t.Errorf("expected %q, got %q", expected, result) } // 対話を確認します if fakeFetcher.FetchedID != "123" { t.Errorf("expected Fetch to be called with ID '123', got %q", fakeFetcher.FetchedID) } if fakeFetcher.FetchCallCount != 1 { t.Errorf("expected Fetch to be called once, got %d", fakeFetcher.FetchCallCount) } } func TestProcessor_ProcessData_Fake_Error(t *testing.T) { expectedErr := errors.New("database connection failed") fakeFetcher := &FakeFetcher{ FetchFunc: func(id string) (string, error) { return "", expectedErr }, } processor := main.NewDataProcessor(fakeFetcher) _, err := processor.ProcessData("456") if err == nil { t.Fatal("expected an error, got nil") } if !errors.Is(err, expectedErr) { t.Errorf("expected error containing %v, got %v", expectedErr, err) } }
インターフェースベースのフェイクの長所:
- Goらしい: このアプローチはGo開発者にとって非常に自然で、インターフェースと構造体を直接活用します。
- コード生成なし: 管理する追加のビルドステップや生成されたファイルはありません。
- 完全な制御: フェイクの実装を完全に制御でき、非常に特定的かつ複雑なテストシナリオを可能にします。
- 明示的で読みやすい: フェイクの動作はテストコードに明示的に定義されており、一目でその目的を理解しやすいことがよくあります。
インターフェースベースのフェイクの短所:
- ボイラープレート: メソッドが多いインターフェースの場合、包括的なフェイクを記述するには、特にすべてのメソッドをさまざまなテストケース(ゼロ値を返す場合でも)で実装する必要がある場合、かなりのボイラープレートコードが必要になることがあります。
- 手動検証: メソッド呼び出し、引数、呼び出し回数の検証には、フェイク内で手動で追跡し、テストで明示的にアサーションを行う必要があり、エラーが発生しやすく冗長になる可能性があります。
- 複雑な期待値に対する柔軟性が低い: 複雑な条件付き動作や高度な引数マッチングの定義は、手書きのフェイクをすぐに扱いにくくする可能性があります。
- コンパイル時安全性: コンパイラはフェイクがインターフェースを実装していることを保証しますが、テストがフェイクの内部状態(例:
FetchFunc
の設定を忘れる)を正しく設定していることを検証するわけではありません。
モッキング戦略の選択
gomock
とインターフェースベースのフェイクの選択は、利便性、制御、およびモックするインターフェースの複雑さのバランスを取ることにしばしば依存します。
-
gomock
を使用する場合:- インターフェースが大きいまたは複雑な場合:
gomock
は、多くのメソッドを実装する際のボイラープレートを削減します。 - 詳細な対話検証が必要な場合: 特定の回数、特定の順序、または正確な引数でメソッドが呼び出されたことを確認したい場合、DSLを備えた
gomock
がその真価を発揮します。 - 強力な型安全性が最優先される場合: コンパイル時のチェックにより、一般的なモッキングエラーを防ぐことができます。
- コード生成に慣れている場合: ビルドステップや生成ファイルに抵抗がない場合。
- インターフェースが大きいまたは複雑な場合:
-
インターフェースベースのフェイクを使用する場合:
- インターフェースが小さく、集中している場合: フェイクを手動で記述するコストは低いです。
io.Reader
、io.Writer
などが典型的な例です。 - 明示的で手書きのコードを好む場合: コード生成を回避し、より「純粋なGo」アプローチを優先する場合。
- 動作がシンプルで、テストのために状態を持たない場合: フェイクは、複雑なロジックや検証ニーズなしに、主に特定の値のみを返します。
- パフォーマンスが非常に重要な場合(ただし、モックではしばしば無視できるレベル):
gomock
の内部処理の反射や動的な動作を回避することは、非常に特定のパフォーマンス重視のテストスイートではわずかな考慮事項となる可能性があります。 - コードで表現しやすい、高度にカスタマイズされた、またはシナリオ固有の動作が必要な場合DSLを介して行うよりも。
- インターフェースが小さく、集中している場合: フェイクを手動で記述するコストは低いです。
結論
gomock
とインターフェースベースのフェイクは、どちらもGoにおける単体テストに有用なツールであり、それぞれ独自の長所があります。gomock
は、コード生成を活用して、複雑なモッキングシナリオのための強力で型安全で機能豊富なDSLを提供します。一方、インターフェースベースのフェイクは、外部ツールなしに、よりGoらしく、透明で、高度にカスタマイズ可能なソリューションを提供します。最適な戦略は、多くの場合、モックするインターフェースの複雑さと、コード生成を好むか明示的な手書きコードを好むかというチームの好みに合わせたツールの選択にあります。Goでの効果的なテストは、最終的には依存関係を分離することにかかっており、どちらの方法もこの重要な目標を達成するための堅牢な方法を提供します。