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