goldie を使って低コストでAPIサーバのリグレッションテストを実装する

ATOM開発チームの上野です。
普段はGo言語を使ってAPIサーバやバッチ処理機構の実装などを担当しています。

今回はATOMのAPIサーバのプロジェクトに導入して良い感じだった
リグレッションテストの手法についてご紹介します。

背景

ATOMチームでは普段からたくさんの機能が追加されています。 時には急いで実装しないといけない場面もあり、 なかなかテストコードまで手が回らないという状況になっていました。

そこで、実装工数が少なく最低限の動作を保証するテストができないかと検討したところ、 リグレッションテストを導入することに行きつきました。

APIのリグレッションテスト

リグレッションテスト (回帰テスト) とは
あるソースコードの変更に対して、「意図しない影響が起きていないか」をチェックするものです。

特にAPIサーバの場合、以下のフローで検証を行います。

  1. 事前準備
    • DBにテストデータを投入
    • テスト用のサーバを起動
    • 各エンドポイントに対し、HTTPリクエストを送信
    • サーバが返したレスポンスをファイルに保存
  2. 実装に手を加えた後、本命のテストを実行
    • 各エンドポイントに対し、HTTPリクエストを送信
    • サーバが返したレスポンスと、[事前準備]で保存しておいたファイルと比較
    • もし差異が出ていればエラーになる (意図しない差異があれば修正する)

これを各プルリクエスト毎にCIでテストしていれば、「意図しない影響が起きていないか」をある程度検知できるようになります。

実装例

テストを実現するには「事前にレスポンスをファイルに保存し、次回以降、それと比較する」というものを実装しないといけないのですが、それをやってくれるのが goldie というパッケージです。

以下のように、 goldie.Asset() メソッドを呼び出すことで、ファイルとの比較をやってくれます。 (README.mdより)

func TestExample(t *testing.T) {
    recorder := httptest.NewRecorder()

    // `/users` に対してリクエストを送信する
    req, err := http.NewRequest("GET", "/users", nil)
    assert.Nil(t, err)
    handler := http.HandlerFunc(ExampleHandler)
    handler.ServeHTTP()

    // goldie を初期化
    g := goldie.New(t)

    // ローカルの `users.goldie` というファイルと、レスポンスの内容を比較する
    // 一致しなければエラーになり、差分を出力してくれる
    g.Assert(t, "users", recorder.Body.Bytes())
}

上記のテストを初回に実行しても、対象の .goldie ファイルが存在しないため、失敗します。(最初は比較対象が存在しないため)

$ go test
--- FAIL: TestExample (0.00s)
    /example_test.go:30 Golden fixture not found. Try running with -update flag.

そのため、初回は -update フラグをつけて実行します。
そうすると、今のレスポンスが .goldie ファイルに出力され、次回以降はそれと比較されるようになります。

$ go test -update
    ok      /example_test   1.610s
$ cat users.goldie
[
    {
        "name": "ポメラニアン",
        "weight": 2
    },
    {
        "name": "トイプードル",
        "weight": 3
    }
]

このテストを作成した後、該当の /users に変更を加えた場合、レスポンスが変わっていればテストがFailします。

# コードを変更してテスト実行

$ go test
--- FAIL: TestExample (0.00s)
    /example_test.go:30 Result did not match the golden fixture. Diff is below:
    --- Expected
    +++ Actual
    @@ -3,4 +3,3 @@
    "name": "ポメラニアン",
    -   "weight": 2
    +   "min_weight": 2,
    +   "max_weight": 3
    },
    ...

Failした場合は、その差分を確認し、意図しないものであれば実装を修正します。
問題がなければ先ほどと同様に -update を実行して、 .goldie ファイルを更新します。

$ go test -update
    ok      /example_test   1.610s
$ cat users.goldie
[
    {
        "name": "ポメラニアン",
        "min_weight": 2,
        "max_weight": 3
    },
    ...

応用

今回はAPIのレスポンスだけを対象としましたが、これを応用してテスト対象を広げることができます。

ATOM開発チームではこれを強化して、APIリクエスト直後のDBの中身をdumpしたものやファイルサーバ(Mock)の中身なども .goldie ファイルに書き出してチェックしています。

通常のテストコードだと「Expectedな値」を自力で書いて管理するのが困難な場合も、このようにリグレッションテストであれば楽に実装することができます。

まとめ

今回はAPIサーバのリグレッションテストをご紹介しましたが、
個人的には以下のようなメリット・デメリットがあると考えています。

メリット

  • 各テストの実装工数がかなり小さい
    • 1度仕組みを作ってしまえば、全エンドポイントに対して展開するだけ
    • 既にたくさんエンドポイントがあって、最低限のテストを全体的に入れたい場合は効果的
  • 実装の変更が多い時期でも、テストが邪魔になりにくい
    • 変更が入るたびに都度テストケースを直してツラい... という状況でもgoldieを使えば go test -update 1発で終わる
  • 出力ファイル (.goldie ファイル) が時にはドキュメントにもなる
    • 実際に返ってくるレスポンスの例がファイルになっているので、コードレビューの時も役立つ

デメリット

  • 使い所は限定される
    • 当然、レスポンスは同じでもロジックが変更されたケースは検知できない
  • レスポンスの値が固定されるように工夫しなければならない
    • 例えばDBのタイムスタンプなどがレスポンスに入った場合は実行のたびにFailしてしまう
    • (単体テストと同様に) 副作用は完全に排除した状態にしなければならない
  • テストデータの準備は必要
    • できるだけProductionに近いレスポンスを保存すると効果的だが、準備が大変

リグレッションテストは、使い所を見極めて導入すれば高いコストパフォーマンスを発揮してくれます。

今後テストを導入したくなった際の一つの選択肢にしていただければと思います!