Goでのテストダブルの選び方
Min-jun Kim
Dev Intern · Leapcell

テストはソフトウェア開発に不可欠な部分であり、アプリケーションの信頼性と正確性を保証します。Goでは、HTTP経由で通信するサービスを扱う際、一般的なジレンマが生じます。外部HTTP依存関係とのやり取りをテストするには、統合テストのために実際の(ただし一時的な)HTTPサーバーを起動すべきか、それとも単体テスト中にこれらのやり取りをシミュレートするために綿密にモックオブジェクトを作成すべきか?この問いは単なる学術的なものではありません。テストの保守性、実行速度、カバレッジに significant な影響を与えます。この記事では、Goでの効果的なテスト戦略のためのガイダンスを提供し、統合テストのためのhttptest.NewServerと単体テストのためのモックサービスインターフェースとのトレードオフを掘り下げます。
詳細に入る前に、議論の中心となるいくつかのコアコンセプトを明確にしましょう。
- 単体テスト: ソフトウェアの個々のユニットまたはコンポーネントを隔離してテストすることに焦点を当てます。目標は、コードの各ユニットが期待どおりに機能することを確認することです。外部依存関係は、ユニットを隔離するために通常「モック」または「スタブ」されます。
- 統合テスト: ソフトウェアシステムのさまざまなユニットまたはコンポーネントが互いにどのように相互作用するかをテストすることを目的としています。これには、システムと外部サービス、データベース、またはAPIとのやり取りをテストすることがよく含まれます。
httptest.NewServer: HTTPテストのためのユーティリティを提供するGoパッケージです。httptest.NewServerはランダムなローカルネットワークアドレスでリッスンする新しいHTTPサーバーを作成します。これは、ライブHTTPエンドポイントが必要だが、その応答と動作を制御したい統合テストに ideal です。- モックサービスインターフェース: 単体テストのコンテキストでは、これは実際の依存関係の動作を模倣する代替オブジェクトを作成することを指します。実際の外部サービスを呼び出す代わりに、コードはこのモックとやり取りします。これは定義済みの応答を提供し、実際のネットワーク呼び出しなしにコードのロジックを隔離してテストできるようにします。
それでは、2つの主要なアプローチを探ってみましょう。
統合テストのためのhttptest.NewServer
httptest.NewServerは統合テストのための強力なツールです。テストスイート内に完全に機能するHTTPサーバーを作成でき、テスト対象のコードはそれにリクエストを送信できます。これは実際の外部サービスをシミュレートし、HTTPヘッダー、ステータスコード、ボディコンテンツを含む完全なリクエスト-レスポンスサイクルをテストできます。
仕組み:
http.Handlerをhttptest.NewServerに渡すと、それが着信リクエストを処理します。サーバーはランダムな利用可能なポートで起動し、そのURLはts.URL経由でアクセスできます。
例:
外部APIからユーザーデータを取得するクライアントを考えてみましょう。
package main import ( "encoding/json" "fmt" "io" "net/http" ) type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` } type UserClient struct { baseURL string client *http.Client } func NewUserClient(baseURL string) *UserClient { return &UserClient{ baseURL: baseURL, client: &http.Client{}, } } func (uc *UserClient) GetUser(id string) (*User, error) { resp, err := uc.client.Get(fmt.Sprintf("%s/users/%s", uc.baseURL, id)) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } var user User if err := json.Unmarshal(body, &user); err != nil { return nil, fmt.Errorf("failed to unmarshal user data: %w", err) } return &user, nil }
次に、httptest.NewServerを使用した統合テストを記述しましょう。
package main import ( "encoding/json" "net/http" "net/http/httptest" testing "testing" ) func TestUserClient_GetUser_Integration(t *testing.T) { // モックサーバーを作成 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/users/123" { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(User{ID: "123", Name: "John Doe", Email: "john@example.com"}) } else if r.URL.Path == "/users/404" { w.WriteHeader(http.StatusNotFound) } else { w.WriteHeader(http.StatusInternalServerError) } })) defer ts.Close() // テスト終了時にサーバーを閉じる client := NewUserClient(ts.URL) // テストケース 1: ユーザー取得成功 user, err := client.GetUser("123") if err != nil { t.Fatalf("Expected no error, got %v", err) } if user.ID != "123" || user.Name != "John Doe" { t.Errorf("Expected user John Doe with ID 123, got %v", user) } // テストケース 2: ユーザーが見つからない _, err = client.GetUser("404") if err == nil { t.Fatalf("Expected an error for user not found, got nil") } expectedErrMsg := "unexpected status code: 404" if err.Error() != expectedErrMsg { t.Errorf("Expected error '%s', got '%s'", expectedErrMsg, err.Error()) } }
利点:
- 高い忠実度: ネットワークのシリアライゼーション/デシリアライゼーション、HTTPステータスコード、ヘッダーを含む、HTTPサービスとの現実的なやり取りをテストします。
- 包括的なエラー処理: さまざまなHTTPエラーシナリオ(4xx、5xx)を正確にテストできます。
- モックのボイラープレートが少ない:
http.Handlerの動作を定義しますが、これは複数のメソッドを持つ完全なモックインターフェースを定義するよりも簡単な場合が多いです。
欠点:
- 実行速度が遅い: 実際のHTTPサーバーの起動とシャットダウンには、純粋なインメモリ単体テストよりも時間がかかります。
- (ローカル)ネットワーク依存: ローカルサーバーですが、ネットワークスタックのやり取りも含まれ、微妙な問題や遅延の原因となる可能性があります。
- デバッグの複雑さ:
http.Handler内の問題のデバッグは、モックオブジェクトを直接デバッグするよりも難しい場合があります。
単体テストのためのモックサービスインターフェース
単体テストでは、コードを外部依存関係から隔離することが最優先事項です。ここでモックサービスインターフェースが威力を発揮します。実際のHTTPエンドポイントにアクセスする代わりに、外部サービスのインターフェースを定義し、そのインターフェースのモック実装を作成します。テスト対象のコードは、このモックとやり取りします。
仕組み:
まず、UserClientが満たす必要があるインターフェースを定義します(または、より一般的には、UserClientが依存関係としてインターフェースを受け取る場合、UserClientが使用する依存関係のインターフェースを定義します)。次に、このインターフェースを実装するモック構造体を作成し、そのメソッドの戻り値と副作用を制御できるようにします。
例:
HTTPリクエストを行うためのインターフェースに依存するようにUserClientをリファクタリングしましょう。
package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" ) // HTTPClientはHTTPリクエストを行うためのインターフェースを定義します。 // 現在のUserClientにはGetメソッドのみが必要です。 type HTTPClient interface { Get(url string) (*http.Response, error) } // ConcreteHttpClientは標準のhttp.Clientのラッパーです type ConcreteHttpClient struct { client *http.Client } func NewConcreteHttpClient() *ConcreteHttpClient { return &ConcreteHttpClient{client: &http.Client{}} } func (c *ConcreteHttpClient) Get(url string) (*http.Response, error) { return c.client.Get(url) } type UserClientWithInterface struct { baseURL string httpClient HTTPClient // 依存性注入されたHTTPClient } func NewUserClientWithInterface(baseURL string, client HTTPClient) *UserClientWithInterface { return &UserClientWithInterface{ baseURL: baseURL, httpClient: client, } } func (uc *UserClientWithInterface) GetUser(id string) (*User, error) { resp, err := uc.httpClient.Get(fmt.Sprintf("%s/users/%s", uc.baseURL, id)) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } var user User if err := json.Unmarshal(body, &user); err != nil { return nil, fmt.Errorf("failed to unmarshal user data: %w", err) } return &user, nil }
次に、単体テストのためのモックHTTPClientを作成しましょう。
package main import ( "bytes" "io" "net/http" testing "testing" ) // MockHTTPClientはHTTPClientインターフェースのモック実装です type MockHTTPClient struct { GetResponse *http.Response GetError error } func (m *MockHTTPClient) Get(url string) (*http.Response, error) { return m.GetResponse, m.GetError } func TestUserClientWithInterface_GetUser_Unit(t *testing.T) { // テストケース 1: ユーザー取得成功 mockClientSuccess := &MockHTTPClient{ GetResponse: &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"id":"123","name":"John Doe","email":"john@example.com"}`)), }, GetError: nil, } clientSuccess := NewUserClientWithInterface("http://api.example.com", mockClientSuccess) user, err := clientSuccess.GetUser("123") if err != nil { t.Fatalf("Expected no error, got %v", err) } if user.ID != "123" || user.Name != "John Doe" { t.Errorf("Expected user John Doe with ID 123, got %v", user) } // テストケース 2: ユーザーが見つからない (ステータス 404) mockClientNotFound := &MockHTTPClient{ GetResponse: &http.Response{ StatusCode: http.StatusNotFound, Body: io.NopCloser(bytes.NewBufferString("")), // 404の場合空のボディ }, GetError: nil, } clientNotFound := NewUserClientWithInterface("http://api.example.com", mockClientNotFound) _, err = clientNotFound.GetUser("404") if err == nil { t.Fatalf("Expected an error for user not found, got nil") } expectedErrMsgNotFound := "unexpected status code: 404" if err.Error() != expectedErrMsgNotFound { t.Errorf("Expected error '%s', got '%s'", expectedErrMsgNotFound, err.Error()) } // テストケース 3: ネットワークエラー mockClientNetworkError := &MockHTTPClient{ GetResponse: nil, GetError: fmt.Errorf("network connection refused"), } clientNetworkError := NewUserClientWithInterface("http://api.example.com", mockClientNetworkError) _, err = clientNetworkError.GetUser("123") if err == nil { t.Fatalf("Expected a network error, got nil") } expectedErrMsgNetwork := "failed to make request: network connection refused" if err.Error() != expectedErrMsgNetwork { t.Errorf("Expected error '%s', got '%s'", expectedErrMsgNetwork, err.Error()) } }
利点:
- 高速実行: テストは完全にインメモリで実行されるため、非常に高速であり、継続的インテグレーションに適しています。
- 隔離: テスト対象のコードは外部要因から完全に隔離されており、テストの失敗はユニット内の問題を示すことが保証されます。
- 正確な制御: モックが返す応答とエラーを細かく制御できるため、エッジケースを簡単にテストできます。
欠点:
- 低い忠実度: 実際のHTTPトランスポートレイヤーをテストしません。シリアライゼーション/デシリアライゼーションやヘッダー処理に関する潜在的な問題が見逃される可能性があります。
- ボイラープレート: インターフェースを定義し、モック実装を作成する必要があり、特に外部モックライブラリを使用しない場合、ボイラープレートコードが増加する可能性があります。
- 不正確なモックのリスク: モックが実際のサービスの動作を正確に模倣しない場合、実際の統合が失敗してもテストはパスする可能性があります。
正しい選択をする
httptest.NewServerとモックサービスインターフェースのどちらを選択するかは、主に実行しているテストの種類と検証したい特定の側面によって異なります。
- 単体テストにはモックサービスインターフェースを使用する - コンポーネントの内部ロジックを隔離してテストし、さまざまな応答(成功、異なるエラーコード)とネットワーク障害を正しく処理することを確認したい場合。これは、HTTPClientの結果にコードがどのように反応するかということです。これらのテストは高速で、焦点が絞られているべきです。
- HTTPサービスとのインターフェースを確保したい場合は、統合テストに
httptest.NewServerを使用する - HTTPプロトコルのニュアンスを含む、HTTPサービスとのインターフェースを正しく確保したい場合。これは、HTTP経由でのエンドツーエンドの通信とデータ交換を検証することです。これらのテストは遅いですが、実際のシナリオでより高い信頼性を提供します。
堅牢なテスト戦略は、両方のアプローチをしばしば含みます。コアビジネスロジックとクライアントサイドのHTTPインタラクションロジックには、モックインターフェースを使用した包括的な単体テストから始めます。次に、httptest.NewServerを使用した統合テストで、実際のHTTP通信パスを検証し、クライアントがライブ(ただしローカル)サーバーからの応答を正しく解釈することを確認します。
結論として、httptest.NewServerは、現実世界のシナリオで信頼性を提供する、HTTPインタラクションのための高忠実度統合テストを提供しますが、速度を犠牲にします。一方、モックサービスインターフェースは、内部ロジックとエラー処理の検証に最適な、非常に高速で隔離された単体テストを可能にします。最適な戦略は、これらの2つの強力なテクニックを調和させ、コンポーネントのモジュールごとの正確性と、より大きなシステムへのシームレスな統合の両方を保証します。

