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