この記事はニフクラ等を提供している、富士通クラウドテクノロジーズ Advent Calendar 2022の3日目の記事です。
前日は、@SogoK さんの「よくあるSPA+API構成でのOpenID Connectクライアント実装」でした。今まで認証処理に触れる機会が少なかったので、原理を知れて勉強になりました。(ユーザーに見えないところで何工程もリダイレクトが走っていたんですね...)
はじめに
しっかり書きたいSQL周りのテスト。しかし、合わない手法を使うと、実行時間がかかってしまったり失敗時にゴミが残ってしまったりとテストを流すこと自体億劫になってしまいがちです。
そこで、本記事では備忘録も兼ねてGo言語でSQL操作のテストを書く方法をいくつかまとめてみました。
検証に使用したコード
見やすいように、同じ実装に対してテストを記述しました。
実装には、1テーブルに対するシンプルなCRUD処理のメソッドを使用しています。
コードとテストは以下のリポジトリですべてご覧いただけます。
環境
- SQL: MySQL8
- ORM: SQLBoiler
- Go: v1.19
技術スタックは個人的な趣味です。他のものでも使えると思います。
実際のSQLを利用する
1. Dockerコンテナを使用
まずはシンプルに、実際に起動しているMySQLに接続してテストします。テスト用ライブラリ等を何も使う必要が無いので手軽に記述できます。
一方、取得のテストであっても準備(作成)と後片付け(削除)が必要1なので、CRUDを流れで行うシナリオテストに向いていると思います(作成したものを削除するシナリオなら自然に冪等になるため)。
ユニットテストの場合は、「作成したものを取得して内容が同じかどうか」をテストすることでシンプルに記述できました。
(「ドメイン駆動設計 サンプルコード&FAQ」で紹介されている方法を参考にさせていただきました)
func TestListWithDocker(t *testing.T) {
ctx := context.Background()
user := &User{
ID: "0123456789ABCDEFGHJKMNPQRS",
Name: "Mike",
Age: 20,
}
db, err := NewClient(3306)
require.NoError(t, err)
// run
r := NewUserRepository(db)
err = r.Register(ctx, user)
require.NoError(t, err)
// 注:作ったリソースは消さないと後続のテストが落ちてしまう!
defer r.Delete(ctx, user)
found, err := r.Get(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, user, found)
}
他の手法と比べると、追加処理が無いのでコード量は一番少なくなっています。
2. Testcontainersを使用
続いて、Testcontainersを使う方法です。こちらもDockerコンテナを使用しますが、SDKでコード上からコンテナ作成、破棄が可能です。
都度作り直すので、テストが失敗してもゴミが残らないのがありがたいです。
コンテナの設定項目はdocker-composeの書き方に似ているので、初見でもあまり戸惑わずに使うことができました。
func TestGetWithTestContainers(t *testing.T) {
ctx := context.Background()
user := &User{
ID: "0123456789ABCDEFGHJKMNPQRS",
Name: "Mike",
Age: 20,
}
db, teardown := prepareContainer(ctx, t)
defer teardown()
// run
r := NewUserRepository(db)
err := r.Register(ctx, user)
require.NoError(t, err)
found, err := r.Get(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, user, found)
}
// Testcontainersを使用しMySQLコンテナ起動
func prepareContainer(ctx context.Context, t *testing.T) (*sql.DB, func()) {
req := testcontainers.ContainerRequest{
Image: "mysql:8",
Env: map[string]string{
"MYSQL_ALLOW_EMPTY_PASSWORD": "yes",
"MYSQL_DATABASE": "practice",
},
ExposedPorts: []string{"3306/tcp"},
Mounts: testcontainers.ContainerMounts{ // volumes に対応(指定はフルパス必須)
testcontainers.BindMount(absPath("initdb.d"), "/docker-entrypoint-initdb.d"),
},
WaitingFor: wait.ForSQL("3306/tcp", "mysql", func(host string, port nat.Port) string { // コンテナが使用可能かを判定する関数(後述)
return fmt.Sprintf("root:@(%s:%d)/practice", host, port.Int())
}),
AutoRemove: true, // 用が済んだら自動削除
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Fatalf("failed to start container: %s", err)
}
teardown := func() {
if err := container.Terminate(ctx); err != nil {
t.Fatalf("failed to terminate container: %s", err)
}
}
port, err := container.MappedPort(ctx, "3306") // ホスト側の空きポートにマッピングしてくれる
if err != nil {
t.Fatalf("failed to get mapped port: %s", err)
}
db, err := NewClient(port.Int())
if err != nil {
t.Fatalf("failed to create client: %s", err)
}
return db, teardown
}
func absPath(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
panic(err)
}
return abs
}
WaitingFor
にはコンテナが使用可能かどうかを判定する関数をセットします。
ここでは、WaitForSQLでDBに SELECT 1
クエリを投げることで起動をポーリングしています。
テストの特徴としては基本的にそのままDockerコンテナを立てる場合と同じですが、以下の利点があります。
- 後片付け不要
- テストが失敗してもゴミが残らない
- 次のテスト実施までに手作業でレコードを消す必要が無い
- 並列実行
t.Parallel()
で高速化可能(コード)- テストケースごとに別々のポート/別々のコンテナを利用可能
一方、テスト開始後にコンテナを作成するため、実行時間のオーバーヘッドがかかる点は注意が必要です。テスト高速化のために採用する場合は、十分な並列数で実行できるか検討したほうが良いでしょう。
# 1. Dockerコンテナを使用
ok github.com/syuparn/gosqltests 0.123s
# 2. Testcontainersを使用
ok github.com/syuparn/gosqltests 21.807s
モックやシミュレーターを使う
3. go-sqlmockを使用
次はSQLの代わりにモックを使う方法です。準備不要で、異常系やコーナーケース等小回りの利く検証がしやすいです。
動作としては、期待するクエリに一致するものがリクエストされた際にモックレコードを返却します。DBの内部構造は再現せず、あくまで振る舞いのみに着目しています。
func TestGetWithSQLMock(t *testing.T) {
columns := []string{"id", "name", "age"}
tests := []struct {
title string
id string
query string
mockRow []driver.Value
expected *User
}{
{
"get a user",
"0123456789ABCDEFGHJKMNPQRS",
"SELECT `user`.* FROM `user` WHERE (`user`.`id` = ?) LIMIT 1", // 期待するクエリ
[]driver.Value{"0123456789ABCDEFGHJKMNPQRS", "Mike", 20}, // 返却するモック
&User{
ID: "0123456789ABCDEFGHJKMNPQRS",
Name: "Mike",
Age: 20,
},
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
// mock
db, mock, teardown := prepareMockDB(t)
defer teardown()
rows := sqlmock.NewRows(columns).AddRow(tt.mockRow...)
mock.ExpectQuery(regexp.QuoteMeta(tt.query)).
WillReturnRows(rows)
// run
r := NewUserRepository(db)
actual, err := r.Get(context.TODO(), tt.id)
// assert
require.NoError(t, err)
require.Equal(t, tt.expected, actual)
})
}
}
func prepareMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock, func()) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
teardown := func() {
db.Close()
}
return db, mock, teardown
}
また、異常系もモックにエラーを返させるだけでテスト可能です。
func TestGetErrorWithSQLMock(t *testing.T) {
tests := []struct {
title string
id string
query string
mockErr error
expectedErr string
}{
{
"not found",
"0123456789ABCDEFGHJKMNPQRS",
"SELECT `user`.* FROM `user` WHERE (`user`.`id` = ?) LIMIT 1",
sql.ErrNoRows,
"user was not found (id: 0123456789ABCDEFGHJKMNPQRS): sql: no rows in result set",
},
{
"unexpected error",
"0123456789ABCDEFGHJKMNPQRS",
"SELECT `user`.* FROM `user` WHERE (`user`.`id` = ?) LIMIT 1",
fmt.Errorf("crashed unexpectedly!!!"),
"failed to get user (id: 0123456789ABCDEFGHJKMNPQRS): models: failed to execute a one query for user: bind failed to execute query: crashed unexpectedly!!!",
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
// mock
db, mock, teardown := prepareMockDB(t)
defer teardown()
mock.ExpectQuery(regexp.QuoteMeta(tt.query)).
WillReturnError(tt.mockErr) // レコードの代わりにエラーをセット
// run
r := NewUserRepository(db)
_, err := r.Get(context.TODO(), tt.id)
// assert
require.Error(t, err)
require.EqualError(t, err, tt.expectedErr)
})
}
}
このように、go-sqlmockはたくさんのテストケースを書きたいスモールテスト(特にテーブル駆動テスト)と相性が良いと思います。Dockerが無くても動くので開発環境も用意しやすいです。
一方、ORMを使っている場合、隠蔽していたクエリ文をテストで意識する必要があるのが難点です。
(上記のように簡単な場合は問題ありませんが、WHEREやJOINが複数入ったクエリの場合「そもそも何が正解か」を探す必要があります)
4. SQLのsimulatorを使用
最後に、シミュレーターライブラリを使用する方法です。こちらはモックではなく、実際にインメモリでMySQL互換のDBとして動作します。
(まだ触っていませんが、Postgres互換のシミュレーター https://github.com/fergusstrange/embedded-postgres もありました)
一方、ライブラリ経由でテーブルやレコード操作ができるため、作成/削除実装の力を借りずとも取得テストを記述可能です。また、実際にレコード取得するため、テストへのクエリ文の記述も不要です。
func TestGetWithGoMySQLServer(t *testing.T) {
tests := []struct {
title string
id string
prepare func(*simsql.Context, *memory.Table)
expected *User
}{
{
"get a user",
"0123456789ABCDEFGHJKMNPQRS",
func(ctx *simsql.Context, table *memory.Table) {
// ライブラリ経由でレコード作成
_ = table.Insert(ctx, simsql.NewRow(
"0123456789ABCDEFGHJKMNPQRS",
"Mike",
int64(20),
))
},
&User{
ID: "0123456789ABCDEFGHJKMNPQRS",
Name: "Mike",
Age: 20,
},
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
// simulator
table, teardown := prepareSimulator(t, 23306)
defer teardown()
tt.prepare(simsql.NewEmptyContext(), table)
// run
db, err := NewClient(23306)
require.NoError(t, err)
r := NewUserRepository(db)
actual, err := r.Get(context.TODO(), tt.id)
// assert
require.NoError(t, err)
require.Equal(t, tt.expected, actual)
})
}
}
準備コードでは、以下のように作成するDB、テーブルを指定しシミュレーターサーバーを起動します。
func prepareSimulator(t *testing.T, port int) (*memory.Table, func()) {
db, table := simulatorDB()
// DB追加
engine := sqle.NewDefault(
simsql.NewDatabaseProvider(
db,
information_schema.NewInformationSchemaDatabase(), // NewInformationSchemaDatabase。他のDB情報格納のために必要
))
// ユーザー追加
engine.Analyzer.Catalog.MySQLDb.AddSuperUser("root", "localhost", "")
config := server.Config{
Protocol: "tcp",
Address: fmt.Sprintf("localhost:%d", port),
}
s, err := server.NewDefaultServer(config, engine)
if err != nil {
t.Fatal(err)
}
// サーバー起動
go func() {
if err = s.Start(); err != nil {
panic(err)
}
}()
teardown := func() {
if err := s.Close(); err != nil {
t.Fatal(err)
}
}
return table, teardown
}
// DBとテーブルをシミュレーター上に作成(もちろんCREATE TABLE文でも可能)
func simulatorDB() (*memory.Database, *memory.Table) {
db := memory.NewDatabase("practice")
tableName := "user"
table := memory.NewTable(tableName, simsql.NewPrimaryKeySchema(simsql.Schema{
{Name: "id", Type: simsql.Text, Nullable: false, Source: tableName, PrimaryKey: true},
{Name: "name", Type: simsql.Text, Nullable: false, Source: tableName},
{Name: "age", Type: simsql.Int64, Nullable: false, Source: tableName},
}), db.GetForeignKeyCollection())
db.AddTable(tableName, table)
return db, table
}
テストケース内の prepare
関数では、このテーブルオブジェクトに対して table.Insert
レコードを追加しています。
_ = table.Insert(ctx, simsql.NewRow(
"0123456789ABCDEFGHJKMNPQRS",
"Mike",
int64(20), // ※型変換必須!
))
スキーマは型チェックしているので、テストの指定ミスや実装のバグにもすぐ気づくことができます2。
ただし、untyped intはint
として扱われてしまう(int64
等に自動変換はされない)のでそこだけは注意が必要です。
_ = table.Insert(ctx, simsql.NewRow(
"0123456789ABCDEFGHJKMNPQRS",
"Mike",
20,
))
panic: Actual Value Type: int, Expected Value Type: int64
とはいえ、テーブル準備コードの記述コストが高いので、リポジトリ内にスキーマ定義がある場合は自動生成してしまうのも手かもしれません。
(例えば、SQLBoilerにはユーザー定義テンプレート機能があります)
並列実行
こちらもテストケースごとにDBポートを変えることで t.Parallel()
による並列実行が可能です。
ランダムな空きポートは net.Listen()
を使うと手軽に見つけられます。
func freePort() (int, error) {
// NOTE: 0番ポートを指定すると空きポートを見つけてlistenしてくれる
l, err := net.Listen("tcp4", "localhost:0")
if err != nil {
return 0, err
}
// あとでDBサーバーで使うために閉じる(閉じないとAlready in useが発生してしまう)
l.Close()
addr := l.Addr().(*net.TCPAddr)
return addr.Port, nil
}
net.Listen()
で0番ポートを指定すると、ランダムな空きポート上でlistenします。今回はポート番号が欲しいだけなので、即座にcloseしています3。
起動のオーバーヘッドも少ないため、実際のSQLサーバーと遜色ない速度でテスト可能です。
# docker (※準備用のcreateと後片付けのdeleteも含めた時間)
ok github.com/syuparn/gosqltests 0.106s coverage: 54.2% of statements
# go-mysql-server
ok github.com/syuparn/gosqltests 0.093s coverage: 29.2% of statements
おわりに
以上、GoでSQL操作のテスト方法の紹介でした。書き出してみて、改めて選択肢が豊富だと実感しました。
- テストコードの簡潔さ
- 並行処理の需要
- 環境の用意しやすさ
のどれを優先したいか場面に応じて使い分けていきたいと思います。
この記事は富士通クラウドテクノロジーズ Advent Calendar 2022の3日目の記事でした。
明日は、@o108minmin さんが料理かRustについて書いてくださるようです。ジャンルがかなり異なりますが、いったいどちらが来るのでしょうか...?お楽しみに!