GoでHTTP Clientのテストを書いてみます。
テストしたい項目
- 正常なRequest & Response
- 無効なAuth Tokenを投げたとき
- Responseが5xxのとき
- Response Bodyが予期しない形式のとき
- タイムアウトしたとき
ポイント
-
http.Client
は差し替えられる実装にする -
http.Client.Transport
を差し替えることで、実際にHTTP Requestをすることなく、任意の*http.Response
が返ってくる状況を作ることができる
http.Client.Transport
ってなんぞ?という方はこちらを参照
Go http.RoundTripper 実装ガイド#http.RoundTripper とは
実装
APIクライアントの構造体
http.Client
はデフォルトでhttp.DefaultClient
を使い、オプションによって差し替えられるようにします。
type Api struct {
// Auth token
token string
// http client
httpclient *http.Client
}
// New builds a API client from the provided token and options.
func New(token string, opts ...Option) *Api {
api := &Api{
token: token,
httpclient: http.DefaultClient,
}
for _, opt := range opts {
opt(api)
}
return api
}
オプションの実装
type Option func(*Api)
func OptionHTTPClient(c *http.Client) Option {
return func(api *Api) {
api.httpclient = c
}
}
Getの実装
Responseはシンプルに{"text": "hoge"}
のようなのを想定。
タイムアウトになったらRequestをキャンセルできるように実装しておきます。
type ResponseBody struct {
Text string `json:"text"`
}
func (api *Api) Get(ctx context.Context) (*ResponseBody, error) {
req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil)
if err != nil {
return nil, err
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", api.token))
resp, err := api.request(ctx, req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("bad response status code %d", resp.StatusCode)
}
b, err := ioutil.ReadAll(resp.Body)
defer resp.Body.Close()
if err != nil {
return nil, err
}
var body ResponseBody
if err := json.Unmarshal(b, &body); err != nil {
return nil, err
}
return &body, nil
}
// request .
func (api *Api) request(ctx context.Context, req *http.Request) (*http.Response, error) {
req = req.WithContext(ctx)
respCh := make(chan *http.Response)
errCh := make(chan error)
go func() {
resp, err := api.httpclient.Do(req)
if err != nil {
errCh <- err
return
}
respCh <- resp
}()
select {
case resp := <-respCh:
return resp, nil
case err := <-errCh:
return nil, err
case <-ctx.Done():
return nil, errors.New("HTTP request cancelled")
}
}
テスト
http.RoundTripper
の実装
RoundTrip
の実装と、http.Client.Transport
を差し替えた*http.Client
を返す関数を実装します。
type RoundTripFunc func(req *http.Request) *http.Response
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func NewTestClient(fn RoundTripFunc) *http.Client {
return &http.Client{
Transport: RoundTripFunc(fn),
}
}
上記は使い回せるコードで、以下からがfunc (*Api) Get(context.Context) (*ResponseBody, error)
に対するテスト実装です。
任意の*http.Response
を返す*http.Client
の実装
上記NewTestClient
を使って、Tokenが正しければ{"text": "hello"}
を、間違っていれば401を返すResponseを実装します。
実際には「そういう*http.Response
を返す*http.Client
」のテストヘルパーを作ります。
// Auth Token
const ValidToken = "valid_token"
func client(t *testing.T) *http.Client {
t.Helper()
body := example.ResponseBody{
Text: "hello",
}
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
return NewTestClient(func(req *http.Request) *http.Response {
if req.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", Token) {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Body: nil,
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.NewBuffer(b)),
Header: make(http.Header),
}
})
}
この返り値の*http.Client
を使えば、任意の*http.Response
が返ってくる状況を作ることができたわけですが、これだと正常系しかテストできないので、サーバーの状況を自由に変えられるようにしたいと思います。
// Auth Token
const ValidToken = "valid_token"
// レスポンスにかかる時間と、*http.Responseを引数で取れるようにしました
// *http.Responseがnilであれば、正常な*http.Responseを返します
func client(t *testing.T, respTime time.Duration, resp *http.Response) *http.Client {
t.Helper()
body := example.ResponseBody{
Text: "hello",
}
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
return NewTestClient(func(req *http.Request) *http.Response {
// 追加
time.Sleep(respTime)
// 追加
if resp != nil {
return resp
}
if req.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", ValidToken) {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Body: nil,
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.NewBuffer(b)),
Header: make(http.Header),
}
})
}
実際のテストはこちら
func TestApi_Get(t *testing.T) {
cases := map[string]struct {
token string
client *http.Client
expectHasError bool
expectedErrorMessage string
expectedText string
}{
"normal": { // 正常なRequest & Response
token: ValidToken,
client: client(t, 0, nil),
expectHasError: false,
expectedText: "hello",
},
"invalid token": { // 無効なAuth Tokenを投げたとき
token: "invalid_token",
client: client(t, 0, nil),
expectHasError: true,
expectedErrorMessage: "bad response status code 401",
},
"internal server error response": { // 5xxが返ってくるとき
token: ValidToken,
client: client(t, 0, &http.Response{
StatusCode: http.StatusInternalServerError,
Body: nil,
Header: make(http.Header),
}),
expectHasError: true,
expectedErrorMessage: "bad response status code 500",
},
"plain text response": { // プレーンテキストが返って来る場合はjson.Unmarshalできずに死ぬはず
token: ValidToken,
client: client(t, 0, &http.Response{
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.NewBufferString("bad")),
Header: make(http.Header),
}),
expectHasError: true,
expectedErrorMessage: "invalid character 'b' looking for beginning of value",
},
"long response time": { // レスポンスに3秒かかるのでタイムアウトになるはず
token: ValidToken,
client: client(t, 3*time.Second, nil),
expectHasError: true,
expectedErrorMessage: "HTTP request cancelled",
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
e := example.New(c.token, example.OptionHTTPClient(c.client))
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
resp, err := e.Get(ctx)
// エラーになることを期待していた場合は期待したエラーメッセージかどうか検証する
if c.expectHasError {
if err == nil {
t.Errorf("expected error but no errors ouccured")
return
}
if err.Error() != c.expectedErrorMessage {
t.Errorf("unexpected error message. expected '%s', actual '%s'", c.expectedErrorMessage, err.Error())
}
return
}
if err != nil {
t.Errorf(err.Error())
return
}
if resp.Text != c.expectedText {
t.Errorf("unexpected response's text. expected '%s', actual '%s'", c.expectedText, resp.Text)
}
})
}
}
テストを実行してみる
$ go test -v ./...
=== RUN TestApi_Get
=== RUN TestApi_Get/invalid_token
=== RUN TestApi_Get/internal_server_error_response
=== RUN TestApi_Get/plain_text_response
=== RUN TestApi_Get/long_response_time
=== RUN TestApi_Get/normal
--- PASS: TestApi_Get (0.50s)
--- PASS: TestApi_Get/invalid_token (0.00s)
--- PASS: TestApi_Get/internal_server_error_response (0.00s)
--- PASS: TestApi_Get/plain_text_response (0.00s)
--- PASS: TestApi_Get/long_response_time (0.50s)
--- PASS: TestApi_Get/normal (0.00s)
PASS
ok github.com/sawadashota/httprequesttest-go 0.591s
TestApi_Get/long_response_time
だけ0.5秒もかかっていてちゃんとタイムアウト(0.5秒)するまで待っていることがわかります。
サンプルコード
今回のサンプルコードをGitHubで公開しています
https://github.com/sawadashota/httprequesttest-go