Database と接続したテストでの課題と改善

2023-11-24

はじめに

こんにちは。Belong Inc. で Backend Engineer を担当している niwa です。
数ヶ月前から一念発起してジムに通い始めましたが、一向に体重が減らないのが最近の悩みです。

私のチームでは、実際に DB からのデータ取得を行う Layer の実装にあたっては、ユニットテストにおいても Docker などで用意した DB と接続するテストが書かれることが多いです。 その中で、DB と接続するテストにおいて課題を感じる場面が何度かありました。 なんとなくこうしたら良いんじゃないか、という構想だけがあったのですが、直近において新規のコードベースを立ち上げる機会があり、その際についでにテストの方式も刷新してみることにしました。 今回はその内容を紹介させていただければと思います。

既存プロダクトのテストにおいての課題

元々私が開発に参画していたプロダクトにおいては、テスト毎に

  • 全テーブルを破棄する
  • DDL の再実行、およびテストケースで共通のデータの挿入
  • 各テストケース内で必要な Additional Data の挿入

というステップを行うことにより、ユニットテストを実現していました。 擬似コードですが、下記のような形をイメージしてもらえれば幸いです。

var conn *database.connection
func setUpDB() {
   conn := getDBConn()
   // delete all tables
   truncateAllTables(conn)
   // setup test data
   setupFixtures(conn)
}

func TestSome_Things(t *testing.T) {
    setUpDB()
    setUpExtraFixtures([]string{"test_data_for_something.sql"})
    ctx := context.Background()
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            ...
        })
    }
}

上記の形でも要件は満たせていはいるのですが、プロジェクトとしては以下の課題を抱えていました。

  • 並列実行を行うと結果が狂う
    • 他のテストによってローカル DB のデータが更新される可能性があるので、直列実行させるしかない
    • go test -p 1 の指定が必須になってしまう。その結果として、CI の稼働時間が伸びてしまう
  • Fixture データの管理が辛い
    • どんなデータが入っているかは SQL を確認しないとわからない
      • ので、同じデータが使えるケースでも見落としやすく、別の SQL が生産されてしまう
    • DB のカラムが変更されたときなど、全テストデータの SQL をメンテナンスする必要があり辛い

上記のような課題感を抱えていた折に新プロダクトが立ち上がることとなり、合わせて上記の問題に対しての対策も盛り込んでしまおう、というモチベーションで動くことになりました。

並列実行の問題と戦う

この問題に関しては、アプローチとして

  • テスト用の DB には Dockertest を採用し、物理的に環境を分けることでクリーンなデータを保つ
  • テスト用の Connection で変更されたデータは, テスト終了時に確実にロールバックされるようにする

の 2 通りの手法が考えられるかと思います。 弊社の別チーム内では先行して Dockertest を試用していたチームもあったためそちらに合わせても良かったのですが、個人的には Spring Framework の @Transaction を使った形 ( https://spring.pleiades.io/spring-framework/reference/testing/testcontext-framework/tx.html#testcontext-tx-rollback-and-commit-behavior ) に親しみがあったため、後者を採用したいと思っていました。

対応方針

@Transaction ライクなテストを実現するためには、SQL の SAVEPOINT 機能を使い、擬似的にネストトランザクションを実現する必要がありました。 SAVEPOINT に関してはもしかしたら馴染みのない方もいるかとは思いますが、機能の概要としては

  • トランザクション内に復元ポイント (セーブポイント) を作成することができる
  • 復元ポイントへのロールバックを行うことで、一部データは保存するが、一部データはロールバックする というようなシナリオを実現できる

という機能になります。標準 SQL の規格として取り入れられているため、多くの DB 製品で利用することが可能です。 Ref: SAVEPOINT (WikiPedia)

SAVEPOINT 機能を活用することで、擬似的にネストしたトランザクションを再現することが可能になります。 イメージとしては下図のような形です。

対応内容について整理すると、下記の形になります。

  • テスト時に取得する DB Connection は、すでに START TRANSACTION されているもの
  • コード内での START TRANSACTION は、SAVEPOINT へ変更する
  • コード内での ROLLBACK は、ROLLBACK TO SAVEPOINT へ変更する
  • DB Connection が Close される際は、合わせて ROLLBACK を行う
    • テスト内では、Commit は行わないようにする

コードベースで考えると、以下のような interface を定義してあげて、実稼働 / テスト時に実装の切り替えができると、上記を差し込めるのでは?という発想です。

interface TxnProvider {
    Begin(context.Context) error
    Commit(context.Context) error
    Rollback(context.Context) error
}

自分で書こうかとも思いましたが、すでに同じような振る舞いをするライブラリが存在していたのでそちらを採用させていただくことにしました。 https://github.com/achiku/pgtxdb

上記ライブラリでは、SQL Driver の層で 上記 Begin / Commit / Rollback の操作を SAVEPOINT での実装に置き換える、ということを行ってくれます。そのため、上記のような Interface を噛ませなくても、テスト時の Driver を切り替えるだけで上記操作を実現することができます。
Ref: https://tech.kanmu.co.jp/entry/2023/06/02/172458

Fixture のデータの管理

SQL での管理が辛い、という反省を踏まえて、こちらはコードベースでの管理を行うように切り替えました。 Functional Option Pattern を適用して、テストケース側で宣言的にテストデータの状態を記述できるようにしています。

簡単な例ではありますが、下記にサンプルの実装を記載しました。

type fixture {
    db *sql.DB
}

func NewFixture(db *sql.DB) fixture {
    return fixture {
        db: db
    }
}

// 作成する端末として iPhone を指定
func WithIPhone(id string) CreateDeviceOption {
    return func(d *Device) {
        d.OS = "iOS"
        d.Model = "iPhone"
        ...
    }
}

// 作成する端末として Pixel を指定
func WithPixel(id string) CreateDeviceOption {
    return func(d *Device) {
        d.OS = "Android"
        d.Model = "Pixel"
        ...
    }
}

func (f fixture) CreateDevice(ctx context.Context, opts ...CreateDeviceOption) {
    var device Device

    for _, opt := range opts {
        opt(device)
    }

    // Insert Device Into DB..
}

使う側としては下記のようなイメージです。

f := fixture.NewFixture(db)

// Pixel かつ 仕入れ済みのデータ
fixture.CreateDevice(ctx,
    fixture.WithPixel("pixel_1"),
    fixture.WithReceived(),
)
// iPhone かつ 出荷済みのデータ
fixture.CreateDevice(ctx,
    fixture.WithIPhone("iphone_1"),
    fixture.WithReceived(),
    fixture.WithShipped("shipment_no"),
)

上記のような形でテストデータを作成することにより、

  • 元の DB の変更に強い
    • ( テストが壊れることを除き ) 一箇所の Fixture のみを直せば良い
  • かつ、テストケース側でどのようなデータを前提としているのかが宣言的である

形を実現できるようになりました。
以前の形だと、例えばテーブルに対し一箇所 NOT NULL の Field を追加した時に、大量の Fixture SQL の中から当該テーブルを参照しているデータを探し、一列一列 Field を指定してあげる必要がありました。この形になると当該の Fixture だけを修正すればよいので、影響範囲がだいぶ少なくなることが期待されます。

まとめ

実際に上記の対応を組み合わせることによって、

  • 並列実行可能なテスト
  • メンテナンス性が高く、かつ宣言的なテストデータ

を得ることができたように思います。 現在取り組んでいるプロダクトもまだ発展途上ではありますので、しばらくはこの形で運用してみて、ブラッシュアップできる点があれば随時改善していく予定です!

最後に、Belong Inc. では我々と一緒にサービスの成長にコミットしてくれるメンバーを募集中です。 興味があるお方、テストに対して熱い意見をお持ちの方など、ぜひ https://entrancebook.belonginc.dev/ をご覧いただけたら幸いです。