go-sqlmock を使ったことがなかったので、Gormを使ってデータベースにアクセスするアプリケーションをテストするコードを書いてみました。
go-sqlmock
の READMEに
sqlmock is a mock library implementing sql/driver. Which has one and only purpose - to simulate any sql driver behavior in tests, without needing a real database connection. sqlmockはsql/driverを実装したモックライブラリです。目的は一つだけで、実際のデータベース接続を必要とせずに、テストで任意のsqlドライバの動作をシミュレートすることです。
という記載があります。なので、Go標準のdatabase/sql パッケージでSQLクエリを発行する場合でも、Gorm
のような ORM
フレームワークを使った場合でもDBを使わずにテストも行うことができます。
前提
- go 1.14
- Gorm V2: v1.20.12
- go-sqlmock: v1.5.0
- testify: v1.7.0
ディレクトリ/ファイル構成と概要
以下のシンプルな構成のサンプルアプリケーションにテストを追加してみます。
. ├── main.go ├── model │ └── book.go └── repository ├── book.go └── book_test.go
それぞれのファイルでやっていることの概要は
main.go
- リポジトリ(
repository/book.go
)を介して、書籍(book)のデータを登録。
- リポジトリ(
model/book.go
- 書籍(book)を表す構造体を定義。
repository/book.go
- データベースにアクセスし、SQLを発行して書籍(book)のデータを登録。そして、結果を返す。
repository/book_test.go
- 追加する
repository/book.go
のテスト。ここでgo-sqlmock
を使ってsqlドライバの動作をシミュレート。
- 追加する
です。
サンプルアプリケーションを動かしてみる
main.go
まずは main.go の内容をみてみます。単純に書籍(book)のcreateを行いエラーが発生しないか確認しているだけです。
package main import ( "fmt" "github.com/moritamori/gorm-testing/model" "github.com/moritamori/gorm-testing/repository" "gorm.io/driver/postgres" "gorm.io/gorm" ) func main() { // DB接続を開く url := "dbname=gormtesting password=mypassword" db, err := gorm.Open(postgres.Open(url), &gorm.Config{}) if err != nil { panic(err) } // リポジトリ(`repository/book.go`)を介して、書籍(book)のデータを登録 bookRepository := repository.BookRepositoryImpl{DB: db} book := &model.Book{ Title: "Go言語の本", Author: "誰か", } err := bookRepository.Create(book) // エラーが発生しないかチェック if err != nil { fmt.Println(err) return } fmt.Println("success!") }
※ローカルで実行する際にはpostgresql
に gormtesting
データベースとbooksテーブルが存在する必要があります。
model
, repository
(book.go)
書籍の構造体の定義( model
)と実際のDBの処理( repository
)を別のパッケージに分けたうえで、BookRepository
というインタフェースを介して具体的なDBの処理は行う設計にしています。
こうすることで、パッケージの外から BookRepository
を介してDB処理をする際、repository
の中の実装を意識せずに済みます。
package model import "gorm.io/gorm" type Book struct { gorm.Model Title string Author string }
package repository import ( "gorm.io/gorm" "github.com/moritamori/gorm-testing/model" ) type BookRepositoryImpl struct { DB *gorm.DB } type BookRepository interface { Create(book *model.Book) error } func (bookRepo BookRepositoryImpl) Create(book *model.Book) error { cx := bookRepo.DB.Create(book) return cx.Error }
実行
実行すると、想定通り動いていることを確認できます。
$ go run main.go success!
テストを追加
サンプルアプリケーションにテストを追加しました。テストのセットアップ、データベース接続のクローズテストを共通化するために testify
を使っています。
package repository import ( "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/moritamori/gorm-testing/model" "github.com/stretchr/testify/suite" "gorm.io/driver/postgres" "gorm.io/gorm" ) // テストスイートの構造体 type BookRepositoryTestSuite struct { suite.Suite bookRepository BookRepositoryImpl mock sqlmock.Sqlmock } // テストのセットアップ // (sqlmockをNew、Gormで発行されるクエリがモックに送られるように) func (suite *BookRepositoryTestSuite) SetupTest() { db, mock, _ := sqlmock.New() suite.mock = mock bookRepository := BookRepositoryImpl{} bookRepository.DB, _ = gorm.Open(postgres.New(postgres.Config{ Conn: db, }), &gorm.Config{}) suite.bookRepository = bookRepository } // テスト終了時の処理(データベース接続のクローズ) func (suite *BookRepositoryTestSuite) TearDownTest() { db, _ := suite.bookRepository.DB.DB() db.Close() } // テストスイートの実行 func TestBookRepositoryTestSuite(t *testing.T) { suite.Run(t, new(BookRepositoryTestSuite)) } // Createのテスト func (suite *BookRepositoryTestSuite) TestCreate() { suite.Run("create a book", func() { newId := 1 rows := sqlmock.NewRows([]string{"id"}).AddRow(newId) suite.mock.ExpectBegin() suite.mock.ExpectQuery( regexp.QuoteMeta( `INSERT INTO "books" ("created_at",` + `"updated_at","deleted_at","title",` + `"author") VALUES ($1,$2,$3,$4,$5) ` + `RETURNING "id"`), ).WillReturnRows(rows) suite.mock.ExpectCommit() book := &model.Book{ Title: "Go言語の本", Author: "誰か", } err := suite.bookRepository.Create(book) if err != nil { suite.Fail("Error発生") } if book.ID != uint(newId) { suite.Fail("登録されるべきIDと異なっている") } }) }
testify
を使う利点
このように testify
を使うことでテストケースが増えた際、共通の関心事であるテストのセットアップ・終了時の処理のコードが重複しないテストコードを書けるというメリットがあります。
Gorm
を使う際の考慮
トランザクションの考慮
Gorm
は create/update/delete
に関してはデフォルトでトランザクションが有効になるため、トランザクションのテスト(suite.mock.ExpectBegin()
、suite.mock.ExpectCommit
)を書いています。
ExpectQuery
か ExpectExec
か
また、 postgresql
と Gormを一緒に使った注意点がこちらの記事に記載があって、通常Insert文は ExpectExec でクエリをチェックしますが postgresql
を使用する場合ExpectQuery でクエリをチェックしています。
In general, a non-query SQL expectation (e.g Insert/Update) should be defined by mock.ExpectExec, but this is a special case. For some reason, GROM uses QueryRow instead of Exec for thepostgres dialect (for more detail, please consult this issue).
Tip: Use mock.ExpectQuery for the GORM model insert if you’re using PostgreSQL.
という記載があり、参照先のGithub issueにも同様のコメントがあります。
Gorm V2におけるInsertの挙動
上記のissueが古く、Gorm v1だけに該当する話の可能性があったので、念の為Gorm v2のソースコードを用いてcreateするメソッド(CreateWithReturning)をデバッグ実行して確認したところ、テーブルスキーマにデフォルト値がある場合にQueryContext
を実行していたので、ExpectQuery
でクエリを指定するとマッチしそうです。
また、どのカラムにデフォルト値が付くか確認したところ、booksテーブルのgorm.Model追加されるIDカラムにデフォルト値が付いていたので、gorm.Model
を使っていればExpectQuery
で確認するのが良さそうです。
sqlmockを使う利点
sqlmockはデフォルトで期待する結果を厳密な順序で得られることを確認してくれるので、このサンプルでは
- suite.mock.ExpectBegin()
- suite.mock.ExpectQuery(regexp.QuoteMeta(
INSERT INTO "books" 〜
)) - suite.mock.ExpectCommit()
という順番でモックに送られることを期待します。
テスト実行
実行すると、OKが返ります。
$ go test ./... -v ? github.com/moritamori/gorm-testing [no test files] ? github.com/moritamori/gorm-testing/model [no test files] === RUN TestBookRepositoryTestSuite === RUN TestBookRepositoryTestSuite/TestCreate === RUN TestBookRepositoryTestSuite/TestCreate/create_a_book --- PASS: TestBookRepositoryTestSuite (0.00s) --- PASS: TestBookRepositoryTestSuite/TestCreate (0.00s) --- PASS: TestBookRepositoryTestSuite/TestCreate/create_a_book (0.00s) PASS ok github.com/moritamori/gorm-testing/repository 0.079s
今回のサンプルアプリケーション
コードをGithubにあげているので、もしよかったら参考にしてみてください! github.com