この記事は、CYBOZU SUMMER BLOG FES '25の記事です。
クラウド基盤本部の新井です。
この記事では、DB へのアクセスを伴う Go の単体テストについての私たちの考え方、そしてそれを実践するために開発・公開したライブラリをご紹介します。
DB のモックとその問題点
DB へのアクセスを伴う Go のプログラムをどのように単体テストしたいとき、最初に思いつくのは go-sqlmock などのツールを使って DB をモックする方法です。 サンプルコードでは、次のようなテストを書いています。
package main import ( "fmt" "testing" "github.com/DATA-DOG/go-sqlmock" ) // a successful case func TestShouldUpdateStats(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } defer db.Close() mock.ExpectBegin() mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("INSERT INTO product_viewers").WithArgs(2, 3).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() // now we execute our method if err = recordStats(db, 2, 3); err != nil { t.Errorf("error was not expected while updating stats: %s", err) } // we make sure that all expectations were met if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("there were unfulfilled expectations: %s", err) } }
このように、モックに期待したクエリが発行されたかを確認することで、テスト対象システムが期待通り動作しているかを検証します。
モックを使うとテストの高速化が容易で、テストケース間の依存も簡単になくせます。
しかし、モックは多用すべきではありません。 なぜなら、リファクタリングへの耐性を失うことでテストコードの保守性が低下し、プロダクションコードの品質を保つことも難しくなるからです。
例えば先ほどの例では、モックに入力したクエリが期待通りかを確かめていました。 ここで、システムのパフォーマンス向上のためにクエリをチューニングしたり、可読性向上のためにリファクタリングしたいとします。
モックを使った方法では、たとえテスト対象システムの振る舞いが同じであっても、クエリをチューニングするたびにテストコードを変更しなければならないため、保守に手間がかかります。 さらに、単体テストではリファクタリング前と後で振る舞いが変わらないことをテストしたいのに、テストコードも変えてしまうのでは目的が達成できません。
実 DB を用いたテスト
上で述べた観点から、実際の DB を使って単体テストしたくなります。
しかし、複数のテストケースから同じ DB にアクセスする場合、クエリが競合して期待した結果が得られないことがあるため、シングルスレッドでテストを実行する必要があります。この場合、テストケースが多いとテストに時間がかかります。
私たちのチームで実践している方法
実際の DB を使いつつ並行してテストを実行するため、テストケースごとにスキーマを分ける方法を採用しています *1。
また、他のプロジェクトからも利用できるよう、mysqltest という名前のライブラリとして公開しました。
このライブラリは SetupDatabase という関数を提供しており、これを呼び出すことで並行して実行するテストのための DB がセットアップされます。
使い方
mysqltest.SetupDatabase を、テーブルドリブンテストの各テストケースを実行するループの中で呼びます。
import ( "testing" "github.com/cybozu-go/mysqltest" ) func TestDB(t *testing.T) { t.Parallel() for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() // Setup query1 := "CREATE TABLE ..." query2 := "INSERT INTO ..." conn := mysqltest.SetupDatabase(t, mysqltest.RootUserCredentials("root", "root"), mysqltest.Verbose(), mysqltest.ModifyConfig(func(c *mysql.Config) { c.Net = "tcp" c.Addr = net.JoinHostPort("127.0.0.1", mysqlPort) c.MultiStatements = true }), mysqltest.Queries(query1, query2), ) sut := NewSUT(conn.DB) // Execute actual := sut.SomeOperation(tc.input) // Verify if actual != tc.expected { t.Errorf("expected %v, got %v", tc.expected, actual) } }) } }
mysqltest.SetupDatabase は次の動作をします。
- ルートユーザでログイン
- ランダムな名前のユーザとスキーマを作成
- そのユーザとスキーマで再接続
mysqltest.Queriesで指定したクエリを実行- DB へのコネクションを返却
これにより、返ってきたコネクションをテスト対象システムに渡すことで DB へのアクセスを伴う単体テストを並行で実行できます。 また、テストが終わったら作られたユーザとスキーマは自動で削除されるので、ユーザ側で明示的に削除する必要はありません(設定により削除されないようにもできます)。
テストにかかる時間の計測
テストケースごとにスキーマを分けるようにすることで、並行してテストを実行できるようになりました。 さて、この方法を導入することでテストの所要時間は短縮できるのでしょうか。 実際に計測して確認します。
計測した環境およびテストの規模は次の通りです。
- 実行環境:仮想マシン
- CPU:8コア
- メモリ:16GB
- OS:Ubuntu 22.04
- Go:1.25.2
- テストの並列数:16
- テストケース数:約 900
SetupDatabaseの実行回数:309 回- 内部で作成しているテーブル数:15
計測結果は次の通りです。
並列化による影響を調べるため、t.Parallel() を呼んだ場合と呼ばない場合の両方で計測しています。
| 実行時間(real) | 実行時間(user) | 実行時間(sys) | |
|---|---|---|---|
t.Parallel() を呼んだ場合 |
18秒 | 25秒 | 5秒 |
t.Parallel() を呼ばない場合 |
1分27秒 | 25秒 | 5秒 |
この結果から、t.Parallel() を呼んでテストを並行実行した時のテストにかかる時間は18秒となり、実用的な時間でテストが完了していることがわかりました。
そして、t.Parallel() を呼ばない場合(シングルスレッドの場合)に比べて約5倍高速化できていることがわかります。
この方法が適さない状況
テーブル数が15程度のサービスであればこの方法は機能しますが、テーブル数が非常に多いサービスや単体テストのために多くの DDL を流す必要がある場合、実 DB を用いたテストは非常に低速になります。
単体テストにおいてテストが短時間で終わることは非常に重要なので、このようなケースではモックが選択肢として有力になりえます。 しかし、モックの使用に際してはテストにかかる時間とコード品質・保守性とのトレードオフを注意深く分析する必要があります。
最後に
本記事では、Go による DB へのアクセスを伴う単体テスト方法を紹介しました。 この方法により、リファクタリング耐性を高く保ちつつ、テストケース間の依存関係を気にせずテストを並行実行できます。
今後も効果的なテストを行い安全で安定したクラウド基盤を提供していきます。
参考文献
モックについての考え方は次の本を参考にしました。