以下の内容はhttps://techblog.zozo.com/entry/go-diffdb-testより取得しました。


Goで実装するDBレコード差分検出による副作用テスト

Goで実装するDBレコード差分検出による副作用テスト

はじめに

こんにちは、ECプラットフォーム部の権守です。普段はZOZOTOWNの会員基盤やID基盤の開発に携わっています。

本記事では、会員基盤で導入したデータベースへの書き込みを伴う処理のテスト手法について紹介します。この手法では実行前後のデータベースの差分に注目することで特定のレコードだけでなく、データベース全体への副作用を網羅的に検知することを目的とします。

目次

従来手法の課題

データベースへの書き込みを伴う処理のテストでは、一般的に以下のように関数の返り値と処理対象である特定のレコードを検証することが多いと思います。

// 1. テスト対象の関数を実行
refundedPoints, err := usecase.CancelOrder(ctx, orderID)
AssertEqual(t, nil, err)

// 2. 返還ポイント(返り値)を検証
AssertEqual(t, 500, refundedPoints)

// 3. 特定のレコードの状態を検証
order, _ := orderRepo.FindByID(ctx, orderID)
AssertEqual(t, "CANCELLED", order.Status)

しかし、これらのテストだけではデータベースへの「期待しない副作用」を防げないことに課題を感じていました。例えば、更新や削除の条件指定に誤りがあると想定外のレコードに影響を及ぼすことが考えられます。この場合には、処理対象である特定のレコードのみを検証したとしても、その他のレコードが破壊されていることに気づくことはできません。

差分検証によるアプローチ

この課題を解決するには、データベースへの副作用を網羅的に検証する必要があります。そこで、データベースの実行前後の全レコードをキャプチャして比較し、その差分を検証するアプローチを採用しました。

このアプローチでは、特定のテーブル・レコード・カラムを見るのではなく、データベース全体への副作用をテスト対象の出力の1つとして捉えます。出力の期待値として差分を指定し、期待した副作用のみが存在することを検証することで、期待しない副作用が生じた際にそれを検知できます。

差分は以下の3種類としてそれぞれ抽出します。

  • 作成 (Create):新規レコードのカラム全体の値を保持
  • 更新 (Update):キー情報と、変更があったカラムの「変更後の値」を保持
  • 削除 (Delete):レコードを特定するキー情報を保持

更新の差分の表現としては、更新前後の値を含める方が一般的ですが、本手法ではあえて更新後の値のみを保持しています。テストという観点では、更新前の値は事前条件の一部であり、テストデータのセットアップ内容と重複するためです。

Goによる差分検出ツールの実装

利用イメージ

会員基盤はGoで実装されているため、テストへの組み込みやすさを考慮して今回はGoのコード上で実装しました。

以下に、どのように差分をGoの構造体で表現し、利用するかのイメージを示します。

// 差分データの構造イメージ
type Diff struct {
    C []Record           // CREATE: 追加されたレコード群を指定。各レコードは全フィールド値を指定
    U map[KeyHash]Record // UPDATE: 更新のあるレコード群を主キーのハッシュ値で指定。各レコードは更新後のフィールド値を指定
    D []Record           // DELETE: 削除されたレコード群を指定。各レコードは主キー値を指定(主キーが存在しない場合は全フィールド値)
}

type Diffs map[string]Diff // mapのキーはテーブル名

// 挿入時の差分検出の利用イメージ
var result int
diffs := DiffDB(ctx, db, func() {
    result = insertMember(member)
})

AssertEqual(t, 1, result)
AssertRecords(t, Diffs{
    "members": {
        C: []Record{
            {
                "id": 1,
                "age": 20,
                "nickname": "taro",
            },
        },
    },
}, diffs)

// 更新時の差分検出の利用イメージ
var result int
diffs = DiffDB(ctx, db, func() {
    result = updateMemberName(1, "jiro")
})
AssertEqual(t, 1, result)
AssertRecords(t, Diffs{
    "members": {
        U: map[KeyHash]Record{
            HashKey({"id": 1}): {
                "nickname": "jiro",
            },
        },
    },
}, diffs)

// 削除時の差分検出の利用イメージ
var result int
diffs = DiffDB(ctx, db, func() {
    result = deleteMember(1)
})
AssertEqual(t, 1, result)
AssertRecords(t, Diffs{
    "members": {
        D: []Record{
            {
                "id": 1,
            },
        },
    },
}, diffs)

このようにデータベースに対する副作用を出力値として検証できるため、「レコードを取得して特定のカラムを検証する」という命令的な記述を繰り返す必要がなくなり、テストコードの可読性と保守性が向上します。

具体的には、Goで広く採用されているテーブル駆動テストのスタイルと親和性が高く、複数のテストケースを簡潔に記述できます。例えば、条件分岐で書き込むテーブルが変わる関数をテストする場合、従来の手法では、テストケースによって検証処理も分岐するか、テストケースの構造体に検証処理を持つ必要がありました。検証処理の分岐はテストコードの複雑化を招き、テストケースの構造体に検証処理を持たせることはテーブル駆動テストのメリットである宣言的な記述を損ないます。

しかし、今回導入した手法であれば宣言的な記述を維持できます。以下にそれぞれの手法の例を示します。

// 従来手法その1(検証処理の条件分岐)
tests := []struct {
    name             string
    orderID          string
    expectRefund     bool // 返金テーブルを確認するかどうかのフラグ
    expectPointReset bool // ポイント更新を確認するかどうかのフラグ
}{
    {
        name:             "クレジットカード決済のキャンセル(返金あり)",
        orderID:          "order_card",
        expectRefund:     true,
        expectPointReset: false,
    },
    {
        name:             "全額ポイント払いのキャンセル(ポイント還元あり)",
        orderID:          "order_point",
        expectRefund:     false,
        expectPointReset: true,
    },
}

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        // 1. テスト対象の実行
        err := usecase.CancelOrder(ctx, tt.orderID)
        AssertEqual(t, nil, err)

        // 2. 注文ステータスの検証(共通)
        order, err := orderRepo.FindByID(ctx, tt.orderID)
        AssertEqual(t, nil, err)
        AssertEqual(t, "CANCELLED", order.Status)

        // 3. 条件分岐による個別テーブルのアサーション
        // テストケースが増えるたびに、この分岐ロジックのメンテナンスが必要になる
        if tt.expectRefund {
            refund, err := refundRepo.FindByOrderID(ctx, tt.orderID)
            AssertEqual(t, nil, err)
            AssertEqual(t, Refund{OrderID: tt.orderID, amount: 1000}, refund)
        }

        if tt.expectPointReset {
            user, err := userRepo.FindByID(ctx, order.UserID)
            AssertEqual(t, nil, err)
            AssertEqual(t, 1500, user.Points)
        }
    })
}

// 従来手法その2(テストケースごとに検証処理を持つ)
tests := []struct {
    name         string
    orderID      string
    assertFunc   func(t *testing.T, orderID string)
}{
    {
        name:    "クレジットカード決済のキャンセル(返金あり)",
        orderID: "order_card",
        assertFunc: func(t *testing.T, orderID string) {
            // 注文ステータスの検証
            order, err := orderRepo.FindByID(ctx, orderID)
            AssertEqual(t, nil, err)
            AssertEqual(t, "CANCELLED", order.Status)
            // 返金テーブルの検証
            refund, err := refundRepo.FindByOrderID(ctx, orderID)
            AssertEqual(t, nil, err)
            AssertEqual(t, Refund{OrderID: orderID, amount: 1000}, refund)
        },
    },
    {
        name:    "全額ポイント払いのキャンセル(ポイント還元あり)",
        orderID: "order_point",
        assertFunc: func(t *testing.T, orderID string) {
            // 注文ステータスの検証
            order, err := orderRepo.FindByID(ctx, orderID)
            AssertEqual(t, nil, err)
            AssertEqual(t, "CANCELLED", order.Status)
            // ユーザーポイントの検証
            user, err := userRepo.FindByID(ctx, order.UserID)
            AssertEqual(t, nil, err)
            AssertEqual(t, 1500, user.Points)
        },
    },
}

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        // テスト対象の実行
        err := usecase.CancelOrder(ctx, tt.orderID)
        AssertEqual(t, nil, err)

        // テストケース固有の検証処理を実行
        tt.assertFunc(t, tt.orderID)
    })
}

// 差分検出を用いた手法
tests := []struct {
    name         string
    orderID      string
    expectedDiff Diffs
}{
    {
        name:    "クレジットカード決済のキャンセル(返金あり)",
        orderID: "order_card",
        expectedDiff: Diffs{
            "orders": {
                U: map[KeyHash]Record{
                    HashKey({"id": "order_card"}): {
                        "status": "CANCELLED",
                    },
                },
            },
            "refunds": {
                C: []Record{
                    {
                        "order_id": "order_card",
                        "amount":   1000,
                    },
                },
            },
        },
    },
    {
        name:    "全額ポイント払いのキャンセル(ポイント還元あり)",
        orderID: "order_point",
        expectedDiff: Diffs{
            "orders": {
                U: map[KeyHash]Record{
                    HashKey({"id": "order_point"}): {
                        "status": "CANCELLED",
                    },
                },
            },
            "users": {
                U: map[KeyHash]Record{
                    HashKey({"id": "user_1"}): {
                        "points": 1500,
                    },
                },
            },
        },
    },
}

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        // 全テーブルの差分をキャプチャしつつ実行
        var err error
        diffs := DiffDB(t.Context(), db, func() {
            err = usecase.CancelOrder(ctx, tt.orderID)
        })

        // 返り値の検証
        AssertEqual(t, nil, err)
        // 差分の検証
        AssertRecords(t, tt.expectedDiff, diffs)
    })
}

差分抽出の実装

差分抽出は、以下の手順で行います。

  1. 全テーブルの主キー情報を含むスキーマ情報を取得
  2. テスト対象の関数実行前に、対象データベースの全テーブルの全レコードを取得し、メモリ上に保存
  3. テスト対象の関数を実行
  4. 関数実行後に、再度全テーブルの全レコードを取得
  5. 実行前後のレコードを比較し、差分を抽出

スキーマ情報の取得や全レコードの取得は、データベースの種類に依存するため、DBSourceインタフェースを定義し、各データベースに応じた実装を用意しました。
ここでは、スキーマ情報と全レコードの取得の実装については割愛し、差分抽出のコアロジックを示します。

func createDiff(source DBSource, before, after map[string]map[KeyHash]Record) Diffs {
    diffs := Diffs{}

    // 各テーブルごとに差分を抽出
    for tableName := range before {
        diff := Diff{U: map[KeyHash]Record{}}
        // 各テーブルのスキーマ情報を取得
        schema := source.schemata()[tableName]

        // レコードごとに差分を比較
        // keyは各レコードの主キーのハッシュ値
        for key, record := range before[tableName] {
            // 実行前にあったレコードが実行後にも存在する場合
            if afterRecord, ok := after[tableName][key]; ok {
                updates := Record{}
                for k, v := range record {
                    // 値が異なるカラムのみを抽出
                    if !reflect.DeepEqual(v, afterRecord[k]) {
                        updates[k] = afterRecord[k]
                    }
                }
                // 更新があった場合のみdiffに追加
                if len(updates) != 0 {
                    diff.U[key] = updates
                }

                // Createを抽出するために、afterから既存レコードを削除
                delete(after[tableName], key)
                continue
            }
            keyValues := map[string]any{}
            for _, key := range schema.keys {
                keyValues[key] = record[key]
            }
            diff.D = append(diff.D, keyValues)
        }

        // 残ったafterのレコードは新規作成されたレコード
        for _, record := range after[tableName] {
            diff.C = append(diff.C, record)
        }

        // 期待値を書く際に差分がない場合は省略可能にするため、空スライスはnilに変換
        if len(diff.U) == 0 {
            diff.U = nil
        }

        // テーブルに何らかの差分があった場合のみdiffsに追加
        if len(diff.C) != 0 || len(diff.U) != 0 || len(diff.D) != 0 {
            diffs[tableName] = diff
        }
    }

    // 期待値を書く際に差分がない場合は省略可能にするため、空のDiffsはnilに変換
    if len(diffs) == 0 {
        return nil
    }
    return diffs
}

複数データベースへの対応

ZOZOTOWNではリプレイスを進めるにあたり、一時的に既存環境と新環境それぞれのデータベースに書き込むケースが存在します。それぞれで期待した差分があるかを検証できるように複数データベースにも対応しました。DiffExtractorにラベルを付けて複数設定することで、差分出力時にそれぞれどのデータベースで生じた差分かを判定できます。

// 複数データベースに対応した実装
type DiffExtractor struct {
    // 複数のデータベースを抽出対象とする
    // mapのキーはデータベースを特定するためのラベル
    sources map[string]DBSource
}

func NewDiffExtractor(sources map[string]DBSource) DiffExtractor {
    return DiffExtractor{sources: sources}
}

func (de DiffExtractor) Diff(ctx context.Context, f func()) map[string]Diffs {
    diffs := map[string]Diffs{}
    before := map[string]map[string]map[KeyHash]Record{}
    for name, source := range de.sources {
        // テスト対象実行前の各データベースをキャプチャ
        before[name] = source.dump(ctx)
    }
    f()
    after := map[string]map[string]map[KeyHash]Record{}
    for name, source := range de.sources {
        // テスト対象実行後の各データベースをキャプチャ
        after[name] = source.dump(ctx)

        // 各データベースの差分抽出
        diffs[name] = createDiff(source, before[name], after[name])
    }
    return diffs
}
// 複数データベースで利用する場合のテストヘルパー例
func DiffDBForDoubleWrite(ctx context.Context, mysqlDB *sql.DB, mssqlDB *sql.DB, f func()) map[string]Diffs {
    mysqlSource := dd.NewMySQLSource(ctx, mysqlDB)
    mssqlSource := dd.NewMSSQLSource(ctx, mssqlDB)

    extractor := dd.NewDiffExtractor(map[string]dd.DBSource{
        "mysql": mysqlSource,
        "mssql": mssqlSource,
    })
    return extractor.Diff(ctx, func() { f() })
}

導入時の工夫点

差分検出を導入するにあたり、テストの安定性を保つためにいくつか工夫しました。

非固定値の取り扱い

本手法では、特定のカラムだけでなくレコード全体を対象とするため、自動採番されたIDや現在時刻、乱数など実行のたびに値が変わるカラムについても常に考慮する必要があります。

IDの自動採番については、各テストケースの実行前にオートインクリメントなどのシーケンスをリセットするために、TRUNCATE文を実行することで対応しました。これにより、発行されるIDを固定し、期待値を固定できます。

// テスト実行前のセットアップ例
func SetupTestDB(t *testing.T, db *sql.DB) {
    t.Helper()

    // ユーザー定義された全テーブル名の取得
    tables := GetTableNames(db)

    for _, table := range tables {
        _, err := db.Exec(fmt.Sprintf("TRUNCATE TABLE %s", table))
        if err != nil {
            t.Fatal(err)
        }
    }
}

実際にTRUNCATE文を実行するには外部キーの制約チェックを一時的に解除する、もしくはテーブルの処理順序を制御するといったことも必要になります。

現在時刻については、関数内でtime.Now()を使わず、時刻を引数として渡すか、インタフェースを介して注入することでテスト内の時刻を固定しています。これにより、時刻に関する期待値も固定できます。

乱数については、乱数生成の箇所をインタフェース化して期待値を固定する方法などが考えられますが、それが難しい場合も考慮して、アサーション関数において値一致以外も可能にしました。具体的には文字列に対する期待値に*regexp.Regexpを指定した場合には正規表現マッチを行うようにしました。

// 乱数を含むフィールドの検証例
expectedDiffs := Diffs{
    "orders": {
        C: []Record{
            {
                "order_id": regexp.MustCompile(`\A[0-9a-f]{32}\z`), // 乱数を正規表現で表現
                "amount":   10,
                "order_at": "2026-01-01T00:00:00Z",
            },
        },
    },
}
AssertRecords(t, expectedDiffs, actualDiffs)

現状は使うケースがなかったため用意していませんが、数値型の乱数を利用する場合にはそれぞれ専用の型を用意して、検証処理を切り替えることも検討しています。

期待値の正規化

会員基盤ではテストデータのセットアップにレコードデータではなくモデルデータを利用しているため、データベースから抽出した差分の値とテストケースの期待値とでは形式が異なることもあります。例えば、モデルデータではbool型のフィールドが、データベースからの出力時はint型の0もしくは1になるケースがあります。他にもモデルデータでは値オブジェクトとして定義されているフィールドが、データベースからの出力時はその値オブジェクトの内部の値になるケースもあります。

このような場合に、テストケースの期待値をデータベースからの出力に合わせた形式で記述するのは、テストケースの可読性を損なうため、アサーション関数内で比較時に正規化する方針としました。実装の詳細は割愛しますが、リフレクションを用いてreflect.ValueOf関数でreflect.Valueに変換した後、Kind()メソッドで元となる型を判定して正規化を行っています。

差分の除外

データベースのトリガー処理による時刻の挿入などアプリケーション側から制御できない値や、もうアプリケーション上から利用していないカラムのような例外的に差分から除外したいケースが存在します。そこで、抽出した差分からカラムを指定して除外するためのIgnore()メソッドを用意しました。また、用意されていない方法で特定のカラムを検証するために一旦、差分から取り除いた上で別途検証するという場合にも利用できます。

diffs := DiffDB(ctx, db, func() {
    someFunc()
})
// hogeは廃止済みのカラムで期待値の管理対象外とする
AssertRecords(t, expectedDiffs, diffs.Ignore("hoge"))

また、特定のテストケースによらず、アプリケーション全体で除外したい条件があるような場合に対応するため、DiffExtractorに除外用の関数をオプションで設定できるようにしました。

var someIgnoreColumnFunc = func(tableName, columnName string) bool {
    // 例えば、全テーブルのhogeカラムを常に除外する場合
    return columnName == "hoge"
}

// 除外関数をオプションに設定したテストヘルパー例
func DiffDB(ctx context.Context, db *sql.DB, f func()) dd.Diffs {
    source := dd.NewMySQLSource(ctx, db)
    source.WithIgnoreColumnFunc(someIgnoreColumnFunc)
    extractor := dd.NewDiffExtractor(map[string]dd.DBSource{
        "mysql": source,
    })
    diffs := extractor.Diff(ctx, func() { f() })
    return diffs["mysql"]
}

まとめ

本記事では、Goを用いたデータベースのレコード差分検出によるテスト手法について紹介しました。

複雑なテストケースになるほど、データベースへの副作用を網羅的に検証することの重要性が増します。本手法を導入することで、期待しない副作用を検知しやすくなり、テストコードの可読性と保守性も向上しました。今後も、より良いテスト手法の模索と改善を続けていきたいと思います。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

hrmos.co




以上の内容はhttps://techblog.zozo.com/entry/go-diffdb-testより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14