👾

Spanner のレコードをYAML で検証できるGo製 CLIツールを作った際の学び

に公開

背景

Spanner のデータ検証を楽にしたいなと思い、YAML で期待値を記述してレコードが期待値を満たしているか確認できる CLI ツールを作ってみました。

動作例

まず、テーブルごとに、複数行・複数カラムの期待値を YAML で記述します。

tables:
  Users:
    columns:
      - UserID: "user-001"
        Name: "Alice Johnson"
        Email: "alice@example.com"
        Status: 1
        CreatedAt: "2024-01-01T00:00:00Z"
  Products:
    columns:
      - ProductID: "prod-001"
        Name: "Laptop Computer"
        Price: 150000
        IsActive: true
        CategoryID: "cat-electronics"
        CreatedAt: "2024-01-01T00:00:00Z"
  Books:
    columns:
      - BookID: "book-001"
        Title: "The Great Gatsby"
        Author: "F. Scott Fitzgerald"
        PublishedYear: 1925
        JSONData: '{"genre": "Fiction", "rating": 4.5}'

その後、以下のコマンドを実行します。

spalidate --project <your-project> --instance <your-instance>  --database <your-database>  ./validation.yaml

もし期待値と一致しない場合は、以下のようなログが出力されます。

 2025/09/13 19:09:25 ERRO ✖️ table Books: expected row does not match
                column mismatch: 1

              1)  column: JSONData
                 ▸ expected: {"genre":"Fiction","rating":4.5}
                 ▸   actual: {"genre": "invalid", "rating": 4.5}

            2025/09/13 19:09:25 ERRO ✖️ table Products: expected row does not match
                column mismatch: 1

              1)  column: CategoryID
                 ▸ expected: cat-electronics
                 ▸   actual: cat-invalid

            2025/09/13 19:09:25 ERRO ✖️ table Users: expected row does not match
                column mismatch: 2

              1)  column: Email
                 ▸ expected: alice@example.com
                 ▸   actual: alice@invalid.com

              2)  column: Status
                 ▸ expected: 1
                 ▸   actual: 9999

CLIツールについて

処理自体は簡単です。コマンドライン引数で与えられる Project/Instance/Database を使って Spanner に接続します。その後、Spanner Client を使ってデータを取得し、go の reflect を使って期待値と比較しています。

https://github.com/nu0ma/spalidate

Spanner Emulator+統合テスト

このツールをテストする場合、並列にテストを行いたければ、テストケースごとにデータベースをそれぞれ用意する必要があります。そうしないと、テストケースでデータを入力した場合に他のテストが落ちてしまうのと、テスト実行時間がすぐ長くなってしまいます。

Spanner Emulator を使用したテストはどうするのがよいかなと色々眺めていると、公式レポジトリに以下の内容が記述されていました。

What is the recommended test setup?
Use a single emulator process and create a Cloud Spanner instance within it. Since creating databases is cheap in the emulator, we recommend that each test bring up and tear down its own database. This ensures hermetic testing and allows the test suite to run tests in parallel if needed.

そんなこと言っても、どうやってテストケースごとにセットアップ + tear down するのがよいのか...?、データベース名をどうにかテストケース毎に切り替えれるようにしないといけないしな...と思っていると、以下の素晴らしい情報に出会いました。

https://zenn.dev/apstndb/articles/go-integration-test-with-spanner-emulator#私のソリューション%3A-spanemuboost

spanemuboost を使うと、インスタンスとデータベースは自動的に作成され、設定済みの Spanner クライアントをすぐに使うことができます。

また、テストケースごとに異なるデータベースを使う推奨構成にも対応しています。
コンテナの起動時にはインスタンスのみをセットアップし、ランダムな名前のデータベースとそれに対するクライアントをテストケースごとに生成することが下記のように手軽に行えます。

spanemuboost というライブラリを使用すれば、t.Parallel()を付与しつつテストが書けるということがわかりました。実際に試してみると、database名はWithRandomDatabaseIDcm903okki1pr2k18ssqzvslltqq9kcのような文字列を生成してくれるので、独立したデータベースが作成できることが確認できました。

今回はデータベース名を指定するライブラリのため、spanemuboost.NewClients が返すDatabaseIDを引数に渡せば、実現したい独立したemulatorを使用したテストが可能になります。

// integration_test.go(抜粋)
t.Run("TestWithExistingValidationFile", func(t *testing.T) {
	t.Parallel()
	clients, clientsTeardown, err := spanemuboost.NewClients(ctx, emulator,
		spanemuboost.EnableDatabaseAutoConfigOnly(),
		spanemuboost.WithRandomDatabaseID(),
		spanemuboost.WithSetupDDLs(ddls),
	)
	if err != nil {
		t.Fatal(err)
	}
	defer clientsTeardown()

	if err := initializeTestData(ctx, clients.Client); err != nil {
		t.Fatal(err)
	}

	output, err := runSpalidateWithFile("test_validation.yaml", true, clients.ProjectID, clients.InstanceID, clients.DatabaseID) // DatabaseIDにランダムな値が入るので独立してテストができる
	if err != nil {
		t.Fatalf("Validation failed: %v\nOutput: %s", err, output)
	}

	if !strings.Contains(output, "Validation passed for all tables") {
		t.Errorf("Expected success message, got: %s", output)
	}
})

まとめ

Spanner のレコードを自動で検証できるCLIツールを作ってみました。正直 Select 文で検証してもよいのですが、宣言的に期待値を保存しておきたいのもあり、今回は yaml を使用するようにしました。spanemuboostの恩恵を受け、データベースを使用しながらも並列なテストを実行できています。

まだ Spanner のいくつかの型には対応していないので、実際のユースケースと相談しながら対応していきたいと思います。

参考

https://zenn.dev/apstndb/articles/go-integration-test-with-spanner-emulator
https://github.com/GoogleCloudPlatform/cloud-spanner-emulator/blob/master/README.md#what-is-the-recommended-test-setup
https://github.com/apstndb/spanemuboost

Discussion