あぼぼーぼ・ぼーぼぼ

のんびり生きたい

httptest.ResponseRecorderを使ったテストでHTTPステータスコードが意図した値にならないとき

httptest.ResponseRecorderを使ってHTTPサーバーのテストをしている際、レスポンスのHTTPステータスコードが意図した値にならないなぁと思って色々調べたのでそのメモです。

例えば以下のようなコードで再現することができます。

type MyHandler struct{}

func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("sample"))
    w.WriteHeader(http.StatusTeapot)
}

func main() {
    log.Fatalln(http.ListenAndServe(":8080", &MyHandler{}))
}

このプログラムを実行してリクエストを送るとsampleという文字列がHTTPステータス200で返ってきます。

sample % curl -i localhost:8080
HTTP/1.1 200 OK
~省略~

sample

テストコードを簡単に書くとこのようになりますが、こちらも同様です。

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func Test(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/", nil)
    res := httptest.NewRecorder()

    h := &MyHandler{}
    h.ServeHTTP(res, req)

    if want, got := http.StatusTeapot, res.Code; want != got {
        t.Errorf("status code should be %d, but got %d", want, got)
    }
}
=== RUN   Test
    main_test.go:18: status code should be 418, but got 200
--- FAIL: Test (0.00s)

FAIL

結論

  • 200以外のHTTPステータスコードを設定したいならWriteメソッドの前にWriteHeaderメソッドを呼び出す必要がある

この辺りは、net/http.ResponseWriterインタフェースのドキュメントに書いてありますね。 pkg.go.dev

// WriteHeader sends an HTTP response header with the provided
// status code.
//
// If WriteHeader is not called explicitly, the first call to Write
// will trigger an implicit WriteHeader(http.StatusOK).
// Thus explicit calls to WriteHeader are mainly used to
// send error codes.
//
// The provided code must be a valid HTTP 1xx-5xx status code.
// Only one header may be written. Go does not currently
// support sending user-defined 1xx informational headers,
// with the exception of 100-continue response header that the
// Server sends automatically when the Request.Body is read.
WriteHeader(statusCode int)

以下、結論を補足する形で実際にhttptest.ResponseRecorderの実装を読んでいきます。

httptest.ResponseRecorderの実装を読んでいく

HTTPサーバーのテストで利用するhttptest.ResponseRecorderの実装を追います。今回は、動機となった「レスポンスのHTTPステータスコードが意図した値にならない」の理由を知ることを目的に読んでいきます。

httptest.ResponseRecorderはhttp.ResponseWriterインタフェースを実装した型なので、Header() Header Write([]byte) (int, error) WriteHeader(statusCode int)の3つのメソッドの周辺を調査します。

まず直接HTTPステータスコードを設定できるWriteHeader()メソッドの実装を眺めます。

func (rw *ResponseRecorder) WriteHeader(code int) {
    if rw.wroteHeader {
        return
    }

    checkWriteHeaderCode(code)
    rw.Code = code
    rw.wroteHeader = true
    if rw.HeaderMap == nil {
        rw.HeaderMap = make(http.Header)
    }
    rw.snapHeader = rw.HeaderMap.Clone()
}

早速レシーバーのwroteHeaderフィールドが真ならリターンしていて怪しいです。wroteHeaderフィールドにはGoDocがありませんでしたが、名前の通りですね。では、いつどこでwroteHeaderフィールドが書き換えられているのか。

wroteHeaderで検索をかけると、参照している箇所がいくつか、値を代入している箇所が1箇所見つかります。値を代入している1箇所がWriteHeaderメソッドの中ですね。

よって同一のResponseRecorderインスタンスでは、WriteHeaderメソッドによるHTTPステータスコードの設定は一度のみできることが分かります。

次にWriteメソッドがどういう実装になっているかを見ます。

func (rw *ResponseRecorder) Write(buf []byte) (int, error) {
    rw.writeHeader(buf, "")
    if rw.Body != nil {
        rw.Body.Write(buf)
    }
    return len(buf), nil
}

1行目でwriteHeaderという非公開メソッドを呼んでいるので、そいつを見にいきます。

func (rw *ResponseRecorder) writeHeader(b []byte, str string) {
    if rw.wroteHeader {
        return
    }
    if len(str) > 512 {
        str = str[:512]
    }

    m := rw.Header()

    _, hasType := m["Content-Type"]
    hasTE := m.Get("Transfer-Encoding") != ""
    if !hasType && !hasTE {
        if b == nil {
            b = []byte(str)
        }
        m.Set("Content-Type", http.DetectContentType(b))
    }

    rw.WriteHeader(200)
}

今回の目的に沿って読むならば、気になる箇所は2箇所あります。まず1行目では、公開メソッドのWriteHeaderと同様にwroteHeaderフィールドがtrueなら何もせずreturnしてますね。そしてメソッドの最後にWriteHeader(200)メソッドを読んでいます。前述した通りWriteHeaderメソッドは内部でwroteHeaderフィールドをtrueにします。よってWriteメソッドは(Bodyへの書き込み前に)HTTPステータスコードに200を設定することがわかります。

ここまで読むと、最初に載せたサンプルコードでなぜHTTPステータスコードが418にならないのか、なぜテストが失敗するのかが理解できます。

func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("sample")) // この時点で200が設定され
    w.WriteHeader(http.StatusTeapot) // 上書きはできない
}

それから、「同一インスタンスで一度のみ」ということは、例えばTable Driven Testなどでhttptest.ResponseRecorderのインスタンスを使い回しているとHTTPステータスコードが以前の値に固定されてしまうので、サブテスト内で初期化する必要があるということですね。

ドキュメントや実装を読むことで理解を深めることができました。

参考