つきなみGo

Spanner Emulatorを用いたGoのテスト実装

Cloud Spanner (以下、Spannerと呼ぶ) とは、Google Cloud Platformで利用可能なフルマネージドデータベースです。高い可用性がありながらトランザクションを自動的に処理できるため、弊社(メルカリ)でも幅広く活用されています。またその中で、サーバーのテストにSpannerの処理を組み込みたい場面が多々存在します。

以前までは、テスト用のSpannerインスタンスを用意しておいて、テストに利用するのが一般的でした。しかし、運用コストや手軽さを加味すると、ローカル上で動かせるエミュレータのほうが扱いやすいかもしれません。

Cloud Spanner Emulator(以下、Spanner Emulatorと呼ぶ)は、2020年4月にbeta版がリリースされました。バイナリからだけでなく、Google Cloud SDKやDockerを用いてエミュレータを立ち上げられるため、導入コストも削減できます。

本記事では、実際にSpanner Emulatorを利用したGoのテストについて、サンプルコードを付随しながら解説していきますサンプルコードのリポジトリ⁠。

Spanner Emulatorの導入

まず、Spanner Emulatorの環境を準備するところから簡単に紹介します。Spanner Emulatorを起動する方法はいくつか存在します[1]

本記事では、例としてDockerを用いてSpanner Emulatorを起動します。Spanner Emulatorのイメージは、gcr.io/cloud-spanner-emulator/emulatorで公開されており、こちらを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_EMULATOR_HOSTを指定することで、Spanner Emulatorにアクセスすることができます。

GoのテストでSpanner Emulatorを使う

本記事でご紹介するデータベースのライフサイクルは、次の図のようになります(1つのパッケージ内でのライフサイクルを表しています⁠⁠。

図1

GoではTestMainを用いることで、パッケージ内でのテストの共通した前処理や後処理を記述できます。Spanner Emulatorを用いる上でパッケージ内で共通した処理はInstanceを作成することなので、図のようにTestMainにおいてInstanceを作成し、各テスト関数は同一のInstance上にデータベースを作成します。

/sample/testmain_test.go#L11
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())
}

/testutil/spanner.go
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としてデータベース名とします。

test_client.go#L24
func getDatabaseName(suffix string) string {
		return fmt.Sprintf("db_%x", suffix)
}

テスト関数の冒頭では、データベース作成と同時にテストに必要なテーブルを作成します。ここでは、事前にテーブルのスキーマファイルを用意しておき、データベース作成時にスキーマファイルを読み込んでテーブルを作成しています。

/testutil/test_client.go#L55
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.T).Runを用いてサブテストを実装し、サブテスト内で各テストケースを検証します。サンプルコードでも、Table Drivenな形でテストを実装しています。

/sample/sample_test.go#L33
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.T).Parallelを用いることでテストを並列で実行し、テスト実行速度を高速化させることがあります。しかし、サンプルコードではテスト関数冒頭で作成したテーブルをサブテスト間で使い回す形になっているので、サブテスト間でデータ競合が起こってしまう可能性があります。そのため、サブテスト終了と同時にテーブル内のデータをリセットする必要があります。ここでは(*testing.T).Cleanupを用いることで、呼び出し元のテスト(サブテストを含む)に対する後処理を実装できます。

/sample/sample_test.go#L70
t.Cleanup(func() {
	client.TruncateTables(“Users”)
})

補足Spannerでは1個のインスタンスにつき100個のデータベースまでしか作成できないため、サブテスト毎にデータベースを作成するような設計は非現実的です(参照:Spannerのドキュメント⁠。

テスト関数内のサブテストが終了した後、そのテスト関数内で使用していたデータベースを消去します。これも、事前に(*testing.T).Cleanupを用いて後処理を登録しておくことで、実装が可能です。他にも、Spanner Clientを閉じる処理について後処理に加えておきます。

/sample/sample_test.go#L23
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からでもアクセスできるため、非常に簡単に利用することが可能です。

/.github/workflows/test.yaml
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 EmulatorPubSub Emulator (beta)など、多くのEmulatorが利用可能となっています(利用可能なEmulatorはgcloud emulators –helpもしくはgcloud beta emulators –helpで確認できます⁠⁠。興味のある方は、ぜひ複数のEmulatorを使ったテスト環境の構築方法などを考えてみてください。

おすすめ記事

記事・ニュース一覧