DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

Golden TestではじめるGoのAPI ServerのRegression Test

はじめに

昨年(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 と呼びます1Advanced 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などのコードは一部省略しています)。 emailnameの値を受け取り、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 ハンドラに対してテストを書いていきます。 テストとしては以下のような流れになります。

  1. APIにhttp requestを投げ、responseをGolden Filesとして保存
  2. テスト実行の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を使います。

handlercontextからの値を使うように修正してみましょう。

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を実現するためにぜひ導入を検討してみてください。


  1. Snapshot Testingと呼ぶTesting Frameworkもあります

  2. アラン・ドノバン、ブライアン・カーニハン著 柴田 芳樹訳『プログラミング言語Go』丸善出版株式会社