みんなで書くGoのエンドポイントテスト

Webアプリケーションサーバーに何か大きな変更をしたいけど、既存のテストだと心許なかったので各エンドポイントにHandlerからのテストを追加することにした。

ただ全部のテストを自分1人で作っていくのはボリューム的に現実的ではなかったので、どうしたらチーム全員が書きやすいテストになるか考えて色々と整備してみた。

テストの書き方がある程度決まっている

エンドポイントごとにスタイルがバラバラだと都度どう書くか考えなければいけなくなってしまうため、基本的にはリクエストとレスポンスだけテーブルに指定するスタイルが良さそうだと考えた。

簡略化すると以下のような形式。

func TestFoo_Get(t *testing.T) {
    tests := []struct {
        name string
        // ヘッダやクエリパラメータなど
        // 期待するレスポンス
    }{
        // 実際のテストケース
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            r := httptest.NewRequest("GET", "/api/foo", nil)
            RunTest(t, r, tt.want)
        })
    }
}

しかし、ヘッダやクエリパラメータの有無によってはそれに応じた処理を書かないといけないので、それを吸収するための関数を用意することにした。

type RequestOption func(*http.Request)

func WithQuery(key, value string) RequestOption {
    return func(r *http.Request) {
        q := r.URL.Query()
        q.Set(key, value)
        r.URL.RawQuery = q.Encode()
    }
}

func WithHeader(key, value string) RequestOption {
    return func(r *http.Request) {
        r.Header.Set(key, value)
    }
}

func NewRequest(method, endpoint string, body io.Reader, options ...RequestOption) *http.Request {
    r := httptest.NewRequest(method, endpoint, body)
    for _, opt := range options {
        opt(r)
    }
    return r
}

また、POSTやPUTでJSONを送る場合は以下の関数でボディを作れるようにした。

func JSONBody(t *testing.T, m map[string]interface{}) io.Reader {
    t.Helper()

    body := new(bytes.Buffer)
    if err := json.NewEncoder(body).Encode(&m); err != nil {
        t.Fatal(err)
    }
    return body
}

期待する結果(want)を全て書かなくても良い

レスポンスはエンドポイントによってはかなり大きくなることもあり、毎回全体を書くのは大変そうだったので避けたかった。
そしてレスポンスが変わるたびに毎回手動で全て直さないといけないのも面倒なのでgoldenファイル化することにした。

値が固定されないところもあるので、そこはレスポンスを柔軟に書き換えられるようにしている。*1
例えばJSONが返ってくるエンドポイントであれば以下のような関数。*2

type ResponseFilter func(t *testing.T, r *http.Response)

func ModJSONFields(overwrite map[string]interface{}) ResponseFilter {
    return func(t *testing.T, r *http.Response) {
        t.Helper()

        var tmp map[string]interface{}
        if err := json.NewDecoder(r.Body).Decode(&tmp); err != nil {
            t.Fatal(err)
        }

        rewriteMap(t, tmp, overwrite)

        body := new(bytes.Buffer)
        if err := json.NewEncoder(body).Encode(&tmp); err != nil {
            t.Fatal(err)
        }
        r.Body = io.NopCloser(body)
    }
}

これでRunTestは以下のようになる。

var (
    handler http.Handler

    updateGolden = flag.Bool("golden", false, "Update golden files")
)

func RunTest(t *testing.T, r *http.Request, want int, filters ...ResponseFilter) {
    t.Helper()

    w := httptest.NewRecorder()
    handler.ServeHTTP(w, r)

    got := w.Result()
    if got.StatusCode != want {
        t.Errorf("HTTP StatusCode = %d, want %d", got.StatusCode, want)
    }

    for _, f := range filters {
        f(t, got)
    }

    dump, err := httputil.DumpResponse(got, true)
    if err != nil {
        t.Fatal(err)
    }

    if *updateGolden {
        writeGolden(t, dump)
    } else {
        golden := readGolden(t)
        if diff := cmp.Diff(golden, dump); diff != "" {
            t.Errorf("HTTP Response mismatch (-want +got):\n%s", diff)
        }
    }
}

httptest.Serverを使わなかったのはモックが無いとどうにもならなくなった時に最悪contextに何か詰めてどうにかしようと思ったからなんだけど、今のところその必要はなさそう。

テストの前後で必要な処理がわかる

これだけで良ければとても楽なんだけど、一番大変なのは必要なリソースの準備なはず。
今回対象としたWebアプリはxorm経由でMySQLを使っているため、テスト実行時に出力されるxormのログを分析するツールを用意した。

go test -v -run TestFoo_Get | go run $PATH_TO_TOOL のように使うことで、サブテストごとにアクセスのあったテーブルを表示したり、setup.sqlcleanup.sqlを生成できる。
まだそのまま使えるSQLにはならないので手動で直さないといけないけど、何も無いよりはだいぶマシかな。

func SetupDB(t *testing.T) {
    t.Helper()

    execSQL(t, "setup.sql")
}

func CleanupDB(t *testing.T) {
    t.Helper()

    execSQL(t, "cleanup.sql")
}

var db *sql.DB

func execSQL(t *testing.T, sqlfile string) {
    t.Helper()

    filename := filepath.Join("testdata", t.Name(), sqlfile)
    file, err := os.ReadFile(filename)
    if os.IsNotExist(err) {
        return
    }
    if err != nil {
        t.Fatal(err)
    }

    if _, err := db.Exec(string(file)); err != nil {
        log.Fatal(err)
    }
}

具体例

ここまできたらあとは

  1. テスト関数を作る
  2. go test -v -run TestFoo_Get | go run $PATH_TO_TOOL する
  3. setup.sqlcleanup.sql を修正する
  4. go test -v -run TestFoo_Get -golden する
  5. goldenファイルの中身を確認する
  6. go test -v -run TestFoo_Get でPASSすることを確認する

の流れで以下を量産していくだけ。

func TestFoo_Get(t *testing.T) {
    SetupDB(t) // TestFoo_Get/setup.sqlがあれば実行する
    t.Cleanup(func() {
        CleanupDB(t) // TestFoo_Get/cleanup.sqlがあれば実行する
    })

    tests := []struct {
        name string
        opts []RequestOption
        want int
    }{
        {
            name: "found",
            opts: []RequestOption{WithQuery("limit", "10")},
            want: http.StatusOK,
        },
        {
            name: "invalid limit",
            opts: []RequestOption{WithQuery("limit", "abc")},
            want: http.StatusBadRequest,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            SetupDB(t) // TestFoo_Get/サブテスト名/setup.sqlがあれば実行する
            t.Cleanup(func() {
                CleanupDB(t) // TestFoo_Get/サブテスト名/cleanup.sqlがあれば実行する
            })

            r := NewRequest("GET", "/api/foo", nil, tt.opts...)
            RunTest(t, r, tt.want,
                ModJSONFields(map[string]interface{}{
                    "created_at": "2006-01-02 15:04:05",
                }),
            )
        })
    }
}

とりあえず次の改修には十分なテストが追加できたので安心して変更できそう。

それでも今後のことを考えるともう少しテストを増やしておきたいので既存のエンドポイントにテスト追加を促すlinterでも作りたいところ。
なお、新規エンドポイント追加時にテストが無かったら警告するlinterは導入済み。

*1:go-cmpのオプションは難しそうだったのでやらなかったのと、この形式ならJSONを整形する関数なんかも簡単に作れる

*2:他にはCookieやHTMLなんかを加工したり