GoによるHTTP呼び出しのユニットテスト

はじめに

外部のWebサービスから、HTTP(HTTPS)で情報を取得し、その結果を利用するというのはよく行われます。 Goの標準パッケージにはHTTPクライアントが含まれているため、その処理自体を書くのは簡単です。 しかしこれのテストを行うとなると少し難しくなります。 この記事ではHacker News API(https://github.com/HackerNews/API)を例として、どのようにHTTP呼び出しのテストを書くのか紹介します。

HTTP呼び出しの例

Hacker News APIのうち、トップストーリーのID一覧を取得するAPIを呼び出す関数を書いてみます。 このトップストーリーAPIは、整数で表現された、ストーリーのidを配列で返します。 具体的には以下のようになります(標準パッケージのみを使用しているためimport省略)。

package main

var HNApiBaseURL = "https://hacker-news.firebaseio.com/v0"

func GetHNTopStories() ([]int, error) {
    res, err := http.Get(HNApiBaseURL + "/topstories.json")
    if err != nil {
        return nil, err
    }
    defer res.Body.Close()
    ids := []int{}
    if err := json.NewDecoder(res.Body).Decode(&ids); err != nil {
        return nil, err
    }
    return ids, nil
}

func main() {
    ids, _ := GetHNTopStories()
    fmt.Print(ids)
}

単純なGETのAPIでもあり、特に難しいところはありません。ポイントとしてはHacker News APIのURLのうち、ドメイン部分など共通の部分をvarで定義しているところです。

テストコード

問題の確認

上記のコードをテストする上で問題となるのは、http.DefaultClient.Do(req) で実際にHTTPリクエストするところです。 何も考えずに、GetHNTopStoriesのテストを書いてしまうと、実際にHacker NewsのAPIを実際に叩くことになります。 これは相手に迷惑ですし、外に接続できない環境ではテスト出来なくなります。

解決策の検討

私たちはトップストーリーのAPIがintの配列を返すことを知っています。つまりこのAPIをエミュレートするMockとしてのhttp.Handlerを書いて、それをローカルで動かし、そこに対してリクエストを送るようにしてやれば解決しそうです。

Mockの作成

まずはこのAPIをエミュレートするMockHandlerを書いてみます。

type mockHNStoriesAPI struct {
    Result []int
    Err    error
}

func (mock *mockHNStoriesAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if mock.Err != nil {
        panic(mock.Err)
    }
    json.NewEncoder(w).Encode(mock.Result)
}

mockHNStoriesAPIは2つのフィールドを持ちます。 Resultは正常系の場合にこのHandlerがレスポンスに設定するためのintスライスです。トップストーリーAPIが返すidの配列を設定しておくためのものです。 Errはエラー状態を作り出すためのものです。ここで言うエラーは500 Internal Server Errorなどではなく、そもそもサーバーにつながらないなどの状態を示します。 そして、mockHNStoriesAPIをHandlerとして機能させるためServeHTTPを実装します。APIのドキュメントから、細かい仕様は読み取れないため、レスポンスがある場合は200 OKであるという想定です。

テストサーバーの作成

Mockが出来上がったので次はテストサーバーを作成します。 Goの標準パッケージにはnet/http/httptestというものがあり、このパッケージはテスト用のサーバーを作成するためのhttptest.NewServer関数を提供しています。この関数はHandlerを引数として、テスト用サーバー(*httptest.Server)を生成します。 すでにmockHNStoriesAPIはHandlerなので、自身を引数としてテストサーバーを返すメソッドを作るだけです。

func (mock *mockHNStoriesAPI) newTestServer() *httptest.Server {
    return httptest.NewServer(mock)
}

ユニットテストを書く

あとは mockHNStoriesAPI を使ってユニットテストを書くだけです。 newTestServer()で作ったテストサーバーのURLで、HNApiBaseURLを上書きすれば、GetHNTopStoriesはテストサーバーに接続するようになります。

func Test_GetHNTopStories(t *testing.T) {
    mock := &mockHNStoriesAPI{
        Result: []int{1, 2, 3},
    }
    ts := mock.newTestServer()
    HNApiBaseURL = ts.URL

    ids, err := GetHNTopStories()
    if err != nil {
        t.Error(err)
    }
    if len(ids) != 3 { // 実際にはもう少しマシなassertを書くべきだろう、、、
        t.Error("expect 3")
    }
}

補足

このテストコードのポイントは、通常の実行時とテストの実行時で接続先を切り替えているところです。 サンプルコードではグローバルな変数を書きかえることで実現しましたが、実際のプロダクトでは環境変数や設定ファイルなどで切り替えを行い、実行中に上書きされないようにするべきでしょう。

今回は単純なAPIを対象としましたが、現実にはもっと複雑なAPIを使うことも多いです。そのようなAPIのMockを作っていると、Mockそのものが複雑化していくことがままあります。しかしテストのためのMockが複雑化するのはよいことではありません。場合に応じて1つのAPIに対して複数のMockを作ったり、整理のためにMock用のテストパッケージを作ることも考えるべきでしょう。

応用例

  • POST API、特に送られた値に応じて反応を変化させるAPIのMock
  • 複数のAPIをエミュレートするMock
  • 呼び出された回数と順番を記録するMock