はじめに
昨年(2019年)の11月にSWETチームへJoinした伊藤(@akito0107)です。 SWETチームでは主にGoのサービスに対するテスト実装のサポートや品質向上の取り組みを行っています。
この記事では、GoのAPI ServerのRegression Testについて、その目的と低コストに始められるGolden Files
を使ったテスト手法をサンプルのコードを交えながら紹介しようと思います。
API ServerのRegression Test
Goに限らず、サービスの成長に伴い常に変更が入るようなシステムだと、過去に実装したアーキテクチャが現在の仕様に適さなくなるといったケースも多くあると思います。
そういった場合、Refactoring、Rearchitectingなどを行い、より理想の形へソースコードやアーキテクチャを進化させていきます。その際に重要なのが、 過去の仕様と変更がないこと
を保証するということで、Regression Test(あるいは回帰テスト)はこれを目的にしたテストです。
httpのAPI Serverにおける 仕様と変更がないこと
の保証とは、極端に単純化して言うと同じhttp requestを同じ条件(時間・パラメータなど)で投げたときに、同じhttp responseが返ってくることを保証することです。
この記事では、この定義でRegression Testを扱って説明をしていこうと思います。
Golden Test
過去に実行したテストの結果をファイルに保存(= Golden Files)し、再度実行するときはそのファイルと同じ結果が返ってきているかどうかをチェックするテスト手法のことを Golden Test
と呼びます1。
Advanced Testing with Goで紹介されていますが、簡単に動きを見てみましょう。
以下の擬似コードを見てください。
var update = flag.Bool("update", false, "update golden files") func TestDoSomething(t *testing.T) { // Test対象の関数をCallする result := DoSomething() golden := filepath.Join("/path/to/testdata/", t.Name() + ".golden") // update flagが指定されていた場合はfileを書き出す if *update { ioutil.WriteFile(golden, result, 0644) } // golden fileを読み込む expected, _ := ioutil.ReadFile(golden) // 変更がないかをチェックする if bytes.Equal(result, expected) { t.Errorf("golden file not matched") } }
このテストでは、 DoSomething()
のチェックを行っています。
DoSomething()
からの返り値をそのままファイルに書き出し、 bytes.Equal
を使ってファイルの値と実行した値が同じかどうかを比較しています。
暗黙的にDoSomething()
の返り値が[]byte
であることを想定しているのに注意してください。
ポイントとしては、 -update
フラグを設定した際に、書き出したGolden Files
をupdateするというところです。
もちろんAPIに仕様変更があった際にはoutputが変わるのは当たり前なので、この-update
フラグのようなGolden Files
のメンテナンスを容易にする仕組みを取り入れましょう。
今回はgoldieというライブラリを使って Golden Test
を行っていこうと思います。-update
フラグでGolden Files
をupdateする仕組みなどは上記のコードと変わりませんが、jsonでの比較やdiffが出た際のvisualizeなどが作り込まれていて、便利に使うことができます。
Golden Filesを使ったAPIのRegression Test
では、goldieを使ってRegression Testを書いて行きましょう。
Goldieの使い方
goldie
の使い方を簡単に見てみます。
なお、goldieはv2.2を使います。
v1とv2ではAPIが違うので注意してください。
go get
を使ってinstallします。この際にv2
を指定します。
$ go get github.com/sebdah/goldie/v2
実際にgoldieを使う際にはNew
で初期化したあとに、Assert
を呼ぶだけです。
試しに上のコードをgoldie
を使った形に直してみましょう。
func TestDoSomething(t *testing.T) { result := DoSomething() // goldieを初期化 g := goldie.New(t) // Golden Fileとの比較 g.Assert(t, t.Name(), result) }
goldie
自体でupdate
フラグによるGolden Fileのメンテナンスなどをサポートしてくれます。
また、Golden Fileと比較するAssert
という関数は3種類あり、ユースケースに応じて呼び分ける必要があります。上の例で使ったAssert
は[]byte
同士を比較します。ほかにも、JSON
同士を比較できるAssertJson
や、Templateを使って実行時に動的に値を書き換えられるAssertWithTemplate
があります。
goldie
ではデフォルトで、testdata
配下にGolden Fileが保存されます。このPathを書き換えたいときは、WithFixtureDir
のOptioinをNew
に渡します。
g := goldie.New(t, goldie.WithFixtureDir("./testadata/golden"))
このケースだと./testdata/golden
配下にファイルが保存されます。
それ以外にも多くのオプションがあるので、GoDocを参照してみてください。
API ServerでのExample
では、goldie
を使ってAPI ServerのRegression Testを書いていきます。
下記のようなHandlerを持つAPIを想定してテストを書きます(error handlingなどのコードは一部省略しています)。
email
とname
の値を受け取り、DBに保存するというユースケースのAPIです。
// User定義のstruct // ID / CreatedAtはDBにより自動でセットされる想定 type UserJSON struct { ID int `json:"id" db:"id"` Name string `json:"name" db:"name"` Email string `json:"email" db:"email"` CreatedAt time.Time `json:"created_at" db:"created_at"` } type ErrorResponse struct { Message string `json:"message"` } // Error用関数 func writeError(w http.ResponseWriter, message string) { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(&ErrorResponse{Message: message}) } func postUser(db *sqlx.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // JSONのbinding var body UserJSON if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "json decode failed") return } // DBへInsert ctx := r.Context() rs, err := db.ExecContext(ctx, "INSERT INTO users(name, email) VALUES (?, ?)", body.Name, body.Email) if err != nil { writeError(w, "insert user failed") return } // IDはAuto IncrementでAuto Generateされるので、その値を取得してくる id, err := rs.LastInsertId() if err != nil { writeError(w, "last insert id failed") return } // Response用にDataを取得する var user UserJSON if err := db.GetContext(ctx, &user, "SELECT * from users where id = ?", id); err != nil { writeError(w, "get user failed") return } // Responseを返す w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(&UserJSON{ ID: user.ID, Name: user.Name, Email: user.Email, CreatedAt: user.CreatedAt, }); err != nil { writeError(w, "json encode failed") return } } }
http.HandlerFunc
を直接定義するのではなく、 http.HandlerFunc
を返す関数として定義しています。このコードの例のように、 db
等の依存モジュールを外部から受け取れるようにするためです。このメリットは後にテストコードを書くときに体感できると思います。
main.go
ではこの handler
を下記のようにして読み込んでいます。
func main() { db := sqlx.MustConnect("mysql", "root:passw0rd@tcp(localhost:3306)/e2e_example?parseTime=true") defer db.Close() handler := postUser(db) if err := http.ListenAndServe(":8080", handler); err != nil { log.Fatal(err) } }
今回はhandler
が1つなので、特にrouter
のライブラリなどは使っていません。
この postUser
ハンドラに対してテストを書いていきます。
テストとしては以下のような流れになります。
- APIにhttp requestを投げ、responseを
Golden Files
として保存 - テスト実行の2回目以降はresponseがGolden Filesと差分が無いかを確かめる
func TestPostUser(t *testing.T) { db := sqlx.MustConnect("mysql", "root:passw0rd@tcp(localhost:3306)/e2e_example?parseTime=true") t.Cleanup(func() { // Go1.14から導入された関数 // DBにinsertしたdataのcleanupを行う。 キー制約を削除し、truncateする db.MustExec("set foreign_key_checks = 0") db.MustExec("truncate table users") db.MustExec("set foreign_key_checks = 1") db.Close() }) var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(&UserJSON{ Name: "test", Email: "test@dena.com", }); err != nil { t.Fatal(err) } // httptestによりRequestとResponseRecorderを作成する req := httptest.NewRequest(http.MethodPost, "/", &buf) rec := httptest.NewRecorder() // http requestをsimulateする。 `rec` にResponseが書き込まれる。 postUser(db).ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("status code must be %d but %d", http.StatusCreated, rec.Code) } // Response BodyのJSONをDecodeする var user UserJSON if err := json.NewDecoder(rec.Body).Decode(&user); err != nil { t.Fatalf("json Decode failed: %v", err) } // goldieの初期化 g := goldie.New(t) // Golden Fileとの比較を行う g.AssertJson(t, t.Name(), user) }
httptest
を使ったシンプルな構成ですが、handlerの中でdb
につなげているので、db
をテストの中でもopen
し、handler
に渡しています。
テストの後半にgoldie
でAPIのresponseのJSONを比較しています。JSONの比較なので、AssertJson
を使っています。
このテストを動かしてみましょう。(DBはlocalhost:3306でlistenしている前提です) すると、下記のように出力されるはずです。
$ go test . --- FAIL: TestPostUser (0.03s) e2e_test.go:42: Golden fixture not found. Try running with -update flag. FAIL FAIL github.com/DeNA/apiexample 0.085s FAIL
fixtureがないというメッセージでFailします。メッセージの通り、-update
をつけてテストを実行してみましょう。
$ go test . -update ok github.com/DeNA/apiexample 0.091s
今度は成功しました。また、testdata
というディレクトリが作成されていることに気づくと思います。中に、TestPostUser.golden
というファイル作成されています。このTestPostUser
というファイル名は今回実装したテストの関数名と同じでしたね。
goldie.AssertJSON
の第2引数で渡しているt.Name()
はTestPostUser
なので、第2引数で渡した文字列の名前のファイルが出来ていることが分かると思います。
. ├── go.mod ├── go.sum ├── main.go ├── main_test.go └── testdata └── TestPostUser.golden
TestPostUser.golden
のファイルの中を見てみましょう
$ cat testdata/TestPostUser.golden { "id": 1, "name": "test", "email": "test@dena.com", "created_at": "2020-03-06T03:50:31Z" }
APIのresponseのjsonがそのまま保存されています。
この状態で、もう一度go test
を実行してみましょう。
$ go test . --- FAIL: TestPostUser (0.03s) e2e_test.go:42: Result did not match the golden fixture. Diff is below: --- Expected +++ Actual @@ -4,3 +4,3 @@ "email": "test@dena.com", - "created_at": "2020-03-06T03:50:31Z" + "created_at": "2020-03-06T03:54:04Z" } FAIL FAIL github.com/DeNA/apiexample 0.089s FAIL
テストがFailしてしまいましたが、なぜFailしたのかの差分ををわかりやすい形式で表示してくれるのが、goldie
を使う利点です。
created_at
はDBへinsertするときにMySQLのCURRENT_TIMESTAMP
で設定しているため、実行時の時間が挿入されます。そのため、実行ごとに値が異なってしまうため、API Responseの値に差分がでてしまいました。
実行時に値が変わる時の対処法
今回のcreated_at
に変更があったケースですが、テストがFailしていることが間違っている状態、つまり偽陽性のあるテスト、となってしまっています。特に時間や、Auto GeneretedされるIDなどの値は実行時に変更されるため、Golden Testsで扱うのが難しい部類となります。ここでは、これに対応するパターンをいくつか考えていこうと思います。
外部からTimestampを取得できるようにする
DBの current_timestamp
を使わずに、アプリケーション上でタイムスタンプを取得し、INSERT
時に渡すように書き換え、その上でテストコードからタイムスタンプを取得するロジックを操作できるようにします。
db
と同じように関数の引数でタイムスタンプを取得する関数を渡すか、
プログラミング言語Go2 11.2.3ホワイトボックステストで触れられているような、関数をpackage scopeの変数として定義しテスト時に置き換えるような方法を取るかの2パターンが考えられます。
ここでは後者の方を採用し、実装してみたいと思います。
handler
を以下のように書き換えます。
+var now = func() time.Time { + return time.Now() +} + func postUser(db *sqlx.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "json decode failed") return } ctx := r.Context() - rs, err := db.ExecContext(ctx, "INSERT INTO users(name, email) VALUES (?, ?)", body.Name, body.Email) + rs, err := db.ExecContext(ctx, "INSERT INTO users(name, email, created_at) VALUES (?, ?, ?)", body.Name, body.Email, now()) ~省略~
now
という変数を定義し、INSERT
時にその関数を使うようにします。
Testは以下のように修正します。
func TestPostUser(t *testing.T) { db := sqlx.MustConnect("mysql", "root:passw0rd@tcp(localhost:3306)/e2e_example?parseTime=true") + saved := now + now = func() time.Time { + n, _ := time.Parse(time.RFC3339,"2020-03-03T15:04:05Z") + return n + } + t.Cleanup(func() { db.MustExec("set foreign_key_checks = 0") db.MustExec("truncate table users") db.MustExec("set foreign_key_checks = 1") db.Close() + + now = saved }) ~省略~
テストの関数の中でnow
を上書きし、任意の固定の時間を返すようにします。そのうえで、テストを何回か実行してみてください。固定の時間が使われるようになったため、テストが安定して動くようなりました。
t.Cleanup
もしくは、defer
などで、テスト実行時に元の関数に値を戻すことを忘れないでください。もしその関数が他のテストに依存していた場合、値が食い違ってしまいます。
このパターンは時間など、実行時に依存する値をコントロールする汎用的なものです。もしAPI Server以外でも同じ様なケースで困ったら採用してみる価値はあるかもしれません。
Http Headerで時間を操作できるようにする
httpのAPI Serverに特化したパターンも考えてみましょう。httpのmiddlewareで時間を設定し、handlerの内部ではその値を使うようなパターンを実装してみます。
以下の様なmiddlewareを実装しましょう。
const requestTimeHeaderKey = "X-Request-Time" type requestTimeCtxKey struct{} func requestTimeMiddleware(next http.HandlerFunc) http.HandlerFunc{ return func(w http.ResponseWriter, r *http.Request) { now := time.Now() t := r.Header.Get(requestTimeHeaderKey) if t != "" { if n, err := time.Parse(time.RFC3339, t); err != nil { now = n } } ctx := context.WithValue(r.Context(), requestTimeCtxKey{}, now) r = r.WithContext(ctx) next(w, r) } }
X-Request-Time
のhttpのrequest headerがあったら、その値をcontext
にセットし、なければ、time.Now
を使います。
handler
でcontext
からの値を使うように修正してみましょう。
func postUser(db *sqlx.DB) http.HandlerFunc { if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "json decode failed") return } ctx := r.Context() - rs, err := db.ExecContext(ctx, "INSERT INTO users(name, email) VALUES (?, ?)", body.Name, body.Email) + now := ctx.Value(requestTimeCtxKey{}).(time.Time) + rs, err := db.ExecContext(ctx, "INSERT INTO users(name, email, created_at) VALUES (?, ?, ?)", body.Name, body.Email, now) ........
テスト側ではmiddleware
でhandlerをwrapし、header
をセットしましょう。
~省略~ + req := httptest.NewRequest(http.MethodPost, "/", &buf) + req.Header.Add(requestTimeHeaderKey, "2020-03-03T15:04:05Z") rec := httptest.NewRecorder() - postUser(db).ServeHTTP(rec, req) + requestTimeMiddleware(postUser(db)).ServeHTTP(rec, req) ~省略~
これでテストを実行してみましょう。Failになることはなくなったと思います。
header
をセットする方法は外部から時間を操作できてしまい、意図しないリクエストを発行される恐れがあるので、テストでのみ有効になるように、build tag
などを使って調整することをおすすめします。
zero値に置き換える
上記2つの方法はアプリケーションコードに手を加える方法でした。ここでは、テストの方に修正を加えて、実行時に値が変わる項目に対応していきたいと思います。
goldie
へわたす前に、値をzero値、もしくは固定値に書き換えてしまえばテストは安定します。
........
g := goldie.New(t)
var user UserJSON
json.NewDecoder(rec.Body).Decode(&user)
+ user.CreatedAt = time.Time{}
g.AssertJson(t, t.Name(), user)
}
非常に乱暴なやり方ですが、今回のようにテスト時に無視できるとわかっている値がある場合には最もコストが少ない方法です(とはいえ、乱用してはテストの意味がなくなってしまうので注意深く使いましょう)。
reflect
パッケージを使って、以下のような helper
を実装しても良いと思います。
func ignoreField(t *testing.T, i interface{}, fieldNames ...string) { t.Helper() if reflect.TypeOf(i).Kind() != reflect.Ptr { t.Fatalf("given type %T is not a pointer type", i) } iv := reflect.Indirect(reflect.ValueOf(i)).Interface() v := reflect.ValueOf(i) tp := reflect.TypeOf(iv) if tp.Kind() != reflect.Struct { t.Fatalf("given type %T is not a struct type", tp) } for j := 0; j < tp.NumField(); j++ { f := tp.Field(j) if !contains(f.Name, fieldNames) { continue } v.Elem().Field(j).Set(reflect.Zero(f.Type)) } } func contains(s string, arr []string) bool { for _, ar := range arr { if s == ar { return true } } return false }
このignoreField
helperは、引数で渡されたstructから、同じく引数で指定されたプロパティ名の値をゼロ値に置き換えます。今回のケースだと、
...... g := goldie.New(t) var user UserJSON json.NewDecoder(rec.Body).Decode(&user) ignoreFields(t, &user, "CreatedAt") g.AssertJson(t, t.Name(), user) }
このように使います。NestedなStructなど、もう少し複雑なケースではまた対応が必要ですが、それに応じたhelper
などを用意しても良いかもしれません。
注意事項
Golden Testとgoldie
の使い方を見てきましたが、大変便利である反面、テストとして有効な範囲は限られます。Golden Test
で確かめられるのは、以前の結果と変更がないことだけです。 Responseは同じでも、中身のロジックが変わってしまったケースなどはこのテストでは検知できません。
また、出力されたGolden File
が正しい結果なのかどうかも、テスト自体では確かめることはできません。Golden File
を出力したら、確実にその結果が正しいのかどうかをチェックするようにしましょう。
加えて、-update
を渡すと、全てのGolden Files
が更新されてしまいます。-update
を実行する際には、go test
にオプションで実行対象のテストを絞り込み、慎重にupdate
を行いましょう。
まとめ
GoのAPI ServerのRegression Testと、それを低コストに始められるGolden Test、そのライブラリであるgoldieについて説明をしました。 Golden Testは簡単に始められますが、実行するたびに値が変わるパターンやGolden Fileの扱いなど、慎重に扱う必要がある場面も多々あります。 使い所は限定されますが、効果的なRegression Testを実現するためにぜひ導入を検討してみてください。