Goで net/http
を使ったControllerのテストには、 net/http/httptest
モジュールを使うと擬似的にサーバをその場で立て、登録したハンドラにリクエストを送れます。
ただ、APIに使うアクションはたびたび、外部のAPIを叩くこともあります。
その際、テストを実行するときに外部APIを使えない状況(本番環境とは違う状況、例えば、production用のAPIの発行には金銭が発生するなど)に遭遇します。
Test Double
そんなときに知られているテクニックとして、テスト時に外部APIへのリクエストをDouble(Stub)することです。
その外部APIが返却するであろう期待値を登録しておけば正常ケースを、エラー値を登録しておけばエラーケースをテストできます。
ここで例に挙げるStubする方法は、 http.Client
をもとにHTTPリクエストすることを前提としています。
ではどうやってリクエストをStubすれば良いでしょうか?
net/http
でのhttp clientでは、 RoundTripper
というインタフェースを軸にHTTPコネクションを制御しています。
https://github.com/golang/go/blob/master/src/net/http/client.go#L41
そこで、このRoundTripperをテスト時にすげ替えてあげれば良いわけです。
RoundTripperは、 *http.Request
を受け取り *http.Response
を返す、RoundTrip関数を充足すれば良いことがインタフェースの定義からわかります。
https://github.com/golang/go/blob/master/src/net/http/client.go#L99
次のコードは http://example.com/api/test
へのリクエストをStubするコードです。
レスポンスのbodyがJSONで、ステータスが200であることを想定しています。
type MyTransport struct {}
func (t *MyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.String() == "http://example.com/api/test" {
resp := &http.Response{
Header: make(http.Header),
Body: ioutil.NopCloser(strings.NewReader(`{"api":"test"}`)),
StatusCode: http.StatusOK,
}
resp.Header.Set("Content-Type", "application/json")
return resp, nil
} else {
return http.DefaultTransport.RoundTrip(req)
}
}
func TestApi(t *testing.T) {
http.DefaultClient.Transport = &MyTransport{}
defer func() {
http.DefaultClient.Transport = nil
}()
ts := httptest.NewServer(http.HandlerFunc(api))
defer ts.Close()
res, err := http.Get(ts.URL)
if err != nil {
t.Error("unexpected", err)
}
defer res.Body.Close()
//...
}
ここで重要なのは、テスト対象のURLのみをstubしていることです。
それ以外のURLへのリクエストは、 デフォルトのRoundTrip関数が定義されている DefaultTransport
を使います。
その理由として、テスト時に外部APIへのリクエストだけではなく、ローカルのテストサーバへのリクエストもstubされてしまうからです。
また、DefaultClientのTransportもテスト終了後nilで初期化しておかないと後続のテスト時にStubの設定が残ったままになることになるので注意するところです。
ライブラリ化
毎回テスト時にこういったコードを書くのは煩雑になるので、簡単にラップしたものを作りました。
使い方は簡単です。先ほどのテストコードが次のようになります。
func TestApi(t *testing.T) {
double := gg.Double(map[string]*gg.ResponseHandler{
"http://example.com/api/test": &gg.ResponseHandler{
HandleFunc: apiResponseHandler,
Status: http.StatusOK,
ContentType: "application/json",
},
})
defer double.Close()
ts := httptest.NewServer(http.HandlerFunc(api))
defer ts.Close()
res, err := http.Get(ts.URL)
if err != nil {
t.Error("unexpected", err)
}
defer res.Body.Close()
// ...
}
URLに対して、処理させたいHandlerとHTTP Status、Content-Typeを登録しておくシンプルな作りです。
複数のURLも登録できるのでわりと汎用的に使えるインタフェースになっているかなと思います。
まとめ
今回は、HTTPリクエストにおけるTest Doubleの一手法を紹介しました。
なんでも適用すると思わぬところでテストが機能しなくなることがありますが、有用な手段な場合もあると知っておくとユニットテストが書きやすくなります。