LoginSignup
41
40

More than 5 years have passed since last update.

GoでHTTP Clientのテストを書く

Last updated at Posted at 2018-10-08

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

41
40
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41
40