LoginSignup
7
4

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-07-31

前置き

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

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

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

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

似たようなところでは go-sqlmock というライブラリを利用するという方法もあります。

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

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

このようなライブラリとしては go-mysql-serverramsql が挙げられます。

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

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

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

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

この記事では 4 の手法を簡単に実現できる testcontainers-go というライブラリの使い方を紹介します。

testcontainers-go とは

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

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

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

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

この記事では v0.17.0 時点での使い方を紹介します。
まだ v0 ということもあり非互換な変更が結構行われるようなので注意してください。

注意

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

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

コンテナを建てる

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

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 でどのようなコンテナを起動するかを定義します。

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

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

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

		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 という interface で指定するのですが、testcontainers/testcontainers-go/wait パッケージにこれを実装する構造体を返す様々な関数が用意されています。
特定のポートが listen されることを待つ wait.ForListeningPort() や特定のログの出力を待つ wait.ForLog() などがありますが、MySQL コンテナの場合は wait.ForSQL() を使うと良さそうです。
この wait.Strategy はコンテナに対して SELECT 1 を実行し、成功することを確認します。

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

testcontainers.ContainerRequest でコンテナを定義したら、testcontainers.GenericContainer() に渡します。
これにより、コンテナを制御するための testcontainers.Container を得ることができます。
Startedtrue を指定しておくとコンテナを自動的に起動します。
これを指定しない場合はコンテナを起動するために testcontainers.ContainerStart() を実行する必要があります。

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

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

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

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 イメージ/docker-entrypoint-initdb.d に存在する .sql ファイルを実行して環境変数 MYSQL_DATABASE で作成したデータベースを初期化する機能をサポートしています2

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

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

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.ContainerMappedPort() で取得することができるため、これを使って MySQL に接続する *sql.DB を得ることができます。

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
}

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

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

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
}

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

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 ファイルを置いています)

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.ContainerTerminate() を忘れずに defer で実行するようにしましょう。
これにより、テスト終了時にコンテナが適切に削除されます。

main_test.go
	container, err := setupMySQL(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer container.Terminate(ctx)
  1. 何か良いライブラリをご存じの方がいらっしゃいましたら是非ご教示ください。

  2. 詳細は mysql イメージ 公式ドキュメントの Initializing a fresh instance を参照してください。

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4