みんなで書く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.sql
やcleanup.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) } }
具体例
ここまできたらあとは
- テスト関数を作る
go test -v -run TestFoo_Get | go run $PATH_TO_TOOL
するsetup.sql
とcleanup.sql
を修正するgo test -v -run TestFoo_Get -golden
する- goldenファイルの中身を確認する
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は導入済み。