はじめに
Go言語のDBモックライブラリ(sqlmock)が体系的にまとまっている日本語ドキュメントが見つからなかったため、まとめました。
便利ですが、若干使い方に癖が強い部分(特に正規表現のところ)がありますので解説していきます。
ちなみにですが、私はGORMを使用しており、GORMに依存する部分も一部あるかもしれません。
sqlmockとは
sqlmockは本物のDBの代わりにSQLドライバのような振る舞いをしてくれるモックのライブラリです。
テスト時に本物のDBを使う必要がなくなるため、「DBに入っているデータをバックアップ→テストに必要な前提データを挿入→テスト終わったらバックアップしたデータを元に戻す」みたいなことが必要なくなります。そのため、DBを使った関数のUT(IT)を手軽に高速にテストできます。
実際にデータが挿入されたり検索されたりするわけではなく、SQLドライバの返り値にのみ注目して正常性を確認しているというところを理解するのがポイントです。
インストール
$ go get github.com/DATA-DOG/go-sqlmock
主要な関数について
sqlmockでよく使う関数について説明します。
呼び出すモック関数の順番は、テスト対象のプロダクションコードでの処理の順番に合わせる必要があります。
例えば、あるテストケースでのプロダクションコード側でのDB処理が「トランザクション開始→SELECT→SELECT→INSERT→COMMIT」という流れであれば、DBモック側も「トランザクション開始→SELECT→SELECT→INSERT→COMMIT」の順でモックを設定する必要があります。
New関数
New関数で空のモックを生成します。
戻り値の1つ目(db)はモックDB接続、戻り値の2つ目(mock)はモック設定を行うために使用します。
db, mock, err := sqlmock.New()
ExpectBegin関数
ExpectBegin関数でトランザクションの開始を期待します。
mock.ExpectBegin()
CRUD処理
SELECT/INSERT/UPDATE/DELETE文の処理をモック化します。
ExpectQuery関数
ExpectQuery関数は、期待するSELECT文と取得結果の組み合わせをモック化します。
- ExpectQuery関数の引数には、期待するSQLクエリ(SELECT文)を指定します。
- regexp.QuoteMeta関数を使うことでsqlmockの正規表現マッチを楽に記述できます。
- 正規表現ポイントメモ
- テーブル名には""つける。カラム名には""つけない。
SELECT order_date FROM "orders"
- ただし、1カラムのみのORDER BYのときは""つける。(謎ルール)
SELECT * FROM "order_details" WHERE (order_id = ?) ORDER BY "id"
SELECT * FROM "order_details" WHERE (order_id = ?) ORDER BY id, order_date
- ↑GORMが発行するクエリが原因ぽい。また、カラム名がスネークタイプだとダブルクォテーションは不要みたいです。
- テーブル名には""つける。カラム名には""つけない。
- クエリは正規表現マッチなので全文でなくても問題ありませんが、下記の理由により、なるべく全文書く。
- クエリが中途半端に終わっているとエラーになります。例えば、SELECT order_date FROM
はNG。FROM書いたならテーブル名も必要。例えば、SELECT * FROM "orders WHERE id"
はNG。WHERE句まで指定したなら値も指定しなければダメ。
- あまりにも短いと何のテストなのかぱっと見分からなくなります。
- WithArgs関数の引数には、期待するクエリパラメータの値を指定します。省略した場合はどの引数の値でもモックにします。
- WillReturnRows関数の引数には、SQLドライバとしての返り値としてNewRows関数を指定します。省略した場合は、取得結果が空になります。
- NewRows関数の引数には、期待する取得結果に必要なカラムを指定します。たとえ想定クエリが
*
だとしても、モックとして必要なカラムだけを指定すればよいです。 - AddRow関数の引数には、期待するカラムの値を指定します。1つのSQLクエリで複数行返すモックを作る場合はAddRow関数をその数だけ指定します。
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1).AddRow(2))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM students WHERE id = ?`).
WithArgs(1).
WillReturnRows(NewRows([]string{"id", "name"}).AddRow(1, "james"))
ExpectExec関数
ExpectExec関数は、期待するUPDATE、INSERT、DELETE文と返り値の組み合わせをモック化します。
ExpectQuery関数ほど、返り値は重要ではありません。
- ExpectExec関数の引数には、期待するSQLクエリ(UPDATE、INSERT、DELETE文)を指定します。
- regexp.QuoteMeta関数を使うことでsqlmockの正規表現マッチを楽に記述できます。
- テーブル名には""つける。カラム名にも""つける。ただし、WHERE句では付けない。
UPDATE "orders" SET "status" = ? WHERE (id IN (?))
- WithArgs関数の引数には、期待するクエリパラメータの値を指定します。省略した場合はどの引数の値でもモックにします。
- WillReturnResult関数の引数には、SQLドライバとしての返り値としてNewResult関数を指定します。
- NewResult関数の引数には、期待するカラムの値を指定します。第一引数には「主キーの自動生成IDの値」、第二引数には「本クエリによって影響を受けるカラムの数」をそれぞれ指定します。
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "orders" SET "status" = ? WHERE (id IN (?))`)).
WithArgs("shipping", 1010).
WillReturnResult(sqlmock.NewResult(1010, 1))
ExpectCommit関数
ExpectCommit関数は、コミットを期待します。
mock.ExpectCommit()
ExpectRollback関数
ExpectRollback関数は、ロールバックを期待します。
mock.ExpectRollback()
ExpectationsWereMet関数
ExpectationsWereMet関数は、期待値と実際値が一致していたかを確認します。
一致していなかった場合は、エラーを返します。
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
具体例
READMEに記載されている簡単な例です。
package main
import "database/sql"
// テスト対象関数。関数内ではUPDATE文とINSERT文を実行している。
func recordStats(db *sql.DB, userID, productID int64) (err error) {
tx, err := db.Begin()
if err != nil {
return
}
defer func() {
switch err {
case nil:
err = tx.Commit()
default:
tx.Rollback()
}
}()
if _, err = tx.Exec("UPDATE products SET views = views + 1"); err != nil {
return
}
if _, err = tx.Exec("INSERT INTO product_viewers (user_id, product_id) VALUES (?, ?)", userID, productID); err != nil {
return
}
return
}
肝心のテストファイルがこちら
package main
import (
"fmt"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
// 正常系
func TestShouldUpdateStats(t *testing.T) {
// DBモック用意
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()
// モック化されたDBを用いてテスト対象関数を実行
if err = recordStats(db, 2, 3); err != nil {
t.Errorf("error was not expected while updating stats: %s", err)
}
// 使用されたモックDBが期待通りの値を持っているかを検証
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
// 異常系
func TestShouldRollbackStatUpdatesOnFailure(t *testing.T) {
// DBモック用意
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).
WillReturnError(fmt.Errorf("some error"))
mock.ExpectRollback()
// モック化されたDBを用いてテスト対象関数を実行
if err = recordStats(db, 2, 3); err == nil {
t.Errorf("was expecting an error, but there was none")
}
// 期待通りの返り値かを照合
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
エラー発生時の例
テスト対象コードの挙動とUTで想定していたsqlmockが合わなかった場合の例です。
[2018-05-21 23:00:00] call to Query 'SELECT * FROM "sample_table1" WHERE (product_code = ?)' with args [{Name: Ordinal:1 Value:sample_value}], was not expected, next expectation is: ExpectedExec => expecting Exec or ExecContext which:
- matches sql: 'INSERT INTO "sample_table2"'
- is without arguments
- should return Result having:
LastInsertId: 1
RowsAffected: 1
上記のエラーログでは、「君はsample_table2へのINSERTを期待していたみたいだけど、実際にはsample_table1へのSELECTが実行されているぜ!」と言っています。
どっちがexpectedでどっちがactualのSQLクエリなのかちょっと分かりづらいですよね。。。
その他
副問い合わせのモック
副問い合わせのモックは、難しく考える必要なく、想定される副問い合わせのSQLクエリを書けばよいだけです。
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "orders" WHERE (id = (SELECT order_id FROM "order_details" WHERE (status = ? AND customer_id = ?)))`)).
WithArgs("ordered", 111).
WillReturnRows(sqlmock.NewRows([]string{"product_code"}).
AddRow("sample_product_code"))
所感
sqlmockのログが少しいまいちで、何が原因でエラーになっているのかがわかりづらいです。
そのため、エラー原因がsqlmock側の問題なのか、プロダクションコード側の問題なのかがわからなくて結構ハマりました。
とはいえ、本物DBと接続せずにある程度の安心感を得られるテストコードをかけるため便利です。これからも使っていきます!不満点はPR出せるように頑張る。
さいごに
regexp.QuoteMeta内で、""つけるべきかどうかは、結構適当です。もう少し使ってみて、わかったことは追記・修正していきます。