Goで複雑めなmockをいい感じにする

Nature エンジニアの的場です。これは第2回 Nature Engineering Blog 祭14日目のエントリです。 表題の通り、外部へのAPIリクエストをそれなりにする機能をテストするときにそのmockをどうやるのがいいかなと自分なりに試行錯誤した結果、今はこうやってますと言う内容を共有しようと思います。

TL;DR

テストケース単位で必要十分なレスポンスを返す小さなmockを都度作っていくのが調子が良い。 packageはgockを経て自作のものを使うように。

mockについて

mockと言う用語、本エントリでは都合の良い値を返す仮想API、またその設定をすること くらいの意味合いで捉えていただければと思います。

背景

外部APIのmockが必要になるのはバックエンド開発をしていればそれなりに遭遇するケースですが、自分の経験上ではテストスイートの頭などでちょっとしたmockを作っておけば対応できるケースがほとんどでした。 ですが条件によって同一エンドポイントの呼び出し回数が変わったり、エンドポイント自体が変わったりするような機能で、かつテストスイートが大きくなってしまう場合はこのmockの作り方ではきつくなってきました。

今までどうやってたか

GoでmockといえばTransportの差し替えです。 従来は以下のようなtypeを定義し、Transportをこのtypeの変数で差し替えていました。

type RoundTripFunc func(req *http.Request) (*http.Response, error)

func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
    return f(req)
}

テストしたい機能は以下のようなものだったとします。

func Sample(ctx context.Context, client *http.Client, userID string) error {
    _, err := http.Get(fmt.Sprintf("http://example.com/users/%s", userID))
    if err != nil {
        return err
    }
    // do something
    return nil
}

実際のテストコードは以下のような感じです。

func TestSample(t *testing.T) {
    mockTransport := RoundTripFunc(func(req *http.Request) (*http.Response, error) {
        if req.URL.String() == "http://example.com/users/001" {
            return &http.Response{
                StatusCode: http.StatusOK,
                Body:       io.NopCloser(strings.NewReader(`{"id":"001","name":"tester1"}`)),
                Request:    req,
            }, nil
        }
        return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
    })
    client := http.DefaultClient
    client.Transport = mockTransport

    err := Sample(context.Background(), client, "001")
    if err != nil {
        t.Fatal(err)
    }
    // check to do something
}

これのテストケースが増えてくると以下のような感じになります

func TestSample(t *testing.T) {
    mockTransport := RoundTripFunc(func(req *http.Request) (*http.Response, error) {
        response := func(body string) *http.Response {
            return &http.Response{
                StatusCode: http.StatusOK,
                Body:       io.NopCloser(strings.NewReader(body)),
                Request:    req,
            }
        }
        if req.URL.String() == "http://example.com/users/001" {
            return response(`{"id":"001","name":"tester1"}`), nil
        } else if req.URL.String() == "http://example.com/users/002" {
            return response(`{"id":"002","name":"tester2"}`), nil
        } else if req.URL.String() == "http://example.com/users/010" {
            return response(`{"id":"010","name":"tester10"}`), nil
        }
        return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
    })
    client := http.DefaultClient
    client.Transport = mockTransport

    specs := []struct {
        UserID string
    }{
        {UserID: "001"},
        {UserID: "002"},
        {UserID: "010"},
    }

    for _, spec := range specs {
        t.Run(spec.UserID, func(t *testing.T) {
            err := Sample(context.Background(), client, spec.UserID)
            if err != nil {
                t.Fatal(err)
            }
            // check to do something
        })
    }
}

ちょっと大変になってきました。 テスト自体がシンプルなのでまだ見てられますが、実際には入力が複数あったり、APIリクエストのheaderやbodyを見たくなったりするので行数が嵩んでいき、可読性がどんどん下がっていきます。 自分の場合、結局このテストケースではどのエンドポイントが叩かれるんだっけ、と考えて手が止まってしまうことがよくありました。

テストケースごとに小さくmockを作る

直前のテストコードをさらに改善するとこんな感じになります。

func TestSample(t *testing.T) {
    response := func(req *http.Request, body string) *http.Response {
        return &http.Response{
            StatusCode: http.StatusOK,
            Body:       io.NopCloser(strings.NewReader(body)),
            Request:    req,
        }
    }

    specs := []struct {
        UserID        string
        MockTransport RoundTripFunc
    }{
        {
            UserID: "001",
            MockTransport: func(req *http.Request) (*http.Response, error) {
                if req.URL.String() == "http://example.com/users/001" {
                    return response(req, `{"id":"001","name":"tester1"}`), nil
                }
                return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
            },
        },
        {
            UserID: "002",
            MockTransport: func(req *http.Request) (*http.Response, error) {
                if req.URL.String() == "http://example.com/users/002" {
                    return response(req, `{"id":"002","name":"tester2"}`), nil
                }
                return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
            },
        },
        {
            UserID: "010",
            MockTransport: func(req *http.Request) (*http.Response, error) {
                if req.URL.String() == "http://example.com/users/010" {
                    return response(req, `{"id":"010","name":"tester10"}`), nil
                }
                return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
            },
        },
    }

    for _, spec := range specs {
        t.Run(spec.UserID, func(t *testing.T) {
            client := http.DefaultClient
            client.Transport = spec.MockTransport

            err := Sample(context.Background(), client, spec.UserID)
            if err != nil {
                t.Fatal(err)
            }
            // check to do something
        })
    }
}

トータルの行数は増えているのですが、どのテストケースでどのようにAPIリクエストがされるのかが見やすくなったかと思います。 こうやって順を追って説明するとまあそりゃそうだって気持ちになっているのですが、自分の場合この書き方に落ち着くまでそれなりに時間がかかりました。

もちろんどんな場合でもこの書き方が一番いいと言うことはないので、テストケースによらず一定のレスポンスを返せば十分な場合などは最初に一回だけmockを作るような書き方もします。

ただ、個人的にもう少し改善したい点がありました。

  • if文がなんかめんどくさい
  • うっかり同じリクエストを2回するようなバグがあった場合もpassしてしまう
    • (あまりないかもだが

と言うわけでこれらも改善します。

mock packageを使う

今回はgockを使います。 選定理由は自分がさわったテストコードで既に使われていたというなんともな理由ですが...。

gockを使って書き直すと以下のようになります。

func TestSample(t *testing.T) {
    specs := []struct {
        UserID   string
        MockFunc func() func()
    }{
        {
            UserID: "001",
            MockFunc: func() func() {
                gock.New("http://example.com").Get("/users/001").Reply(200).BodyString(`{"id":"001","name":"tester1"}`)
                return func() { gock.OffAll() }
            },
        },
        {
            UserID: "002",
            MockFunc: func() func() {
                gock.New("http://example.com").Get("/users/002").Reply(200).BodyString(`{"id":"002","name":"tester2"}`)
                return func() { gock.OffAll() }
            },
        },
        {
            UserID: "010",
            MockFunc: func() func() {
                gock.New("http://example.com").Get("/users/010").Reply(200).BodyString(`{"id":"010","name":"tester10"}`)
                return func() { gock.OffAll() }
            },
        },
    }

    for _, spec := range specs {
        t.Run(spec.UserID, func(t *testing.T) {
            // gock は DefaultTransport を差し替える
            defer spec.MockFunc()()
            client := http.DefaultClient

            err := Sample(context.Background(), client, spec.UserID)
            if err != nil {
                t.Fatal(err)
            }
            // check to do something

            CheckGockIsDone(t)
        })
    }
}

// gock.Newしたリクエストが全部使われているか確認して、残っていたらエラーとしてURLと残回数を表示する
func CheckGockIsDone(t *testing.T) {
    if gock.IsPending() {
        t.Errorf("following mock requests are not used")
        for _, m := range gock.GetAll() {
            t.Errorf("url: %s, counter: %d", m.Request().URLStruct.String(), m.Request().Counter)
        }
    }
}

gockはこのようにmockするエンドポイントを定義できます。 一回定義すると一回だけmockしてくれるので、もし二回呼ばれるとエラーとなり、意図しないAPI呼び出しに気づくことができます。 またテストケースの終わりで呼ばれているCheckGockIsDoneにより、mockしたエンドポイントが不足なく呼ばれているかも確認することができます。

また、複数行にわたってif文を書いていく必要もなく、メソッドチェーンですっきり書けるのも個人的には気に入ったところです。

ここまで来ると作った機能のAPI呼び出し周りについて、意図した振る舞いになっていることにだいぶ安心感が持てるようになります。 逆に機能の振る舞いを理解するのにも一役買ってくれるかと思います。

package作ってみた

gock, 書き味は気に入ったのですが、グローバル変数をゴリゴリに使っている感じで自分には理解が難しかったのと、もっとシンプルでいいかなと思ったので同じような書き味でいけるrtqueueと言うpackageを作ってみました。 作りたてでテスト書きながら必要な機能を作っている段階なのでpublicにはしていないのですが、これを使って書くと以下のようになります。

func TestSample(t *testing.T) {
    specs := []struct {
        UserID        string
        MockTransport *rtqueue.RoundTripQueues
    }{
        {
            UserID: "001",
            MockTransport: rtqueue.NewTransport(
                rtqueue.New().Get("/users/001").ResponseSimple(200, `{"id":"001","name":"tester1"}`),
            ),
        },
        {
            UserID: "002",
            MockTransport: rtqueue.NewTransport(
                rtqueue.New().Get("/users/002").ResponseSimple(200, `{"id":"002","name":"tester2"}`),
            ),
        },
        {
            UserID: "010",
            MockTransport: rtqueue.NewTransport(
                rtqueue.New().Get("/users/010").ResponseSimple(200, `{"id":"010","name":"tester10"}`),
            ),
        },
    }

    for _, spec := range specs {
        t.Run(spec.UserID, func(t *testing.T) {
            client := http.DefaultClient
            client.Transport = spec.MockTransport

            err := Sample(context.Background(), client, spec.UserID)
            if err != nil {
                t.Fatal(err)
            }
            // check to do something

            // mockしたエンドポイントが全て呼び出されていればtrue
            if !spec.MockTransport.Done() {
                t.Fatal("mockTransport is not empty")
            }
        })
    }
}

rtqueueでも一度のNewで一回だけmockするので、Doneメソッドと合わせればgockと同様にAPI呼び出しの過不足を確認することができます。

複数のエンドポイントをmockするには以下のように書くことができます。

mockTransport := rtqueue.NewTransport(
    rtqueue.New().Get("/users/001").
        ResponseSimple(200, `{"id":"001","name":"tester1"}`). // 1回目
        ResponseSimple(200, `{"id":"001","name":"tester1"}`), // 2回目
    rtqueue.New().Get("/users/002").ResponseSimple(200, `{"id":"002","name":"tester2"}`),
),

そんなわけで、今現在はこんな書き方でmockしていってます。

なんか分かりやすくなったな、と思っていただけたなら万々歳です。


お知らせ

Nature エンジニアコミュニティ

エンジニアコミュニティはじめました。Matter を使った Nature Remo nano の活用方法から Nature Remo could API の疑問など、Nature のエンジニアがお答えします! Discord への参加はこちらから。 https://discord.gg/3Ep57Muuuc

Nature Remo nano 好評発売中!

Matter 対応の Nature Remo nano が定価3,980円で発売中!