はじめに
Go言語で外部サービスと連携するアプリケーションを開発する際、net/http
パッケージを使用してHTTPリクエストを行うと思いますが、HTTPリクエストを含むコードのテストは外部サービスの可用性や応答速度に依存するため、不安定で遅くなりがちです。
安定かつ高速なテストを実現するためには、実際に外部サービスに接続する代わりに、モックやテスト用のスタブサーバーを利用する手法が有効です。この記事ではそれらの手法について、自分だったらこのようにやるなーという方法を2つ紹介したいと思います。
なぜHTTPリクエストのテストは難しいのか?
コードが外部のHTTPエンドポイントに依存している場合、以下のような問題が発生します。
-
不安定さ(Flakiness): ネットワークの問題、外部サービスの停止、データ変動などにより、テストが時々失敗する可能性があります
-
実行速度: ネットワーク遅延や外部サービスの処理時間により、テストスイート全体の実行が遅くなります
-
依存関係: テストを実行するために特定の外部サービスが稼働している必要があります。これはCI/CD環境などで問題となることがあります
-
シナリオの再現: 特定のエラーレスポンス(例: 404, 500)や、様々なレスポンスボディを返すシナリオを簡単に再現するのが難しい場合があります
これらの問題を解決し、独立して実行できる信頼性の高いテストを作成するために、以降で紹介する手法が役立ちます。
テスト対象となるコード
ログインを実行するAPIを例として用意しました。
package myservice
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
// HTTPClient は http.Client の Do メソッドをラップするインタフェース
type Doer interface {
Do(req *http.Request) (*http.Response, error)
}
// MyService は外部APIと通信するサービス
type MyService struct {
client Doer // インタフェース型でクライアントを受け取る
url string
}
// リクエスト
type LoginRequest struct {
ID string `json:"id"`
Password string `json:"password"`
}
// レスポンス
type LoginResponse struct {
Message string `json:"message"`
}
func (s *MyService) Login(params LoginRequest) (LoginResponse, error) {
reqBody, err := json.Marshal(¶ms)
if err != nil {
return LoginResponse{}, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, s.url+"/api/login", bytes.NewBuffer(reqBody))
if err != nil {
return LoginResponse{}, fmt.Errorf("failed to create request: %w", err)
}
// インタフェース経由でHTTPリクエストを実行
resp, err := s.client.Do(req)
if err != nil {
return LoginResponse{}, fmt.Errorf("failed to perform request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// エラーハンドリング
bodyBytes, _ := io.ReadAll(resp.Body)
return LoginResponse{}, fmt.Errorf("API returned status code %d: %s", resp.StatusCode, string(bodyBytes))
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return LoginResponse{}, fmt.Errorf("failed to read response body: %w", err)
}
var loginResponse LoginResponse
if err := json.Unmarshal(bodyBytes, &loginResponse); err != nil {
return LoginResponse{}, fmt.Errorf("failed to unmarshal response body: %w", err)
}
return loginResponse, nil
}
手法1: http.Do() をinterface化してモックする
この方法は、リクエストを実行するhttp.Do()をモックすることでネットワーク通信を発生させずにテストを実行する方法です。
例のコードではすでにDoer
というインターフェースを用意し、MyService.client
がそのインターフェースを受け取るようにしているためこの手法を適用しやすい形にしています。
考え方
テストコード内でDoerインタフェースを満たすモッククライアントを作成し、そのDoメソッドがテストに必要なダミーの*http.Responseとerrorを返すように実装します。
そのモッククライアントを使用してMyService
を初期化することでテストコード上ではダミーの値が返るようになります。
テストコード例
package myservice
import (
"bytes"
"errors"
"io"
"net/http"
"reflect"
"testing"
"github.com/stretchr/testify/mock"
)
func TestMyServiceByMockInterface_Login(t *testing.T) {
type mockReturn struct {
res *http.Response
err error
}
tests := map[string]struct {
args LoginRequest
mockReturn
want LoginResponse
wantErr bool
}{
"success": {
args: LoginRequest{
ID: "1234",
Password: "password",
},
mockReturn: mockReturn{
res: &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"message": "OK"}`)),
},
err: nil,
},
want: LoginResponse{
Message: "OK",
},
wantErr: false,
},
"401": {
args: LoginRequest{
ID: "9999",
Password: "password!!",
},
mockReturn: mockReturn{
res: &http.Response{
StatusCode: 401,
Body: io.NopCloser(bytes.NewBufferString(`{"message": "Unauthorized"}`)),
},
err: errors.New("Unauthorized Error!"),
},
wantErr: true,
},
}
for testName, tt := range tests {
t.Run(testName, func(t *testing.T) {
m := new(clientMock)
// `Do()`が実行された時に、テストケース毎に任意の値が返るように設定する
m.On("Do", mock.Anything).Return(tt.mockReturn.res, tt.mockReturn.err)
s := &MyService{
client: m,
url: "http://example",
}
got, err := s.Login(tt.args)
if (err != nil) != tt.wantErr {
t.Errorf("MyService.Login() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("MyService.Login() = %v, want %v", got, tt.want)
}
})
}
}
// `testify/mock`を使用したモック構造体
type clientMock struct {
mock.Mock
}
// `Do()`を実装する
func (_m *clientMock) Do(req *http.Request) (*http.Response, error) {
args := _m.Called(req)
res := args.Get(0).(*http.Response)
return res, args.Error(1)
}
モックの作成にtestify/mock
を使用していますが、ライブラリを使用しない方法もありますし、他のライブラリを使用しても問題ありません。
メリット・デメリット
メリット
- ネットワーク通信が一切発生しないため、テストの実行が高速
- 外部依存が最低限
- シンプルなケースであれば、モックの実装も容易
デメリット
- モック実装を手で書く必要があり、扱うAPIのエンドポイントや応答パターンが多いほど実装が複雑になりがち
- HTTPプロトコルレベルの挙動(ヘッダー、ステータスコードの詳細、リダイレクトなど)を完全に再現するのが難しく、実際のネットワーク状況とは乖離する可能性がある
- あくまで「クライアントコードが特定のレスポンスを受け取った場合にどう振る舞うか」をテストする単体テストの手法であり、サービス間の実際の連携を検証する結合テストには向かない
手法2: TestcontainersのWireMockモジュールを使用したテスト
WireMockはHTTPベースのAPIの挙動をシミュレート(モック/スタブ化)するためのライブラリです。
また、Testcontainersは、Dockerコンテナをテスト実行中のみ起動・管理するためのライブラリです。
TestcontainersのWireMockモジュールを使用することで、テストコードから簡単にWireMockコンテナを起動し、設定し、テスト終了時にクリーンアップすることができます。
考え方
Goテストコード内でtestcontainers-goライブラリのWireMockモジュールを使用し、WireMockコンテナを起動します。この際、testdataディレクトリなどに配置したスタブ定義JSONファイルをコンテナにマウントします。
そうすることで、JSONファイルに記載した任意のレスポンスを設定できることができるようになります。
テストコード例
package myservice
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"path/filepath"
"reflect"
"testing"
"github.com/stretchr/testify/mock"
wiremock "github.com/wiremock/wiremock-testcontainers-go"
)
func TestMyServiceByWireMockContainer_Login(t *testing.T) {
ctx := context.Background()
// testcontainersのwiremockモジュールを起動する
// testdata/~にあるスタブ定義したJSONファイルをマッピングする
container, err := wiremock.RunContainerAndStopOnCleanup(
ctx,
t,
wiremock.WithMappingFile("success", filepath.Join("testdata/login_success.json")),
wiremock.WithMappingFile("401", filepath.Join("testdata/login_401.json")),
)
if err != nil {
t.Fatal(err)
}
endpoint, err := wiremock.GetURI(ctx, container)
if err != nil {
t.Fatal(err)
}
type fields struct {
client Doer
url string
}
tests := map[string]struct {
fields fields
args LoginRequest
want LoginResponse
wantErr bool
}{
"success": {
fields: fields{
client: http.DefaultClient,
url: endpoint,
},
args: LoginRequest{
ID: "1234",
Password: "password",
},
want: LoginResponse{
Message: "OK",
},
wantErr: false,
},
"401": {
fields: fields{
client: http.DefaultClient,
url: endpoint,
},
args: LoginRequest{
ID: "9999",
Password: "password!!",
},
wantErr: true,
},
}
for testName, tt := range tests {
t.Run(testName, func(t *testing.T) {
s := &MyService{
client: tt.fields.client,
url: tt.fields.url,
}
got, err := s.Login(tt.args)
if (err != nil) != tt.wantErr {
t.Errorf("MyService.Login() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("MyService.Login() = %v, want %v", got, tt.want)
}
})
}
}
成功レスポンスのスタブ定義
{
"request": {
"bodyPatterns": [
{
"equalToJson": "{\"id\":\"1234\",\"password\":\"password\"}",
"ignoreArrayOrder": true,
"ignoreExtraElements": true
}
],
"method": "POST",
"urlPath": "/api/login"
},
"response": {
"body": "{\"message\": \"OK\"}",
"headers": {
"Content-Type": "application/json"
},
"status": 200
}
}
失敗レスポンスのスタブ定義
{
"request": {
"method": "POST",
"urlPath": "/api/login"
},
"response": {
"body": "{\"message\": \"Unauthorized\"}",
"headers": {
"Content-Type": "application/json"
},
"status": 401
}
}
メリット・デメリット
メリット
- テスト実行ごとにクリーンな環境でWireMockが起動するため、テスト間の干渉がなく、常に同じ状態でテストを実行可能
- WireMockコンテナの起動、ポート取得、終了といったライフサイクル管理をテストコード内で完結
- 実際のHTTPサーバーの挙動に近い環境でテスト可能
デメリット
- テストを実行する環境にDocker(または互換性のあるコンテナランタイム)がインストールされている必要がある
- コンテナイメージのダウンロード(初回のみ)やコンテナの起動・消去に時間がかかるため、テストの実行時間がかかる
- ライブラリの追加が必要なので外部依存の箇所が増える
まとめ
上記で紹介した手法以外にも、
-
httptest.NewServer()
を使用してテスト用サーバーの使用 - WireMockをtestcontainersを使用せずにdocker composeなどで起動して使用
- WireMock以外のモックサーバーライブラリの使用
などが考えられます。
httptest
は標準ライブラリで完結できるためまずはこちらを検討するのがいいかなと思います。
逆に、ローカル環境での開発などでモックサーバーのライブラリを導入している場合はそちらを使用したテストコードを検討するのがいいかもしれません。
いずれにしても、テストシナリオが増えた場合にコードの管理が複雑になったり、コンテナを利用することで実行時間がボトルネックになったりと完璧な選択肢はないので色々な手法を知っておき、その時々でいい方法を選択できるようにしておきたいです。