httptest.ResponseRecorderを使っていてもRoundTripperを使いたい

時折、テスト中にRoundTripper (client.Transport) を挟んで良い結果を得ようと企む事がある。コレに対応可能なのがhttptest.Serverを利用した通信のときだけだと思っていたけれど。一手間加えるとRecorderを利用したテストでも対応可能な事に気づいたのでメモ。

Recorder, Server?

httptestを使ったhttp requestのテストを書くときには2つの方針がある

  • Recorderを使ったもの
  • Serverを使ったもの

前者は基本的にはnet/http.HandlerFuncなどを直接関数として利用するもの。後者は適当なポートでローカルに本物のサーバーを立てて通信を行うもの。前者はユニットテスト、後者は結合テストで使われる事が多い。

RoundTripperの利用

例えば以下の様な何の変哲もないHandlerがあるとする。hello worldを返すだけのもの。

func Handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "hello world"+r.URL.Query().Get("suffix"))
}

このHandlerへのrequestに触りたい。とりあえず変更されることがわかれば良いのでquery stringの"suffix"を追加するようなRoundTripperを書いてみる。

// このRoundTripperを利用したい
type AddSuffixQuery struct {
    Transport http.RoundTripper
}

func (t *AddSuffixQuery) RoundTrip(req *http.Request) (*http.Response, error) {
    // <url> -> <url>?suffix=!!
    q := req.URL.Query()
    q.Add("suffix", "!!")
    req.URL.RawQuery = q.Encode()

    tranport := t.Transport
    if tranport == nil {
        tranport = http.DefaultTransport
    }
    return tranport.RoundTrip(req)
}

コレを使ったServerのテストはそのまますんなり書き下せる。hello worldではなくhello world!!が返るかどうかを確認すれば良い。

// TestUseTestServer テストサーバーを使う場合は本物の通信を行う。Transportを見てくれる。
func TestUseTestServer(t *testing.T) {
    ts := httptest.NewServer(http.HandlerFunc(Handler))
    defer ts.Close()

    client := &http.Client{Transport: &AddSuffixQuery{}}
    res, err := client.Get(ts.URL)
    if err != nil {
        t.Fatalf("res: %+v", err)
    }

    if want, got := http.StatusOK, res.StatusCode; want != got {
        t.Errorf("status code\nwant\n\t%d\nbut\n\t%d", want, got)
    }

    got, _ := ioutil.ReadAll(res.Body)
    if want := "hello world!!"; want != string(got) {
        t.Errorf("body\nwant\n\t%+v\nbut\n\t%+v", want, string(got))
    }
}

レスポンスのstatusとbodyを確認しているだけのコード。OK。

PASS
ok      m/example_go5/02record  0.010s

Recorderの利用

通常Recorderを利用したコードは以下の様な形になる。このコードは先程定義したRoundTripperを利用していない。 これを利用する形に書き換えたい。

// TestUseRecord 通常のhttptest.Recorderを使う例
func TestUseRecord(t *testing.T) {
    rec := httptest.NewRecorder()
    req := httptest.NewRequest("", "http:", nil)
    Handler(rec, req)

    res := rec.Result()
    if want, got := http.StatusOK, res.StatusCode; want != got {
        t.Errorf("status code\nwant\n\t%d\nbut\n\t%d", want, got)
    }

    got, _ := ioutil.ReadAll(res.Body)
    if want := "hello world"; want != string(got) {
        t.Errorf("body\nwant\n\t%+v\nbut\n\t%+v", want, string(got))
    }
}

呼び出すだけなのでコレでおしまい。関数を直接呼び出すのでrequestのContextに詰め込んだ値がそのままHandlerで使えたりと便利なことがあったりする。今回も利用したかったRoundTripperの効果を得るだけ(query stringの追加だけ)なら直接渡すrequestに変更を加えれば良いだけなので、不要かもしれないが、この記事の本題としてはRoundTripperの利用を強制したいということだった。

実はRoundTripperをネストした構造にすることは概ねmiddlewareのように機能する。そして最も下層の処理はnet/http.Transportの実装ということになる。これをHandlerを呼ぶものに差し替えてあげれば良い。そんなわけで以下のようなRoundTripperのHandlerTripperを定義してあげて使ってあげれば良い1

type HandlerTripper struct {
    Rec     *httptest.ResponseRecorder
    Handler http.HandlerFunc
}

func (t *HandlerTripper) RoundTrip(req *http.Request) (*http.Response, error) {
    t.Handler(t.Rec, req)
    return t.Rec.Result(), nil
}

// TestUseRecordWithTransport そういうTransportを書いてあげれば済む話だ
func TestUseRecordWithTransport(t *testing.T) {
    rec := httptest.NewRecorder()
    req := httptest.NewRequest("", "http:", nil)

    transport := &AddSuffixQuery{
        Transport: &HandlerTripper{Rec: rec, Handler: Handler},
    }
    res, err := transport.RoundTrip(req)
    if err != nil {
        t.Fatalf("res: %+v", err)
    }

    if want, got := http.StatusOK, res.StatusCode; want != got {
        t.Errorf("status code\nwant\n\t%d\nbut\n\t%d", want, got)
    }

    got, _ := ioutil.ReadAll(res.Body)
    if want := "hello world!!"; want != string(got) {
        t.Errorf("body\nwant\n\t%+v\nbut\n\t%+v", want, string(got))
    }
}

テストも通る。

$ go test
PASS
ok      m/example_go5/02record  0.013s

RoundTripperを使えると何が嬉しいの?

直近での用途を考えてみると、例えばweb APIの呼び出しのテストに失敗したときだけ、あるいはgo test -vで実行したときだけ、request/responseのtraceを表示するRoundTripperを追加することができたりする2

request/response trace

まとめ

  • RecorderでもServerでもRoundTripperを使える
  • こういう一手間のためのライブラリが欲しいかも

gist


  1. 直接Recorderを渡す必要もなくて、下層のRoundTripperの中で作って使う形にしても良い。実は以前の記事でその様な実装を書いていた。https://pod.hatenablog.com/entry/2020/08/28/212325

  2. こういう失敗したときだけの出力を作るときにt.Logf()を使って書き出すのが便利。