GoでDBのテストにgithub.com/cockroachdb/copyistを使う

久しぶりにMySQLを使ったシステムを触ることになったものの、現状のテストが遅すぎたので高速化に取り組むことにした。

遅い原因としては、

  • テストでDBを使うパッケージが多いので接続時間がそれなりになってしまう
  • テストケースごとにTRUNCATE→INSERTでデータを入れ直している

といったところ。
なので今回はDBにアクセスしないでテストができるようになるcopyistを試してみようと思う。

github.com

仕組みとしては実行されたSQLを記録しておき、以降はその時のデータを使うという

などと似たようなものとなる。

README.mdにはMySQLは非対応と書いてあったが、普通に使うことができた。
(もしかしたら一部正しく記録/再生できないクエリがあるのかもしれない)

MySQL版のサンプルコードはこちら。

mysql_test.go

package example

import (
    "database/sql"
    "log"
    "testing"

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

// GetName をテストする。
func GetName(db *sql.DB, id int64) (string, error) {
    var name string
    err := db.QueryRow("SELECT name FROM `user` WHERE id=?", id).Scan(&name)
    return name, err
}

// resetDBで使うためにグローバル変数にしておく。
var db *sql.DB

// 記録時にcopyist.Open()の中で実行される。
func resetDB() {
    db.Exec("DROP TABLE `user`")
    db.Exec("CREATE TABLE `user` (`id` INT(11) unsigned NOT NULL AUTO_INCREMENT, `name` VARCHAR(20) NOT NULL, PRIMARY KEY (`id`))")
    db.Exec("INSERT INTO `user` (id, name) VALUES (?, ?)", 1, "Andy")
}

func TestMain(m *testing.M) {
    // ドライバ名は"copyist_mysql"になる。
    copyist.Register("mysql", resetDB)

    // resetDBとテストで使うdbを作る。
    var err error
    db, err = sql.Open("copyist_mysql", "admin:pass@/copyist")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    m.Run()
}

func TestGetName(t *testing.T) {
    defer copyist.Open(t).Close()

    // found
    name, err := GetName(db, 1)
    if err != nil {
        t.Fatal(err)
    }
    if name != "Andy" {
        t.Error("failed test")
    }

    // not found
    if _, err := GetName(db, 100); err != sql.ErrNoRows {
        t.Error(err)
    }
}

// subtest版
func TestGetName_subtest(t *testing.T) {
    t.Run("found", func(t *testing.T) {
        defer copyist.Open(t).Close()

        name, err := GetName(db, 1)
        if err != nil {
            t.Fatal(err)
        }
        if name != "Andy" {
            t.Error("failed test")
        }
    })

    t.Run("not found", func(t *testing.T) {
        defer copyist.Open(t).Close()

        if _, err := GetName(db, 100); err != sql.ErrNoRows {
            t.Error(err)
        }
    })
}

1回目は以下のように -record をつけてテストを実行する。

$ go test -v -record
=== RUN   TestGetName
--- PASS: TestGetName (0.09s)
=== RUN   TestGetName_subtest
=== RUN   TestGetName_subtest/found
=== RUN   TestGetName_subtest/not_found
--- PASS: TestGetName_subtest (0.12s)
    --- PASS: TestGetName_subtest/found (0.06s)
    --- PASS: TestGetName_subtest/not_found (0.06s)
PASS

そうするとtestdataディレクトに以下のようなmysql_test.copyistが生成される。

1=DriverOpen 1:nil
2=ConnPrepare   2:"SELECT name FROM `user` WHERE id=?"  1:nil
3=StmtNumInput  3:1
4=StmtQuery 1:nil
5=RowsColumns   9:["name"]
6=RowsNext  11:[10:QW5keQ]  1:nil
7=RowsNext  11:[]   7:EOF

"TestGetName"=1,2,3,4,5,6,2,3,4,5,7
"TestGetName_subtest/found"=1,2,3,4,5,6
"TestGetName_subtest/not_found"=1,2,3,4,5,7

この状態で -record をつけずに実行すると↑のデータが使われる。

$ go test -v
=== RUN   TestGetName
--- PASS: TestGetName (0.00s)
=== RUN   TestGetName_subtest
=== RUN   TestGetName_subtest/found
=== RUN   TestGetName_subtest/not_found
--- PASS: TestGetName_subtest (0.00s)
    --- PASS: TestGetName_subtest/found (0.00s)
    --- PASS: TestGetName_subtest/not_found (0.00s)
PASS

なお、記録する前に -record 無しで実行するとpanicになる。

$ go test -v
=== RUN   TestGetName
--- FAIL: TestGetName (0.00s)
panic: no recording exists with this name: TestGetName [recovered]
        panic: no recording exists with this name: TestGetName

また、以下のようにクエリを変えて、

--- a/mysql_test.go
+++ b/mysql_test.go
@@ -12,7 +12,7 @@ import (
 // GetName をテストする。
 func GetName(db *sql.DB, id int64) (string, error) {
        var name string
-       err := db.QueryRow("SELECT name FROM `user` WHERE id=?", id).Scan(&name)
+       err := db.QueryRow("SELECT name FROM `user` WHERE id=? AND TRUE", id).Scan(&name)
        return name, err
 }

記録し直さずにそのまま実行してもpanicになる。

$ go test -v
=== RUN   TestGetName
--- FAIL: TestGetName (0.00s)
panic: mismatched argument to ConnPrepare, expected SELECT name FROM `user` WHERE id=? AND TRUE, got SELECT name FROM `user` WHERE id=? - regenerate recording [recovered]
        panic: mismatched argument to ConnPrepare, expected SELECT name FROM `user` WHERE id=? AND TRUE, got SELECT name FROM `user` WHERE id=? - regenerate recording

ただし、プレースホルダーの値が変わっても返ってくるレコードは変わらないのでその点は注意が必要。
例えば以下のようにidを逆にしてもテストはPassしてしまう。

--- a/mysql_test.go
+++ b/mysql_test.go
@@ -45,7 +45,7 @@ func TestGetName(t *testing.T) {
        defer copyist.Open(t).Close()

        // found
-       name, err := GetName(db, 1)
+       name, err := GetName(db, 100)
        if err != nil {
                t.Fatal(err)
        }
@@ -54,7 +54,7 @@ func TestGetName(t *testing.T) {
        }

        // not found
-       if _, err := GetName(db, 100); err != sql.ErrNoRows {
+       if _, err := GetName(db, 1); err != sql.ErrNoRows {
                t.Error(err)
        }
 }
@@ -63,7 +63,7 @@ func TestGetName_subtest(t *testing.T) {
        t.Run("found", func(t *testing.T) {
                defer copyist.Open(t).Close()

-               name, err := GetName(db, 1)
+               name, err := GetName(db, 100)
                if err != nil {
                        t.Fatal(err)
                }
@@ -75,7 +75,7 @@ func TestGetName_subtest(t *testing.T) {
        t.Run("not found", func(t *testing.T) {
                defer copyist.Open(t).Close()

-               if _, err := GetName(db, 100); err != sql.ErrNoRows {
+               if _, err := GetName(db, 1); err != sql.ErrNoRows {
                        t.Error(err)
                }
        })

細かい部分はまだ何とも言えないが、とりあえず使えそうだし高速化にも期待ができそう。

さて、あとはこれをどうやって既存のテストに組み込んでいくか。
既存のテストではxormが使われているが、copyistに対応していないので少し特殊な初期化をしないといけないようだ。

engine, err := xorm.NewEngine("mysql", "")
// エラーハンドリングやengineの設定など
// ...

// DBをsql.Open("copyist_mysql", ...)したものと差し替える
engine.DB().DB = db

これを何とかするのが一番大変なのかもしれない...