最終更新:2020/09/17
sqlmockとは
sqlmockはsql/driverを実装するモックライブラリです。
sqlmockを使うとデータベースに接続しないでsqlドライバーの動きをシュミレートすることができます。
実際のDBに接続する必要がないので、高速にテストが実行できます。
なので、SQLに関する基本的なテストはなるべくsqlmockを使うべきです。
目次
- gorm version1でsqlmockを使う
- gorm version2でsqlmockを使う
- select(Query)文をテストする
- insert or update or delete 文の成功パターンをテストする
- insert or update or delete 文の失敗パターンをテストする
- select(Query)文の検索条件INをテストする
- count文をテストする
- updatedat or createdatなどのtimestamp値をテストする
- ハマるパターン
1. gorm version1でsqlmockを使う
gormのversion1でsqlmockを使う場合は、以下のように実装します。
import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/jinzhu/gorm"
)
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
dbMock, err := gorm.Open("mysql", db)
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
gormのOpenメソッドに"mysql"の文字列を利用しています。 これは、DBにmysqlを使うからです。
他のDBの場合は、"postgres", "sqlite3"などのように、利用しているDBの名称を指定します。
2. gorm version2でsqlmockを使う
gormのversion2でsqlmockを使う場合は、以下のように実装します。
import (
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
db, mock, err := sqlmock.New()
if err != nil {
panic(err.Error())
}
dbClientMock, err := gorm.Open(
mysql.Dialector{Config: &mysql.Config{DriverName: "mysql", Conn: db, SkipInitializeWithVersion: true}}, &gorm.Config{},
)
gorm version2はgorm version1をリライトした新しいライブラリなのでパッケージが異なります。 そのため、importや実装方法も異なります。
gorm version1からgorm version2への更新方法が知りたい人はこちらの記事を参考にして ください
3. select(Query)文をテストする
DBを利用するアプリの場合、findByIDのようなメソッドを作って、IDで検索するクエリを発行するメソッドをよく作ります。
func (u usersUseCaseImpl) FindByID(userID int) (*model.User, error) {
user, err := u.usersRepository.FindByID(employeeID)
if err != nil {
return nil, err
}
return user, nil
}
上記のメソッドは
SELECT * FROM `users` WHERE `users`.`deleted_at` IS NULL AND id = {userID}
のSQLを発行して、userオブジェクトに結果を格納します。
その場合、sqlmockでは以下のようにテストコードを記載します。
func TestUsersPersistenceFindByID(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)
}
dbClientMock, err := gorm.Open("mysql", db)
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
userID := 641
// result mock
rows := sqlmock.NewRows([]string{"id"}).
AddRow(userID)
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE `users`.`deleted_at` IS NULL AND ((id = ?))")).WithArgs(userID).WillReturnRows(rows)
persistence := persistence.NewUsersPersistence(dbClientMock)
_, error := persistence.FindByID(userID)
if error != nil {
t.Errorf("error was not expected while updating stats: %s", error)
}
}
以下で具体的に説明します。
3-1. 結果モックデータの生成
クエリが返す結果データのモックは以下のように作成します。
rows := sqlmock.NewRows([]string{"id"}).
AddRow(userID)
3-2. 期待するクエリ
ExpectQueryで発行を期待するクエリを記載します。
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE `users`.`deleted_at` IS NULL AND ((id = ?))"))
QuoteMetaを使うのは、sqlには * や 括弧を考慮するためです。
? はパラメーターです。
パラメーターの指定はWithArgsメソッドを使います。
WithArgs(userID)
とすると、 ? の部分に指定した値が入ります。
上記の場合だと
userID := 641
なので
SELECT * FROM `users` WHERE `users`.`deleted_at` IS NULL AND ((id = 641))
というsqlが発行されることが期待されているということをテストしていることになります。
3-3. 期待する結果
WillReturnRowsメソッドで期待する結果を指定します。
WillReturnRows(rows)
これは、上記の結果モックデータの生成で設定した値です。
これでmockクエリの結果を指定したことになります。
4. insert or update or delete 文の成功パターンをテストする
insert、update、delete文は、登録、更新、削除の時に利用します。
クエリとの違いは、データが変化したり、トランザクションのコミットやロールバックが必要になることです。
sqlmockはトランザクションのコミットやロールバックもサポートしています。
例として
DELETE FROM `users` WHERE (id = ?)
のsqlmockを使ってテストしてみます。
func TestUsersDeleteByIDOnComplete(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)
}
dbClientMock, err := gorm.Open("mysql", db)
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
userID := 641
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM `users` WHERE (id = ?)")).WithArgs(userID).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
persistence := persistence.NewUsersPersistence(dbClientMock)
if err = persistence.DeleteByID(userID); err != nil {
t.Errorf("error was not expected while updating stats: %s", err)
}
if err = mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
以下で具体的に説明します。
4-1. トランザクション開始
insert or update or delete分はトランザクションを扱います。
sqlmockではトランザクションの開始を明示する必要があります。
// トランザクション開始
mock.ExpectBegin()
これでトランザクション開始の合図になります。
4-2. 期待するSQL
次に発行するinsert、update、delete文を記載します。
// トランザクション開始
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM `users` WHERE (id = ?)")).WithArgs(userID).WillReturnResult(sqlmock.NewResult(1, 1))
クエリ(select)は
mock.ExpectQuery
でしたが、insert、update、delete文は
mock.ExpectExec
を利用します。
他はクエリの場合と同じです。
4-3. コミット
insert、update、delete文がデータに反映されるにはコミットが必要です。
sqlmockではコミットもコードで明示的に記載する必要があります。
mock.ExpectCommit()
これでSQLが発行されて成功したと明示したことになります。
4-4. 結果
SQLの期待結果は
mock.ExpectationsWereMet()
で取得します。
5. insert or update or delete 文の失敗パターンをテストする
func TestUsersDeleteByIDOnFailure(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)
}
dbClientMock, err := gorm.Open("mysql", db)
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
userID := 641
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM `users` WHERE (id = ?)")).WithArgs(userID).WillReturnError(fmt.Errorf("some error"))
mock.ExpectRollback()
persistence := persistence.NewUsersPersistence(dbClientMock)
if err = persistence.DeleteByID(userID); err == nil {
t.Errorf("error was not expected while updating stats: %s", err)
}
if err = mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
成功パターンと異なる部分は以下です。
5-1. WillReturnError
エラーを発生させる必要があるので、
.WillReturnError(fmt.Errorf("some error"))
でmockがエラーを返すようにします。
5-2. ExpectRollback
sqlの結果が失敗したらコミットしてはいけないので、
mock.ExpectRollback()
を実装して、ロールバックが発生するようにします。
5-3. err == nil
WillReturnErrorでエラーを発生させているので結果にエラーが返ります。
なので、エラーがnilではおかしいですね。
if err = persistence.DeleteByID(userID); err == nil {
t.Errorf("error was not expected while updating stats: %s", err)
}
以上のチェックが通ればロールバックの処理がうまくいっていることを確認できます。
6. select(Query)文の検索条件INをテストする
sqlの検索条件がINの場合、gormでは以下のように実装します。
// FindByIDs finds Users.
func (p IUserRepositoryImpl) FindByIDs(IDs []int) (*[]model.User, error) {
users := &[]model.User{}
if err := p.db.Where("id IN (?)", IDs).Find(users).Error; err != nil {
return nil, err
}
return users, nil
}
上記のコードは
SELECT * FROM `users` WHERE `users`.`deleted_at` IS NULL AND id IN (1,2,3)
のSQLを発行します。
このsqlをsqlmockで試験コードを書く場合は、IN (?) でなく、 IN (?,?,?) にように細かく全てのパラメーターを指定します。
rows := sqlmock.NewRows([]string{"id", "name"}).
AddRow(1, "sato").AddRow(1, "smith")
query := "SELECT * FROM `users` WHERE (id IN (?,?,?)) "
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(1, 2, 3).WillReturnRows(rows)
7. count文をテストする
count文の場合、gormでは以下のように実装します。
// CountByCategoryID counts Article objects of categoryID.
func (p *articlesRepositoryImpl) CountByCategoryID(categoryID int) (int, error) {
var count int
if err := p.db.Model(&model.Article{}).Where("category_id = ?", categoryID).Count(&count).Error; err != nil {
return -1, err
}
return count, nil
}
上記のコードは以下のSQLを発行します。
SELECT count(*) FROM `articles` WHERE (category_id = ?)
sqlmockでは以下のようにテストコードを書きます。
func TestArticlesRepositoryCountByCategoryID(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)
}
dbClientMock, err := gorm.Open("mysql", db)
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
categoryID := 100
rows := sqlmock.NewRows([]string{"count"}).
AddRow(2)
query := "SELECT count(*) FROM `articles` WHERE (category_id = ?) "
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(categoryID).WillReturnRows(rows)
persistence := persistence.NewArticlesRepository(dbClientMock)
_, error := persistence.CountByCategoryID(categoryID)
if error != nil {
t.Errorf("error was not expected. cause: %s", error)
}
}
select文なのでExpectQueryでコードを書きます。
8. updatedat or createdatなどのtimestamp値をテストする
updatedatやcreatedatは更新や登録時の時間を記録します。
なので、テストコードを実行すると、時間の部分がずれてSQL文の不一致エラーになります。
これを解決するには以下の方法をとる必要があります。
- AnyTime struct{}を定義する
- Matchを定義する
- WithArgsでAnyTime{}を指定する
実装すると以下のようになります。
type AnyTime struct{}
func (a AnyTime) Match(v driver.Value) bool {
_, ok := v.(time.Time)
return ok
}
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `users` (`id`,`name`,`created_at`,`updated_at`) VALUES (?,?,?,?)")).WithArgs(
user.ID,
user.Name,
AnyTime{},
AnyTime{},
これで試験が通るようになります。
また、typeとfuncは他のクラスのテストでも必要になるので、SQLテストをする共通パケージの共通クラスに外だししておいた方が可読性が上がります。
package persistence_test
import (
"database/sql/driver"
"time"
)
// updated_at or created_atなどのtimestamp値をテストする
type AnyTime struct{}
func (a AnyTime) Match(v driver.Value) bool {
_, ok := v.(time.Time)
return ok
}
9. ハマるパターン
swlmockでSQLのテストをしていると、sqlがなぜか一致せず、時間を浪費してしまうことがあります。
修正した試験コードがうまく通らない場合は、以下の箇所を見直してみてください。
9-1. sqlの大文字小文字が異なる
sqlmockのsqlは大文字と小文字を厳密にチェックします。 なので、WHERE文をwhereなどにしてしまってエラーが発生する場合があります。