GoでHTTP Clientのテストを書く

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 とは](https://qiita.com/tutuming/items/6006e1d8cf94bc40f8e8#httproundtripper-%E3%81%A8%E3%81%AF)

実装
---

### APIクライアントの構造体

`http.Client`はデフォルトで`http.DefaultClient`を使い、オプションによって差し替えられるようにします。

```golang
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
}
```

### オプションの実装

```golang
type Option func(*Api)

func OptionHTTPClient(c *http.Client) Option {
	return func(api *Api) {
		api.httpclient = c
	}
}
```

### Getの実装

Responseはシンプルに`{"text": "hoge"}`のようなのを想定。
タイムアウトになったらRequestをキャンセルできるように実装しておきます。

```golang
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`を返す関数を実装します。

```golang
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`」のテストヘルパーを作ります。

```golang
// 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`が返ってくる状況を作ることができたわけですが、これだと正常系しかテストできないので、サーバーの状況を自由に変えられるようにしたいと思います。

```golang
// 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),
		}
	})
}
```

実際のテストはこちら

```golang
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)
			}
		})
	}
}
```

### テストを実行してみる

```shell-session
$ 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