<-- mermaid -->

新規プロダクト開発で API の統合テスト文化が根付いているっていう話(Golang)

はじめに

はじめまして!株式会社アルファドライブにてバックエンド開発をやっています コタ @sirogami_main です。

この記事は AlphaDrive Advent Calendar 2023 の最終日の記事です!

qiita.com

現在開発中の新規プロダクトではバックエンドのほぼすべての API に複数のテストがあり、API テスト数の合計は720件を超えています。これは私一人の力ではなくて、チーム内にテスト文化が根付いているのがすごい大きいです。

嬉しいことに今では「とりあえず API テストは書こう」という文化が出来ており、これは社員だけではなくて業務委託などのフルコミット出来ないエンジニアでも API テストの重要性を理解して実践出来ています。

このアドベントカレンダーの記事では、どのように API テストの文化が根づいたか を共有したいと思います!


(補足) ここでいう API テストとは、APIを実際にcallして、レスポンスのJSONと永続層の変更を検査し、レスポンスボディを GoldenFile として保存するリグレッションテストを指します


新規プロダクトにおける API テストの重要性を理解してもらう

そもそも API テストの整備をE2Eやユニットテストより優先した理由は、簡潔に言えば「効率性」です。テストトロフィーの概念に沿って API テストはシステムの統合部分に焦点を当て、実際の動作を検証するために重要だと考えます。

具体的なメリットとして、API テストの整備を優先することでレビュワーの負担も軽減され結果として開発速度が向上します。 API テストがない状況でプルリクエストの差分だけを見て API が正しく実装されているかを判断するのは非常にストレスの多い作業です。API テストがあれば、レビュワーはテスト結果を参照してコードの挙動をより正確に理解しやすくなります。

また、API テストによりデグレチェックが自動化されたことでコードのリファクタやライブラリのアップデートが気軽にできるようになったというのもメリットが大きいです。

誰でも容易に信頼性の高いテストが書ける基盤づくり

テスト文化を根付かせるためには、容易に信頼性の高いテストがかけるようになる必要があります。難しいテストは誰も書きたくならないし、読みたくもならないです。

そこで私たちのサービスでは自作した Golden Test 用のライブラリを作って API の Regression Test の運用をしています。

このライブラリはテスト中に

   if diff := golden.DiffJSONBytes(t, body); diff != "" {
        t.Errorf("response body mismatch (-want, +got)\n%s", diff)
    }

と3行書くだけで完結するのがすごいお気に入りです。これだけで次のようなことが出来ます。

  • Golden File の更新を制御

テストを実行するときに環境変数 UPDATE_GOLDEN_FILE=true とするだけで、Golden File を更新することが出来ます。

  • 差分を分かりやすく表示

このライブラリは go-cmp を拡張したものなので、期待される出力と実際の出力の差分を分かりやすく表示する機能があります。 JSON の差分表示にはこの issue を参考にしています。

Is there any way to compare two JSON []byte inputs with this library? · Issue #224 · google/go-cmp · GitHub

=== RUN   TestUserGetAPI_OK
    user_get_test.go:43: response body mismatch (-want, +got)
          []uint8(Inverse(ParseJSON, map[string]any{
        -   "display_name": string("test-user1"),
        +   "display_name": string("test-user2"),
            "mail_address": string("test@example.com"),
          }))
--- FAIL: TestUserGetAPI_OK_AuthUser (0.52s)
  • JSONだけでなく画像もサポート

レスポンスで帰ってくるものには PNG などの画像もありえます。

その場合は

   if diff := golden.DiffPNGBytes(t, body); diff != "" {
        t.Errorf("response body mismatch (-want, +got)\n%s", diff)
    }

と書くだけです、これだけで PNG 形式として Golden File が保存されるので PR 上で差分を分かりやすく確認できます。もちろんレスポンスが PNG 形式かどうかのチェックもしてくれます。

Github で表示される差分

  • ほぼ一意なファイルパスの生成

Golden Test の独立性を保証するために、ファイルパスの生成ロジックが一番重要だと考えます。もし複数のテストから同じ Golden File を参照するようになると信頼性はガタ落ちです。

このライブラリではそれを防ぐために、テスト名から Golden File の名前を決定するようなロジックになっています。 テスト名はパッケージ内でほぼ一意なので高いレベルで独立性が保たれます。*1

下コードはライブラリのパス生成ロジックです。*2

package golden_file

...

// path return golden filepath
// panic occurs if '/' is contained in the name, so replace to '__'
// For example, subtest name will contain '/'
func path(t *testing.T, suffix string) string {
    name := strings.ReplaceAll(t.Name(), "/", "__")
    return filepath.Join(goldenFileDir, name+suffix)
}

const goldenFileDir = "testdata/golden_file"

実際のテスト

実際に運用しているコードとほぼ同じものがこれです。ユーザーを1件取得するシンプルな API に正常系、異常系をそれぞれ1件づつ書いてみました。

package api_test

import ...

func TestUserGetAPI_OK(t *testing.T) {
    dbR.initDB(t) // DB 初期化

    router := initTestRouter(t)

    user1 := model.User{
        DisplayName: "test-user1",
        MailAddress: "test@example.com",
    }
    if err := user1.Insert(context.Background(), dbR.DB(), boil.Infer()); err != nil {
        t.Fatalf("failed to insert user1: %v", err)
    }

    w := callGetUserAPI(t, router, entity.NewResourceCode(user1.ID).String())
    if w.Code != http.StatusOK {
        t.Errorf("status code got %d, want %d", w.Code, http.StatusOK)
    }

    if diff := golden.DiffJSONBytes(t, w.Body.Bytes()); diff != "" {
        t.Errorf("response body mismatch (-want, +got)\n%s", diff)
    }
}

func TestUserGetAPI_NG_NonExistUser(t *testing.T) {
    dbR.initDB(t)

    router := initTestRouter(t)

    w := callGetUserAPI(t, router, entity.NewResourceCode(10101017).String())

    if w.Code != http.StatusNotFound {
        t.Errorf("status code got %d, want %d", w.Code, http.StatusNotFound)
    }

    if diff := golden.DiffJSONBytes(t, w.Body.Bytes()); diff != "" {
        t.Errorf("response body mismatch (-want, +got)\n%s", diff)
    }
}

func callGetUserAPI(t *testing.T, router *gin.Engine, userCode string) *httptest.ResponseRecorder {
    t.Helper()

    w := httptest.NewRecorder()

    req, err := http.NewRequest(
        http.MethodGet,
        fmt.Sprintf("/v1/users/%s", userCode),
        nil,
    )
    if err != nil {
        t.Fatalf("http.NewRequest not expected err: %v", err)
    } 
    // 認証をセットする処理

    router.ServeHTTP(w, req)
    return w
}

このテストに対して UPDATE_GOLDEN_FILE=true 環境変数を付けてテストを実行するだけで、TestUserGetAPI_OK.jsonTestUserGetAPI_NG_NonExistUser.json という2つの Golden File が生成されます。環境変数無しでテストを実行すればその Golden File をもとにテストを行ってくれます。

このようにすごい簡単に API テストが書けるような仕組みができています。

カバレッジ情報の見える化でテストを書くモチベアップ

人間は数字が変化する様子を目にすることで、よりやる気を感じやすい生き物です。

私たちのチームでは、テストカバレッジの情報を可視化するために「octocov」というツールを導入しました。 octocov は簡単な設定で PR にカバレッジレポートを出してくれるのですごい便利なツールです。*3

詳しくは作者さんが書いているテックブログを見てもらえればと思います。

tech.pepabo.com

API テストの継続的なリファクタで負債と戦う

コードは書いた瞬間から負債になっていきます。これはテストに対しても同じで、当時は良いと思ったテストがあとから見れば負債に見えることはよくあります。負債を残しておくと、開発チームのモチベーションが下がり「汚いテストでもいいか」という気分になってしまいます。*4

そのため、私たちは特に重要度が高い API テストに焦点を当て、細かくリファクタリングを行っています。

一部だけですが例えばこのようなリファクタが行われました。

DB、LocalStack などの外部サービスの起動方法の修正

最初は dockertest を使ってテストするときに毎回起動していましたが、 環境がコンテナ化されたので起動してあるコンテナにアクセスするように修正されました。

zenn.dev

seeder の影響を排除

はじめは migration と seeder をテストの共通処理として実行していましたが seeder の処理をなくしました。 これにより API テストの独立性が明確になりました。

Golden File の更新制御をフラグから環境変数に修正

元々はフラグで管理していました。ただこれだとテストで Golden Test をしてない時にフラグをつけると 「フラグが存在しません」 という理由でテストが落ちるので環境変数に変更しました。

package golden_file

...

// before
var updateGoldenFile = falg.Bool("update-golden-file", false, "update golden file") 


// after
var updateGoldenFile = false

func init() {
    if os.Getenv("UPDATE_GOLDEN_FILE") == "true" {
        updateGoldenFile = true
    }
}

DB 初期化処理の高速化

DB初期化処理をすべてのテーブルを truncate する方法に修正しました。 この方法はとても速く、700 件の API テストが 5分程度で終了します。*5

このように API のテストをいつでもキレイな状態に維持することでテスト文化を維持しています。

チームメンバーとのコミュニケーションと協力

テスト文化の根付けにおいてチーム内のコミュニケーションと協力の促進は一番重要だと思います。 いかに 100 点満点のテスト基盤ができても、チームメンバーにそれを理解して使ってもらえなければ意味が無いです。

なので上で話した「API テストの重要性」や「どういう負債があってそれをどのように解消しようと思っている」ということを逐一 MTG やモブプロで共有をしてテスト文化の共有をしていました。

あんまり書くことは無いですが、すごい大事なことだと思っています。

おわりに

これでテスト文化に対する話を終わろうと思います。

API テストの導入はこの会社に正社員として入って初めての仕事だったので上手く導入できるかすごい心配でしたが、とても成功したのでとても嬉しいです。良いチームメンバーに恵まれたと思っています。

ただ最終的な目的ははビジネスの最大化です、APIテストが製品の信頼性を高めてリスクを軽減することで、ビジネスの成長に繋がったらもっと嬉しいです!

*1:サブテストとか関わってくると一意じゃなくなってきます。同じサブテスト名が無くなるような運用が必要です。

*2:suffix を受け取れるようにすることで、1つのテストから複数回呼び出せるようにしています。

*3:最初計測したときはカバレッジ35%とかですごいびっくりしましたw

*4:俗に言う割れ窓理論

*5:並列化無しで700件が5分で終了

Page top