Goを使ったSQLテストの実現方法について書きました。
Goを使ったSQLテストの実現方法について書きました。
こんにちは。
EMoshUインターン生のしゅうです!
業務として自社アプリのバックエンドをGoを使って開発しています!
今回は、SQLの単体テストをGoを使ってどのように実現するのか調べてみました。
実際にそれぞれのパターンを実装してみて考察していきます。
是非最後までお付き合いください!
みなさんはSQL周りのテストを書くとき、どのような方法で行っていますか?
個人的には以下の2つの方法が簡単で王道かなと考えています。
これらの手法でテストを行う場合、Goではどのように実現すればよいのでしょうか?
今回は前者を DATA-DOG/go-sqlmock、後者を ory/dockertest を使って実現していきます。
今回はテスト用のプロジェクトとしてシンプルなAPIを用意しました。
DBはMySQLを使用し、DBの接続にGo製のORマッパーであるGormを使っています。
go_simple_api
├── controller
│ └── user_controller.go
├── go.mod
├── go.sum
├── main.go
└── model
├── db.go
└── user.go
SQLが絡んでいる model/ のコードを見てみましょう
model/db.go
package model
import (
"os"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func InitDB() *gorm.DB {
dsn := os.Getenv("DB_DSN")
var err error
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
return db
}
ここではDB接続して*gorm.DBを初期化しています。
model/user.go
package model
import "gorm.io/gorm"
type User struct {
ID int `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
type UserModel struct {
db *gorm.DB
}
func NewUserModel(db *gorm.DB) *UserModel {
return &UserModel{db}
}
func (m *UserModel) FindUserById(id int) (user User, err error) {
err = m.db.Find(&user, id).Error
return
}
ここではusersテーブルに対してidからuserを取得するメソッドを実装しています。
UserModelが持つ*gorm.DBを使ってDBへアクセスしています。
それでは実際にテストを実装していきましょう。
go-sqlmockとは、Goのライブラリで簡単に sql/driver を実装したモックを立てることができます。
また、実際に発行されるSQLをテストすることができます。
早速実装してみましょう。
新たに model/db_test.go と model/user_mock_test.go を追加して以下のようなディレクトリ構成になりました。
go_simple_api
├── controller
│ └── user_controller.go
├── go.mod
├── go.sum
├── main.go
└── model
├── db.go
├── db_test.go
├── user.go
└── user_mock_test.go
まずは、SQLドライバのモックを初期化してみます。
model/db_test.go に処理を書いています。
model/db_test.go
package model
import (
"github.com/DATA-DOG/go-sqlmock"
_ "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func InitDBMock() (*gorm.DB, sqlmock.Sqlmock, error) {
// sqlmockを初期化
mockDB, mock, err := sqlmock.New()
if err != nil {
return nil, mock, err
}
// DBのコネクションを初期化
db, err := gorm.Open(mysql.New(
mysql.Config{
Conn: mockDB,
SkipInitializeWithVersion: true,
}),
&gorm.Config{},
)
return db, mock, err
}
ここではモック用の*gorm.DBを初期化しています。
sqlmock.New()の定義は以下のようになっています。
func New(options ...func(*sqlmock) error) (*sql.DB, Sqlmock, error)
sqlmock.New()によって*sql.DBとSqlmockを初期化することができます。
基本的にはSqlmockを使ってDBの振る舞いなどを設定します。
引数にoptionを渡すことで後述するQueryMatcherを独自に設定できます。
今回はGormを利用しているので、sqlmock.New()で初期化した*sql.DBをコネクションとして
gorm.Open()によって*gorm.DBを初期化しています。
db_test.goのように_testをサフィックスに持つファイルに定義することでビルドされず、テスト時のみ利用可能になります。
それでは実際のテストコードを見てみましょう。
model/user_mock_test.go
package model_test
import (
"go_simple_api/model"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
)
func TestFindByIdMock(t *testing.T) {
//MockDBを初期化する
mockDB, mock, err := model.InitDBMock()
if err != nil {
t.Fatal(err)
}
//UserModelの初期化する
userModel := model.NewUserModel(mockDB)
//期待されるカラム
expectedColumns := []string{
"id",
"first_name",
"last_name",
}
//期待されるuser
expect := model.User{
ID: 1,
FirstName: "Michael",
LastName: "Jordan",
}
//期待されるSQL
query := "SELECT * FROM `users` WHERE `users`.`id` = ?"
//DBの振る舞いを定義する
mock.ExpectQuery(regexp.QuoteMeta(query)).
WithArgs(1).
WillReturnRows(sqlmock.NewRows(expectedColumns).AddRow(expect.ID, expect.FirstName, expect.LastName))
s, _ := userModel.FindById(1)
//実際のuserと期待されるuserを比較する
assert.Equal(t, expect, s)
}
FindById()のテストを実装してみました。
上述したInitDbMock()で初期化したSqlmockに対して
ExpectQuery()で期待されるクエリを定義します。
sqlmockはデフォルトで正規表現を設定するので、regexp.QuoteMeta()でメタ文字をエスケープしています。
そして、WithArgs()でクエリの引数を設定します。
さらに、ここでは1件のレコードを取得するという挙動をテストしたいため
WillReturnRows()で期待されるカラムとレコードを設定しています。
ほかにも、トランザクションをテストするための ExpectedBegin, ExpectedCommit, ExpectedRollback や
insertやdeleteなどのレコードを返さない操作をテストするための ExpectedExec など、
ほぼすべての操作に対してテストするメソッドが用意されています。
QueryMathcerは期待する文字列と実際の文字列との比較の条件を定義します。
QueryMatcherの定義は以下のようなinterfaceとなっています。
type QueryMatcher interface {
Match(expectedSQL, actualSQL string) error
}
Match()をメソッドに持つ構造体を定義すれば独自にQueryMathcerを定義できます。
標準では、QueryMatcherEqualとQueryMatcherRegexpという2つのQueryMathcerが用意されています。
QueryMatcherEqual は期待するクエリと実際のクエリが等しい場合のみテストが通ります。
QueryMatcherRegexp は期待するクエリを正規表現にパースして実際のクエリと等しい場合にテストが通ります。
デフォルトでは QueryMatcherRegexp が設定されています。
QueryMatcherEqual を設定したい場合は、以下のようにsqlmockの初期化時に設定します。
model/db_test.go
package model
import (
"github.com/DATA-DOG/go-sqlmock"
_ "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func InitDBMock() (*gorm.DB, sqlmock.Sqlmock, error) {
// sqlmockを初期化
// QueryMatcherを設定
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)))
if err != nil {
return nil, mock, err
}
// DBのコネクションを初期化
db, err := gorm.Open(mysql.New(
mysql.Config{
Conn: mockDB,
SkipInitializeWithVersion: true,
}),
&gorm.Config{},
)
return db, mock, err
}
dockertest は簡単にdockerコンテナを立ち上げることができるライブラリです。
dockertestを使えば実際のDBに接続してテストを行うことができます。
実装してみましょう。
新たに model/user_docker_test.go を追加して以下のようなディレクトリ構成になりました。
go_simple_api
├── controller
│ └── user_controller.go
├── go.mod
├── go.sum
├── main.go
└── model
├── db.go
├── db_test.go
├── user.go
├── user_docker_test.go
└── user_mock_test.go
まずは、MySQL用のコンテナを起動してみます。
model/db_test.go に処理を書いています。
model/db_test.go
type DBContainer struct {
Resource *dockertest.Resource
DB *gorm.DB
}
func NewPool() (*dockertest.Pool, error) {
return dockertest.NewPool("")
}
func NewDBContainer(pool *dockertest.Pool) (*DBContainer, error) {
workDir, _ := os.Getwd()
// コンテナ起動
resource, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "mysql",
Tag: "8.0",
Env: []string{"MYSQL_ROOT_PASSWORD=secret"},
Mounts: []string{
workDir + "/testdata:/docker-entrypoint-initdb.d",
},
})
if err != nil {
return nil, err
}
dsn := fmt.Sprintf("root:secret@(localhost:%s)/test_db?parseTime=true", resource.GetPort("3306/tcp"))
var db *gorm.DB
// exponential backoff でコンテナの起動を待つ
if err := pool.Retry(func() error {
var err error
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.Ping()
}); err != nil {
pool.Purge(resource)
return nil, err
}
return &DBContainer{Resource: resource, DB: db}, nil
}
dockertest.NewPool()でdockerコンテナを操作するAPIへの接続である *dockertest.Poolを初期化します。
*dockertest.Pool を使ってコンテナの起動やコンテナの削除などを行います。
今回はNewPool()で*dockertest.Poolを初期化してNewDBContainer()に*dockertest.Poolを引数で渡すことで
DB用のコンテナを起動するようにします。
コンテナを起動するメソッドとして、dockerイメージをビルドしてdockerコンテナを起動する pool.BuildAndRun() と
dockerコンテナの起動だけを行うpool.Run()という2つのメソッドが用意されています。
さらに、それぞれのメソッドで詳細のオプションを設定できる
pool.BuildAndRunWithOptions()、pool.BuildAndRunWithBuildOptions()、pool.RunWithOptions() の3つのメソッドが用意されています。
各メソッドの定義は以下のようになります。
func (d *Pool) BuildAndRun(name, dockerfilePath string, env []string) (*Resource, error)
func (d *Pool) BuildAndRunWithBuildOptions(buildOpts *BuildOptions, runOpts *RunOptions, hcOpts ...func(*dc.HostConfig)) (*Resource, error)
func (d *Pool) BuildAndRunWithOptions(dockerfilePath string, opts *RunOptions, hcOpts ...func(*dc.HostConfig)) (*Resource, error)
func (d *Pool) Run(repository, tag string, env []string) (*Resource, error)
func (d *Pool) RunWithOptions(opts *RunOptions, hcOpts ...func(*dc.HostConfig)) (*Resource, error)
Run() では内部で RunWithOptions() を呼び出しているため、
Run(repository, tag, env)
と
RunWithOptions(&dockertest.RunOptions{
Repository: repository,
Tag: tag,
Env: env,
})
は同じように動きます。
今回は公式のMySQLのdockerイメージを使いたいという理由と、
コンテナの起動時にテストデータをDBの初期データをマウントしたいという理由から、
RunWithOptions() でコンテナの起動を行っています。
コンテナを起動した後、コンテナの起動が完了するまでGormによるDB接続を以下のようにpool.Retry()で待ちます。
// exponential backoff でコンテナの起動を待ち、DBへ接続する
if err := pool.Retry(func() error {
var err error
//DBへ接続する
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
//DBへのコネクションの確認
return sqlDB.Ping()
}); err != nil {
//コンテナを削除する
pool.Purge(resource)
return nil, err
}
Retry() は Exponential Backoff によってリトライをします。
デッドラインに達した際にはエラーを返すような仕様になっています。
これでコンテナの起動とDBへの接続の処理が実現できます。
実際のテストコードを見てみましょう
model/user_docker_test.go
package model_test
import (
"go_simple_api/model"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFindUserByIdDocker(t *testing.T) {
pool, err := model.NewPool()
if err != nil {
t.Fatal(err)
}
dbContainer, err := model.NewDBContainer(pool)
if err != nil {
pool.Purge(dbContainer.Resource)
t.Fatal(err)
}
userModel := model.NewUserModel(dbContainer.DB)
expect := model.User{
ID: 1,
FirstName: "Michael",
LastName: "Jordan",
}
user, err := userModel.FindById(1)
if err != nil {
pool.Purge(dbContainer.Resource)
t.Fatal(err)
}
pool.Purge(dbContainer.Resource)
assert.Equal(t, expect, user)
}
簡単に実装することができました。
dockertest を使わなくても事前にコマンドラインからdockerコンテナを立ち上げることで
実際のDBへ接続したテストを実現することは可能ですが、dockertestを使うのも簡単ですし、Goで実装できるので便利ですよね。
ここまで読んでいただきありがとうございました。
いかがでしたか。今回はGoによるSQLテストについて見ていきました。
dockertestに関しては、SQLテスト以外の用途でも利用できそうです。
実際にテストを実行してみた結果、
go-sqlmockを使った場合は約0.008秒ほどで完了し、dockertestを使った場合は約14秒ほどで完了しました。
コンテナを使ったSQLテストは実行時間がネックになりそうですね。
ただし、テストの確実性は実際のDBへ接続したテストのほうが明らかに高いので、
どのプロジェクトでもDB周りのテストはコンテナを使ったテストの方を選択するのが良いかもしれません。
最後になりますが、EMoshUでは一緒に開発できる仲間をいつでも募集しております。
EMoshUブログを読んでご興味を持たれましたら、是非下記の募集要項をご覧ください。カジュアル面談なども実施しております!
Go言語でサクッとCUIツールを作ってみる|Engineering|EMoshU Blog|株式会社EMoshU
EMoshU Blog|Go言語の勉強も兼ねて、最小限のコードで並列処理やAPIの実行、JSONの取り扱い等を実際に実装して、簡単なCUIツールを作ってみました。
はじめてのDocker環境構築 〜 Dockerに触れてみよう!|Engineering|EMoshU Blog|株式会社EMoshU
EMoshU Blog|この度開発環境でDockerを使う機会がありましたので、その際に学習したことについて簡単にまとめてみました。