goでテスト用にhttpの通信を失敗させたかったのでその方法のメモ

たまにエラー時のハンドリング部分のテストが書きたくなることがありますね。例えば、通信の記録のような操作を書いているときなどに。 こういうときに人為的にエラーを発生させる方法を調べたのでメモをしておこうと思います。

タイムアウト分待つ

まずはじめに考えることはタイムアウトかもしれません。これは *http.Clientタイムアウトを指定できるのでそれ以上の時間を待ってあげれば良いと言うようなコードです。

=== RUN   TestTimeout
    x_test.go:14: for test -- timeout forcely 200ms
--- PASS: TestTimeout (0.11s)

コードは以下です。タイムアウト用のエラーが正しく返ってきたかのテストがめんどくさいですね。実はこれを書いているときにerrors.Asインターフェイスも取れたことに気づきました。考えてみればそうですね。

package expecterror

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

func TestTimeout(t *testing.T) {
    timeout := 100 * time.Millisecond
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        t.Logf("for test -- timeout forcely %v", 2*timeout)
        time.Sleep(timeout)
    }))
    defer ts.Close()

    client := &http.Client{Timeout: timeout}
    _, err := client.Get(ts.URL)
    if err == nil {
        t.Errorf("error is expected but nil")
    }

    // context.DeadlineExceeded あたりになってくれれば嬉しいのだけれど。。
    // &url.Error{Op:"Get", URL:"http://127.0.0.1:59233", Err:(*http.httpError)(0xc0001b2040)}
    var x interface{ Timeout() bool }
    if ok := errors.As(err, &x); !(ok && x.Timeout()) {
        t.Errorf("timeout is expected but return error is %[1]T, %#+[1]v", err)
    }
}

とはいえ、特定の時間分だけ待つと言うテストは微妙な感じですね。可能ならテスト中のtime.Sleep()は禁止にしたいところです。

無理やりコネクションを切断する

次は、貼ったコネクションを無理やり切断する方法です。これにはhttp.Hijackerというインターフェイスを使います。もしかしたら、存在を知らない人は結構いるかも知れません。

=== RUN   TestCloseConnection
    x_test.go:17: for test -- close connection
--- PASS: TestCloseConnection (0.00s)

このようなコードになります。コネクションをぶった切っている感がわかりやすいコードですね。

package expecterror

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

func TestCloseConnection(t *testing.T) {
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        c, _, err := w.(http.Hijacker).Hijack()
        if err != nil {
            t.Fatal(err)
        }
        t.Log("for test -- close connection")
        c.Close()
    }))
    defer ts.Close()

    _, err := http.Get(ts.URL)
    if err == nil {
        t.Errorf("error is expected but nil")
    }
    if !errors.Is(err, io.EOF) {
        t.Errorf("EOF is expected but return error is %[1]T, %+[1]v", err)
    }
}

⚠ httptestのRecorderの方は使えない

webAPIのテストをする際に利用するもう一つの方法としてRecorderを使う方法がありますが、こちらは使えません。こちらはユニットテスト用のヘルパーなのでハンドラー関数をそのまま呼ぶだけですし、通信はしていないので当たり前といえば当たり前ですね。まぁ一応。メモなので。

// WARNING: これは動かない
func TestErrorWithRecorder(t *testing.T) {
    rec := httptest.NewRecorder()
    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        c, _, err := w.(http.Hijacker).Hijack()
        if err != nil {
            t.Fatal(err)
        }
        t.Log("for test -- close connection")
        c.Close()
    })

    handler(rec, httptest.NewRequest("GET", "http://example.net", nil))
}

panicします。

panic: interface conversion: *httptest.ResponseRecorder is not http.Hijacker: missing method Hijack [recovered]
        panic: interface conversion: *httptest.ResponseRecorder is not http.Hijacker: missing method Hijack

常に失敗を返すtransportを挟む

今までエラーを発生させてきたのは通信先のリモート側(httptest.Serverで動いているハンドラー側)でしたが、これだけだとサーバー側の処理ではなくクライアント側の処理を書いているときに少し困ってしまうかもしれませんね。そのような場合にはClientがTransportというフィールドにhttp.RoundTripperが取れるのでこれが使えそうです。

これはgoでhttp(https)の通信が行われてるときに、実際の通信の処理自体はDefaultTransportというstructで行われているのですが、その部分を差し替えるということです。ちなみにtransportをラップするtransportを書いてあげることでいわゆるミドルウェアのようなものを作ることは結構それなりに知られた常套手段だったりします。

=== RUN   TestErrorTransport
    x_test.go:19: error forcely *errors.errorString
--- PASS: TestErrorTransport (0.00s)

コードは以下です。インターフェイスの実装になるのでstructとメソッドの2つが必要になるのは少し面倒ですね。

package expecterror

import (
    "errors"
    "fmt"
    "net/http"
    "testing"
    "time"
)

type errorTransport struct {
    T      *testing.T
    genErr func() error
}

func (t *errorTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    err := t.genErr()
    t.T.Logf("error forcely %T", err)
    return nil, err
}

func TestErrorTransport(t *testing.T) {
    this := fmt.Errorf("*THIS*")
    transport := &errorTransport{T: t, genErr: func() error { return this }}

    client := &http.Client{Timeout: 100 * time.Millisecond, Transport: transport}
    _, err := client.Get("http://example.net.dummy") // テキトーなURL
    if err == nil {
        t.Errorf("error is expected but nil")
    }
    if !errors.Is(err, this) {
        t.Errorf("%#[2]v is expected but return error is %[1]T, %+[1]v", err, this)
    }

}

おわりに

通信を失敗させる部分の処理が時々欲しくなるのですが、それが時々だからこそ調べる必要がでてきてめんどくさく感じたりします。そんなわけで調べたついでにメモしてみたのでした。 ふとその場で思いつくような実装はこれくらいでしたが、その他手軽な良い方法があれば知りたいところです。

追記

これを試したときのgoのversionは以下です。

$ go version
go version go1.15.1 darwin/amd64

gist