【Go】testcontainers-go で MySQL 関連のテストを書く

## 前置き

MySQL を扱うコードをテストする場合、いくつかの方法があると思います。

1. 別プロセスで MySQL サーバを建てておく
2. mock を利用する
3. in-memory な MySQL 実装を提供するライブラリを利用する
4. テストプロセス内でテスト用の MySQL コンテナを建てる

1 はテストを実行するプロセスの外部で予めテスト用の MySQL を実行しておく方法です。
この方法はかなりお手軽ですが、テスト全体で 1 つのデータベースを共有することになる点がデメリットです。
MySQL を利用するテストが複数ある場合、互いに影響しないようにテスト毎にデータを削除する処理が必要になります。
また、テストの内容によっては複数のテストを並列実行することができません。

2 は MySQL を扱う部分を interface で抽象化し、その interface を実装する mock を利用してテストをする方法です。
Go だけでテストが完結し、軽量な mock 実装を利用することで高速なテストを実現できる点が魅力です。
一方で実際にクエリが実行されるわけではないため、クエリが期待通りに動作するかどうかをテストすることはできません。

似たようなところでは [go-sqlmock](https://github.com/DATA-DOG/go-sqlmock) というライブラリを利用するという方法もあります。

https://github.com/DATA-DOG/go-sqlmock

こちらは [database/sql/driver](https://pkg.go.dev/database/sql/driver) の mock 実装を提供するライブラリです。
mock に対して実行されることを期待するクエリやその戻り値を設定しておくことで、実際に実行されるクエリと一致するかどうかをテストすることができます。
こちらも実際にクエリが実行されるわけではないため、クエリの動作そのものをテストすることはできません。

3 は MySQL の動作を模した [database/sql/driver](https://pkg.go.dev/database/sql/driver) 実装を提供するライブラリを利用するというものです。
2 と同様に Go だけでテストが完結し、高速なテストを期待することができる上に、クエリの動作もテストすることができる手法です。

このようなライブラリとしては [go-mysql-server](https://github.com/dolthub/go-mysql-server) や [ramsql](https://github.com/proullon/ramsql) が挙げられます。

https://github.com/dolthub/go-mysql-server

https://github.com/proullon/ramsql

`go-mysql-server` は様々なデータソースに対して MySQL 互換のインタフェースを提供するプロダクトです。
[メモリによる MySQL 実装](https://pkg.go.dev/github.com/dolthub/go-mysql-server/memory) を提供しているため、それをテストに利用することができます。
[driver](https://pkg.go.dev/github.com/dolthub/go-mysql-server/driver) に [database/sql/driver](https://pkg.go.dev/database/sql/driver) の実装が含まれているのですが、ドキュメントがほぼ提供されていない状態なので利用は難しいと言えます。
また、[v0.12.0](https://github.com/dolthub/go-mysql-server/releases/tag/v0.12.0) 時点で [example](https://github.com/dolthub/go-mysql-server/blob/main/_example/main.go) が panic になって動作しないなど、明らかにバグがある点も気になります。

`ramsql` は単体テストを実行することを目的に作られた in-memory な SQL エンジンを提供するライブラリです。
ドキュメントを読む限り、MySQL というよりは PostgreSQL を意識して作られていそうなので、一部の MySQL の機能や方言などには対応していない可能性があります。
また、よくメンテナンスされている点は素晴らしいのですが [Connector](https://pkg.go.dev/database/sql/driver#Connector) に対応していなかったり、`$GOPATH/src` を参照する機能があるなど少し作りが古い点があります。

3 は手法としては非常に優れているのですが、現時点ではあまり良いライブラリが存在しないという印象です[^1]。

[^1]: 何か良いライブラリをご存じの方がいらっしゃいましたら是非ご教示ください。

4 はテストコード内でテスト用の MySQL コンテナを起動し、テストごとに使い捨てにしていくという手法です。
テスト単位で専用のコンテナを用意できるため、テスト後のデータの削除が不要で、テストを並列実行することもできます。
また、実際に MySQL を実行するためクエリの動作を正確にテストすることができます。
一方でテストの実行環境に Docker のようなコンテナエンジンが必要であったり、コンテナの起動に若干時間がかかる点はデメリットです。

この記事では 4 の手法を簡単に実現できる [testcontainers-go](https://github.com/testcontainers/testcontainers-go) というライブラリの使い方を紹介します。

## testcontainers-go とは

https://github.com/testcontainers/testcontainers-go

テスト用のコンテナの起動や後始末を良い感じに行ってくれるライブラリです。
元々は [Java のライブラリ](https://www.testcontainers.org/) のようですが、様々な言語による実装が存在しています。

コンテナイメージさえ用意できれば MySQL に限らず様々なテストに利用できる非常に便利なライブラリです。

動作には Docker が必要となります。

## testcontainers-go で MySQL コンテナを利用する

この記事では [v0.17.0](https://github.com/testcontainers/testcontainers-go/releases/tag/v0.17.0) 時点での使い方を紹介します。
まだ v0 ということもあり非互換な変更が結構行われるようなので注意してください。

**注意**

v0.17.0 では `go.mod` に次の replace ディレクティブが必要となります。

```go.mod:go
replace github.com/docker/docker => github.com/docker/docker v20.10.3-0.20221013203545-33ab36d6b304+incompatible // 22.06 branch
```

### コンテナを建てる

次のようなコードで MySQL コンテナを起動することができます。

```go:main_test.go
package main

import (
	"context"
	"net"

	"github.com/docker/go-connections/nat"
	"github.com/go-sql-driver/mysql"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
)

type mysqlContainer struct {
	testcontainers.Container
}

func setupMySQL(ctx context.Context) (*mysqlContainer, error) {
	req := testcontainers.ContainerRequest{
		Image: "mysql:8.0",
		Env: map[string]string{
			"MYSQL_DATABASE":             "app",
			"MYSQL_USER":                 "user",
			"MYSQL_PASSWORD":             "password",
			"MYSQL_ALLOW_EMPTY_PASSWORD": "yes",
		},
		ExposedPorts: []string{"3306/tcp"},
		WaitingFor: wait.ForSQL("3306", "mysql", func(host string, port nat.Port) string {
			cfg := mysql.NewConfig()
			cfg.Net = "tcp"
			cfg.Addr = net.JoinHostPort(host, port.Port())
			cfg.DBName = "app"
			cfg.User = "user"
			cfg.Passwd = "password"
			return cfg.FormatDSN()
		}),
	}
	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		return nil, err
	}

	return &mysqlContainer{
		Container: container,
	}, nil
}
```

[testcontainers.ContainerRequest](https://pkg.go.dev/github.com/testcontainers/testcontainers-go@v0.17.0#ContainerRequest) でどのようなコンテナを起動するかを定義します。

ほぼ見たままですが、`Image` でコンテナイメージを、`Env` で環境変数を指定します。
Docker 公式の [mysql イメージ](https://hub.docker.com/_/mysql) の場合、環境変数でデータベースやユーザーの作成ができるので適当な環境変数を指定しておくと良いでしょう。
また、`MYSQL_ROOT_PASSWORD`, `MYSQL_ALLOW_EMPTY_PASSWORD`, `MYSQL_RANDOM_ROOT_PASSWORD` のいずれかの環境変数が必須なので忘れずに設定しておきます。

`ExposedPorts` にはコンテナから公開するポートを指定します。
MySQL コンテナの場合は通常 `3306/tcp` となります。

ポイントとなるのは `WaitingFor` の部分です。

```go
		WaitingFor: wait.ForSQL("3306", "mysql", func(host string, port nat.Port) string {
			cfg := mysql.NewConfig()
			cfg.Net = "tcp"
			cfg.Addr = net.JoinHostPort(host, port.Port())
			cfg.DBName = "app"
			cfg.User = "user"
			cfg.Passwd = "password"
			return cfg.FormatDSN()
		}),
```

`WaitingFor` にはコンテナの起動が完了したことをチェックする方法を指定します。
[wait.Strategy](https://pkg.go.dev/github.com/testcontainers/testcontainers-go@v0.17.0/wait#Strategy) という interface で指定するのですが、[testcontainers/testcontainers-go/wait](https://pkg.go.dev/github.com/testcontainers/testcontainers-go@v0.17.0/wait) パッケージにこれを実装する構造体を返す様々な関数が用意されています。
特定のポートが listen されることを待つ [wait.ForListeningPort()](https://pkg.go.dev/github.com/testcontainers/testcontainers-go@v0.17.0/wait#ForListeningPort) や特定のログの出力を待つ [wait.ForLog()](https://pkg.go.dev/github.com/testcontainers/testcontainers-go@v0.17.0/wait#ForLog) などがありますが、MySQL コンテナの場合は [wait.ForSQL()](https://pkg.go.dev/github.com/testcontainers/testcontainers-go@v0.17.0/wait#ForSQL) を使うと良さそうです。
この `wait.Strategy` はコンテナに対して `SELECT 1` を実行し、成功することを確認します。

`wait.ForSQL()` の第一引数にはコンテナ内の MySQL のポート (3306)、第二引数には [sql.Open()](https://pkg.go.dev/database/sql#Open) で使用するドライバ名 (`mysql`) を指定します。
第三引数はコンテナのホストと動的に割り当てられたポートを受け取って DSN を返す関数です。

`testcontainers.ContainerRequest` でコンテナを定義したら、[testcontainers.GenericContainer()](https://pkg.go.dev/github.com/testcontainers/testcontainers-go@v0.17.0#GenericContainer) に渡します。
これにより、コンテナを制御するための [testcontainers.Container](https://pkg.go.dev/github.com/testcontainers/testcontainers-go@v0.17.0#Container) を得ることができます。
`Started` に `true` を指定しておくとコンテナを自動的に起動します。
これを指定しない場合はコンテナを起動するために `testcontainers.Container` の `Start()` を実行する必要があります。

得られた `testcontainers.Container` は構造体で wrap しておくと、いろいろとメソッドをはやせるので便利です。

#### Tips: コンテナの起動を待つ時間を延長する

[testcontainers/testcontainers-go/wait](https://pkg.go.dev/github.com/testcontainers/testcontainers-go@v0.17.0/wait) パッケージに用意されている `wait.Strategy` はデフォルトでは 60 秒でタイムアウトします。
この時間に不安がある場合は次のようにして任意のタイムアウトを設定することができます。

```go:main_test.go
	req := testcontainers.ContainerRequest{
		// 略
		WaitingFor: wait.ForSQL("3306", "mysql", func(host string, port nat.Port) string {
			// 略
		}).WithStartupTimeout(90 * time.Second),
	}
```

#### Tips: データベースにスキーマや初期データを流し込む

Docker 公式の [mysql イメージ](https://hub.docker.com/_/mysql) は `/docker-entrypoint-initdb.d` に存在する `.sql` ファイルを実行して環境変数 `MYSQL_DATABASE` で作成したデータベースを初期化する機能をサポートしています[^2]。

[^2]: 詳細は [mysql イメージ](https://hub.docker.com/_/mysql) 公式ドキュメントの `Initializing a fresh instance` を参照してください。

これを利用し、`/docker-entrypoint-initdb.d` に `.sql` ファイルをバインドマウントすることでデータベースのスキーマや初期データを簡単にセットアップすることができます。

次の例では `testdata` ディレクトリに `.sql` ファイルを用意しておき、`/docker-entrypoint-initdb.d` にバインドマウントしてデータベースを初期化しています。

```go:main_test.go
	initdbDir, err := filepath.Abs("testdata")
	if err != nil {
		return nil, err
	}

	req := testcontainers.ContainerRequest{
		// 略
		Mounts: testcontainers.ContainerMounts{
			testcontainers.BindMount(initdbDir, "/docker-entrypoint-initdb.d"),
		},
	}
```

### MySQL コンテナに接続する

起動したコンテナには `ExposedPorts` で公開したポートに対してホスト側のポートが動的に割り当てられます。
割り当てられたポートは `testcontainers.Container` の `MappedPort()` で取得することができるため、これを使って MySQL に接続する [\*sql.DB](https://pkg.go.dev/database/sql#DB) を得ることができます。

```go:main_test.go
package main

import (
	"context"
	"database/sql"
	"net"

	"github.com/docker/go-connections/nat"
	"github.com/go-sql-driver/mysql"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
)

type mysqlContainer struct {
	testcontainers.Container
}

func (s *mysqlContainer) OpenDB(ctx context.Context) (*sql.DB, error) {
	host, err := s.Container.Host(ctx)
	if err != nil {
		return nil, err
	}

	port, err := s.Container.MappedPort(ctx, "3306")
	if err != nil {
		return nil, err
	}

	cfg := mysql.NewConfig()
	cfg.Net = "tcp"
	cfg.Addr = net.JoinHostPort(host, port.Port())
	cfg.DBName = "app"
	cfg.User = "user"
	cfg.Passwd = "password"

	connector, err := mysql.NewConnector(cfg)
	if err != nil {
		return nil, err
	}

	return sql.OpenDB(connector), nil
}
```

### コンテナを利用してテストする

次のようなコードを例にします。

```go:main.go
package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"

	"github.com/go-sql-driver/mysql"
)

func main() {
	cfg := mysql.NewConfig()
	cfg.Addr = "localhost:3306"
	cfg.DBName = "app"
	cfg.User = "user"
	cfg.Passwd = "password"

	connector, err := mysql.NewConnector(cfg)
	if err != nil {
		log.Fatal(err)
	}

	db := sql.OpenDB(connector)

	ctx := context.Background()

	if err := CreateUser(ctx, db, "test"); err != nil {
		log.Fatal(err)
	}

	user, err := GetUser(ctx, db, "test")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(user)
}

type User struct {
	ID   int
	Name string
}

func CreateUser(ctx context.Context, db *sql.DB, name string) error {
	_, err := db.ExecContext(ctx, "INSERT INTO users (name) VALUES (?)", name)
	return err
}

func GetUser(ctx context.Context, db *sql.DB, name string) (*User, error) {
	row := db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE name = ?", name)
	user := &User{}
	if err := row.Scan(&user.ID, &user.Name); err != nil {
		return nil, err
	}
	return user, nil
}
```

テーブルの定義は次の通りです。

```sql
CREATE TABLE `users` (
    `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT "ユーザー ID",
    `name` VARCHAR(20) NOT NULL COMMENT "ユーザー名",
    PRIMARY KEY(`id`)
);
```

`CreateUser()` と `GetUser()` のテストは次のように書くことができます。
(`testdata` ディレクトリ以下に上記のテーブル定義を記述した `.sql` ファイルを置いています)

```go:main_test.go
package main

import (
	"context"
	"database/sql"
	"net"
	"path/filepath"
	"testing"

	"github.com/docker/go-connections/nat"
	"github.com/go-sql-driver/mysql"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
)

func TestUser(t *testing.T) {
	ctx := context.Background()
	container, err := setupMySQL(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer container.Terminate(ctx)

	db, err := container.OpenDB(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer db.Close()

	if err := CreateUser(ctx, db, "test"); err != nil {
		t.Fatal(err)
	}
	user, err := GetUser(ctx, db, "test")
	if err != nil {
		t.Fatal(err)
	}

	if user.Name != "test" {
		t.Errorf("expected %q, but got %q", "test", user.Name)
	}
}

type mysqlContainer struct {
	testcontainers.Container
}

func (s *mysqlContainer) OpenDB(ctx context.Context) (*sql.DB, error) {
	host, err := s.Container.Host(ctx)
	if err != nil {
		return nil, err
	}

	port, err := s.Container.MappedPort(ctx, "3306")
	if err != nil {
		return nil, err
	}

	cfg := mysql.NewConfig()
	cfg.Net = "tcp"
	cfg.Addr = net.JoinHostPort(host, port.Port())
	cfg.DBName = "app"
	cfg.User = "user"
	cfg.Passwd = "password"

	connector, err := mysql.NewConnector(cfg)
	if err != nil {
		return nil, err
	}

	return sql.OpenDB(connector), nil
}

func setupMySQL(ctx context.Context) (*mysqlContainer, error) {
	initdbDir, err := filepath.Abs("testdata")
	if err != nil {
		return nil, err
	}

	req := testcontainers.ContainerRequest{
		Image: "mysql:8.0",
		Env: map[string]string{
			"MYSQL_DATABASE":             "app",
			"MYSQL_USER":                 "user",
			"MYSQL_PASSWORD":             "password",
			"MYSQL_ALLOW_EMPTY_PASSWORD": "yes",
		},
		ExposedPorts: []string{"3306/tcp"},
		Mounts: testcontainers.ContainerMounts{
			testcontainers.BindMount(initdbDir, "/docker-entrypoint-initdb.d"),
		},
		WaitingFor: wait.ForSQL("3306", "mysql", func(host string, port nat.Port) string {
			cfg := mysql.NewConfig()
			cfg.Net = "tcp"
			cfg.Addr = net.JoinHostPort(host, port.Port())
			cfg.DBName = "app"
			cfg.User = "user"
			cfg.Passwd = "password"
			return cfg.FormatDSN()
		}),
	}
	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		return nil, err
	}

	return &mysqlContainer{
		Container: container,
	}, nil
}
```

コンテナを起動したら、`testcontainers.Container` の `Terminate()` を忘れずに `defer` で実行するようにしましょう。
これにより、テスト終了時にコンテナが適切に削除されます。

```go:main_test.go
	container, err := setupMySQL(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer container.Terminate(ctx)
```