Cloud Spanner (以下、Spannerと呼ぶ) とは、Google Cloud Platformで利用可能なフルマネージドデータベースです。高い可用性がありながらトランザクションを自動的に処理できるため、弊社
以前までは、テスト用のSpannerインスタンスを用意しておいて、テストに利用するのが一般的でした。しかし、運用コストや手軽さを加味すると、ローカル上で動かせるエミュレータのほうが扱いやすいかもしれません。
Cloud Spanner Emulator
本記事では、実際にSpanner Emulatorを利用したGoのテストについて、サンプルコードを付随しながら解説していきます
Spanner Emulatorの導入
まず、Spanner Emulatorの環境を準備するところから簡単に紹介します。Spanner Emulatorを起動する方法はいくつか存在します[1]。
- devcontainer
- Google Cloud SDK
- Docker
- Linux binary
- Bazel
本記事では、例としてDockerを用いてSpanner Emulatorを起動します。Spanner Emulatorのイメージは、gcr.
で公開されており、こちらをpullすることで簡単に起動できます。
docker run -p 9010:9010 -p 9020:9020 gcr.io/cloud-spanner-emulator/emulator:latest
GoからSpanner Emulatorを呼び出す際は、基本的にcloud上にあるSpannerにアクセスするときと同じで、Google Cloud Client Libraryを使用します。cloudと異なる点として、環境変数でSPANNER_
を指定することで、Spanner Emulatorにアクセスすることができます。
GoのテストでSpanner Emulatorを使う
本記事でご紹介するデータベースのライフサイクルは、次の図のようになります

GoではTestMainを用いることで、パッケージ内でのテストの共通した前処理や後処理を記述できます。Spanner Emulatorを用いる上でパッケージ内で共通した処理はInstanceを作成することなので、図のようにTestMainにおいてInstanceを作成し、各テスト関数は同一のInstance上にデータベースを作成します。
func TestMain(m *testing.M) {
if err := testutil.SetupInstance(); err != nil {
fmt.Fprintf(os.Stderr, “failed to setup instance: %v”, err)
os.Exit(1)
}
os.Exit(m.Run())
}
func SetupInstance() error {
if v := os.Getenv("SPANNER_EMULATOR_HOST"); v == "" {
return fmt.Errorf("EnvSpannerEmulatorHost is not set")
}
ctx := context.Background()
client, err := instanceadmin.NewInstanceAdminClient(ctx)
if err != nil {
return fmt.Errorf("failed to create instance client: %w", err)
}
defer client.Close()
op, err := client.CreateInstance(ctx, &instanceadminpb.CreateInstanceRequest{
Parent: fmt.Sprintf("projects/%s", testProjectName),
InstanceId: testInstanceName,
})
if err != nil {
if status.Code(err) == codes.AlreadyExists {
return nil
}
return fmt.Errorf("failed to create instance: %w", err)
}
if _, err := op.Wait(ctx); err != nil {
return fmt.Errorf("failed to wait operation: %w", err)
}
return nil
}
ここで注意点として、インスタンスが既に存在する場合は、エラーハンドリングをスキップしていることです。ローカルで実行する場合は、Spanner Emulatorのコンテナを常時起動する形になるため、テストを複数回実行したときに同じインスタンスを使い回すようにしています。
作成したインスタンスにデータベースを作成するとき、インスタンス内に同一名のデータベースを作成できないため、各テスト関数ではそれぞれ固有名でデータベースを作成しなければいけません。データベース作成は各テスト関数ごとで行われ、さらにGoの同一パッケージ内ではテスト関数名が重複することはないので、テスト関数名をSuffixとしてデータベース名とします。
func getDatabaseName(suffix string) string {
return fmt.Sprintf("db_%x", suffix)
}
テスト関数の冒頭では、データベース作成と同時にテストに必要なテーブルを作成します。ここでは、事前にテーブルのスキーマファイルを用意しておき、データベース作成時にスキーマファイルを読み込んでテーブルを作成しています。
func (c *Client) CreateDatabase(schemaPath string) error {
statements, err := c.parseSchemaToStatements(schemaPath)
if err != nil {
return fmt.Errorf("failed to parse schema file: %w", err)
}
ctx := context.Background()
op, err := c.databaseClient.CreateDatabase(ctx, &databaseadminpb.CreateDatabaseRequest{
Parent: fmt.Sprintf("projects/%s/instances/%s", testProjectName, testInstanceName),
CreateStatement: fmt.Sprintf("CREATE DATABASE `%s`", c.databaseName),
ExtraStatements: statements,
})
if err != nil {
return fmt.Errorf("failed to create database: %w", err)
}
if _, err := op.Wait(ctx); err != nil {
return fmt.Errorf("failed to wait operation: %w", err)
}
return nil
}
Goで複数のテストケースをテストする際、Table Drivenなテストがよく用いられます。この時、(*testing.
を用いてサブテストを実装し、サブテスト内で各テストケースを検証します。サンプルコードでも、Table Drivenな形でテストを実装しています。
for name, tt := range map[string]struct {
userData []User
stmt spanner.Statement
wantUsers []User
}{
"sample1-1": {
userData: []User{
{ID: 0, Name: "Taro", Age: 25},
{ID: 1, Name: "Jiro", Age: 41},
{ID: 2, Name: "Hanako", Age: 28},
},
stmt: spanner.Statement{SQL: "SELECT ID, Name, Age FROM Users ORDER BY ID"},
wantUsers: []User{
{ID: 0, Name: "Taro", Age: 25},
{ID: 1, Name: "Jiro", Age: 41},
{ID: 2, Name: "Hanako", Age: 28},
},
},
// APPEND OTHER TEST CASES
}{
// TEST CODES
}
ここで注意点として、サブテストは並列化しないようにしています。しばしば、(*testing.
を用いることでテストを並列で実行し、テスト実行速度を高速化させることがあります。しかし、サンプルコードではテスト関数冒頭で作成したテーブルをサブテスト間で使い回す形になっているので、サブテスト間でデータ競合が起こってしまう可能性があります。そのため、サブテスト終了と同時にテーブル内のデータをリセットする必要があります。ここでは(*testing.
を用いることで、呼び出し元のテスト
t.Cleanup(func() {
client.TruncateTables(“Users”)
})
補足:Spannerでは1個のインスタンスにつき100個のデータベースまでしか作成できないため、サブテスト毎にデータベースを作成するような設計は非現実的です
テスト関数内のサブテストが終了した後、そのテスト関数内で使用していたデータベースを消去します。これも、事前に(*testing.
を用いて後処理を登録しておくことで、実装が可能です。他にも、Spanner Clientを閉じる処理について後処理に加えておきます。
t.Cleanup(func() {
client.DropDatabase(t.Name())
client.Close()
})
Spanner EmulatorをCIで利用する
ここでは、GitHub Actionsで実行する例を紹介します。GitHub Actionsでは、Service Containerという機能を用いることで、CI上でも簡単にSpanner Emulatorを構築できます。
Service Containerは、DockerHubやGoogle Cloud Registryなどにアップロードされているイメージからコンテナを起動することができ、同じjob内であればどのstepからでもアクセスできるため、非常に簡単に利用することが可能です。
name: Go Spanner Emulator Sample Test
on:
push:
branches:
- main
jobs:
test:
name: Sample Go Test
runs-on: ubuntu-latest
container:
image: golang:1.19
services:
spanner-emulator:
image: gcr.io/cloud-spanner-emulator/emulator:latest
ports:
- 9010:9010
- 9020:9020
steps:
- name: checkout
uses: actions/checkout@v3
- name: setup go
run: go mod download
- name: run test
env:
SPANNER_EMULATOR_HOST: spanner-emulator:9010
run: go test ./...
おわりに
本記事では、GoのテストでSpanner Emulatorを活用する例をご紹介しました。ここでは、簡単なテストケースをもとにテストコードを実装しましたが、より複雑なシステムなどでは違った構成でテストコードを実装した方が良い、ということも十分あり得るでしょう。
また、GCPにおいては、Spanner Emulatorに限らずFirestore EmulatorやPubSub Emulator (beta)など、多くのEmulatorが利用可能となっていますgcloud emulators –help
もしくはgcloud beta emulators –help
で確認できます)。興味のある方は、ぜひ複数のEmulatorを使ったテスト環境の構築方法などを考えてみてください。