GoのSQL driver mock `go-sqlmock` を試す
golang
Lastmod: 2020-10-19

go-sqlmock とは

go-sqlmock は Go の database/sql/driver の実装で、DB ドライバの振る舞いをモック化できるライブラリ。 go-sqlmock を使用することで、DB ドライバを必要するロジックと、実際の DB ドライバ以降の処理を分離してテストできる。

go-sqlmock の特徴

DB ドライバに対するクエリ単位でモック設定ができる

sql.DB::Querysql.DB::Exec に指定する個々のクエリの内容ごとに返戻する行や結果を指定できる。 複数パターンのクエリに対して同一の結果を返したい場合も、正規表現で指定可能。

DB ドライバに対する関数呼び出しの順序検証ができる

ドライバのモック化だけでなく、ドライバモックに対して呼ばれたクエリやトランザクション処理の順序を検証する機能も持つ。

その他特徴

  • 安定版である
  • 並行性と同時接続サポート
  • Go1.8 以降の Context に対応:SQL パラメータの命名とモッキング
  • 仕様元ソースコードの変更必要なし
  • どの sql/driver メソッドの振る舞いもモック化できる
  • 厳密な期待順序マッチングを搭載
  • サードパーティライブラリへの依存なし

Usage

Install

$ go get github.com/DATA-DOG/go-sqlmock

検証のため、今回は MySQL ドライバを使用。

$ go get github.com/go-sql-driver/mysql

検証用コード

今回使用する検証用コードは以下に置いてあります。
rennnosuke/go-playground/go-sqlmock

取得クエリ(SELECT)の検証

テスト対象コード

DB ドライバを使用するコードを用意する。

import (
	"database/sql"
	"fmt"

	_ "github.com/go-sql-driver/mysql"
)

type Article struct {
	ID      int
	Title   string
	Content string
}

func GetByID(id int, db *sql.DB) (*Article, error) {
	row := db.QueryRow("SELECT * FROM ARTICLES WHERE ID = ? AND IS_DELETED = 0", id)

	e := Article{}
	if err := row.Scan(&e.ID, &e.Title, &e.Content); err != nil {
		return nil, fmt.Errorf("failed to scan row: %s", err)
	}

	return &Article{ID: e.ID, Title: e.Title, Content: e.Content}, nil
}

テストコード

ドライバを使用する実装に対する、go-sqlmock を使用したテストコードを用意。

import (
	"fmt"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
)

func TestSQLMock_Select(t *testing.T) {

	// モックDBの初期化
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("failed to init db mock")
	}
	defer db.Close()

	id := 1

	// dbドライバに対する操作のモック定義
	columns := []string{"id", "title", "content"}
	mock.ExpectQuery("SELECT (.+) FROM ARTICLES"). // expectedSQL: 想定される実行クエリをregexpで指定(指定文字列が含まれるかどうかを見る)
		WithArgs(id).                                          // 想定されるプリペアドステートメントへの引数
		WillReturnRows(sqlmock.NewRows(columns).AddRow(1, "test title", "test content")) // 返戻する行情報の指定

	// テスト対象関数call
	article, err := GetByID(id, db)
	if err != nil {
		t.Fatalf("failed to get article: %s", err)
	}
	fmt.Printf("%v", article)

	// mock定義の期待操作が順序道理に実行されたか検査
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("failed to ExpectationWerMet(): %s", err)
	}
}

モック DB の初期化

sqlmock.New() ファクトリ関数で sql.DB 値への参照と、モックを定義するための SqlMock 型の値を取得できる。 sql.DB は DB ドライバを使用するロジックに引き渡し、DB ドライバ以降の処理のモックとして使用される。 SqlMock は主にテスト関数内で、想定される DB ドライバに対する操作と、それらに対する結果を定義する。

// モックDBの初期化
db, mock, err := sqlmock.New()

ドライバへの想定操作と結果の定義

次に、DB ドライバに対してどのようなクエリが実行されるのかを SqlMock::ExpectQuery() 関数で定義する。 この定義は後述する SqlMock::ExpectationsWereMet() の検証で使用される。
指定するクエリ文字列は regexp パッケージ準拠の正規表現で記述できる。またデフォルト設定の場合、指定した文字列が実際に呼び出されたクエリに部分マッチすれば検証は Pass される。

クエリにプリペアドステートメントが指定されている場合、WithArts で実際に挿入する値を指定できる。

また、各々の操作に対してどのような結果を返すのかを SqlMock::WillReturnRows() で定義している。 返す行のカラムを NewRows() で、行データを *Rows.AddRow() で指定している。

// dbドライバに対する操作のモック定義
columns := []string{"id", "title", "content"}
mock.ExpectQuery("SELECT (.+) FROM ARTICLES"). // expectedSQL: 想定される実行クエリをregexpで指定(指定文字列が含まれるかどうかを見る)
    WithArgs(id).                                          // 想定されるプリペアドステートメントへの引数
    WillReturnRows(sqlmock.NewRows(columns).AddRow(1, "test title", "test content")) // 返戻する行情報の指定

実際のクエリ呼び出し検証

ExpectationsWereMet checks whether all queued expectations were met in order. If any of them was not met - an error is returned.

SqlMock::ExpectationsWereMet() は、呼び出した SqlMock::ExpectXXX に即する操作が、実際に DB ドライバ( sqlmock.New() から返戻された sql.DB )に順番どおりに実行されたかどうかを検証する。

// mock定義の期待操作が順序道理に実行されたか検査
if err := mock.ExpectationsWereMet(); err != nil {
    t.Fatalf("failed to ExpectationWerMet(): %s", err)
}

永続化処理(INSERT)の検証

テスト対象コード

func Create(id int, title, content string, db *sql.DB) error {
	tx, err := db.Begin()
	defer func() {
		switch err {
		case nil:
			tx.Commit()
		default:
			tx.Rollback()
		}
	}()

	if err != nil {
		return err
	}

	_, err = tx.Exec("INSERT INTO ARTICLES (ID, TITLE, CONTENT) VALUES (?, ?, ?)", id, title, content)
	if err != nil {
		return fmt.Errorf("failed to insert article: %s", err)
	}

    return nil
}

テストコード

func TestSQLMock_Insert(t *testing.T) {

    // モックDBの初期化
    // ...

	id := 1
	title := "test title"
	content := "test content"

	// dbドライバに対する操作のモック定義
	mock.ExpectBegin()
	mock.ExpectExec("INSERT INTO ARTICLES").      // 想定される実行SQLをregexpで指定(指定文字列が含まれるかどうかを見る)
		WithArgs(id, title, content).             // 想定されるプリペアドステートメントへの引数
		WillReturnResult(sqlmock.NewResult(1, 1)) // 想定されるExec関数の結果を指定
	mock.ExpectCommit()

    // テスト対象関数call
    // ...

    // mock定義の期待操作が順序道理に実行されたか検証
    // ...
}

ドライバへの想定操作と結果の定義

SELECT 検証時と異なる点は 2 つ。

  1. トランザクション呼び出しの定義 永続化処理などでトランザクション開始終了処理 Begin() Commit() RollBack() を呼ぶとき、それに合わせて SqlMock.ExpectBegin() SqlMock.ExpectCommit() SqlMock.ExpectRollback() を呼ぶ。

  2. Exec 関数呼び出しの定義 永続化処理では sql.DB::Query() ではなく sql.DB::Exec() を使用しているため、 SqlMock.ExpectExec() を使用している。また返戻値が行ではなく更新結果となるため、これを WillReturnResult() で指定し、返戻値の内容を NewResult() で定義している。

// dbドライバに対する操作のモック定義
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO ARTICLES").      // 想定される実行SQLをregexpで指定(指定文字列が含まれるかどうかを見る)
    WithArgs(id, title, content).             // 想定されるプリペアドステートメントへの引数
    WillReturnResult(sqlmock.NewResult(1, 1)) // 想定されるExec関数の結果を指定
mock.ExpectCommit()

UPDATE 処理を実装した場合も、基本検証は INSERT とほぼ同じ流れになる。

クエリマッチングルールの変更

SqlMock::ExpectQuery() SqlMock::ExpectExec() でクエリに含まれるべき文字列を指定する事ができたが、このルールは sqlmock.New()sqlmock.QueryMatcherOption() 返戻値を指定することで変更可能になる。

db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))

sqlmock.QueryMatcherOption() には以下の QueryMatcher が指定できる。

  • sqlmock.QueryMatcherRegexp : 想定される SQL 文字列を正規表現として使用し、実行クエリの文字列と照合する
  • sqlmock.QueryMatcherEqual : 想定される SQL 文字列と実行クエリを大文字・小文字含め完全一致で照合する

この QueryMatcher は QueryMatcherFunc 型で、この型にキャスト可能な関数を定義すれば独自の QueryMatcher を定義できる。

type QueryMatcherFunc func(expectedSQL, actualSQL string) error

所感

  • テストケースごとに個別にクエリ結果を設定できるのは柔軟でよい
  • sql.DB 関数呼び出し順検証、DB 周りの処理が頻繁に変更しない前提ならば便利かも
  • 逐一想定クエリを記述するのはちょっと面倒
  • RDB のテーブルスキーマやリレーションは再現できないので、実動環境と乖離しない定義を書くことが求められる1

参考文献


  1. DBMS などの機能も含めて検証したい場合、モックではなく素直に検証用 DB 含めてテストしたほうが良いかも。モックの宿命 [return]