2020-07-29

[Golang] gorpでDBを使ったユニットテストを書くたったひとつの冴えたやりかた

(エヴァは見たことないです。2回目)

今の現場で DB を使ったユニットテスト環境を整備させてもらったのでどうやって実現したかを共有します。

ちゃんと記事にする許可はもらってるから大丈夫だ問題ない。

この記事は gorp を使う前提で書きますが、条件さえ整えば他のORMでも実現可能です。実際、最初は SQLBoiler 環境でセットアップしました。 条件というのをこの時点で書いても意味不明だと思うので追って説明します。

概要

DBを使ったテストのやり方を考えるといくつか方法が見つかります。

まず、sqlmock を使って期待するSQLが発行されるかを監視する方法。正確にはDBは使いませんが... 今の現場は前までこれでした。DBを用意しなくてよいのはたしかにメリットですが、SQLが複雑になると何が正なのか把握するのが難しくなりますし、 発行するSQLの順番やスペースの有無とかでテストが落ちるのでじわじわ辛くなってきます。 SQLの差分を追うのは人間様には向いていません。

つづいて、予めテスト用のフィクスチャデータを投入して全てのテストケースで使い回すという方法。これは短期的には楽ですが、 ケースごとにデータの整合性を保つのが大変になってきます。 他のケース用に投入した(あるいは削除した)データのせいで既存のテストが動かなくなるなんてよくあることです。 テストが多くなるほど保守が難しくなります。

今回はテストケースごとにトランザクションを張り、終了後に破棄することでDBを常にクリーンに保つという方法をとります。 ただ、このやり方にも手間や落とし穴があるのでそれを軽減、回避する策を併せて解説していきます。

セットアップ

この記事の内容を手元で試したいという方はリポジトリをクローンして コンテナ起動して入りましょう。

https://github.com/righ/gorp-tips

$ docker-compose up # -d $ # デーモン起動しない場合は別シェルで実行すること $ docker exec -it gorp /bin/bash # cd src/

これで準備完了です。

gorp の クエリはテンプレートに書いたほうがわかりやすいと思いました。 を解説するために作ったリポジトリなので必要最低限のコード というわけではありません。他の件でなにかあれば今後も追記するかも。

興味ある方はぜひそちらも是非見てくださいね〜

必要なパーツを準備していく

ネスト可能なトランザクション

一口にトランザクションのロールバックによってDBをきれいに保つと言っても、そう簡単にはいきません。 問題になるのは対象関数内でもトランザクションを使っている場合です。

これの対処としてトランザクションをネストさせるようにしました。

info
  • 実際、Djangoというフレームワークではトランザクション内でトランザクションを発行すると自動的に ネストされます

とはいっても、トランザクションにはネストという機能はないので SAVEPOINT というものを使って擬似的にネストを実現しています。 この辺の詳細な解説は Golang でトランザクションをネストさせるたった一つの冴えたやり方 という記事を書いたので参照してください。(記事タイトルはこれに合わせてみた)

実際のコードは以下のような感じになります。

transaction.go
package db

import (
	"strconv"

	"github.com/go-gorp/gorp"
)

type NestableTx struct {
	*gorp.Transaction

	savePoint int
	next      *NestableTx
	resolved  bool
}

func (tx *NestableTx) Begin() (*NestableTx, error) {
	tx.next = &NestableTx{
		Transaction: tx.Transaction,
		savePoint:   tx.savePoint + 1,
	}
	if err := tx.Savepoint("SP" + strconv.Itoa(tx.next.savePoint)); err != nil {
		return nil, err
	}
	return tx.next, nil
}

func (tx *NestableTx) Rollback() error {
	tx.resolved = true
	if tx.savePoint > 0 {
		return tx.RollbackToSavepoint("SP" + strconv.Itoa(tx.savePoint))
	}
	return tx.Transaction.Rollback()
}

func (tx *NestableTx) Commit() error {
	if tx.next != nil && !tx.next.resolved {
		if err := tx.next.Commit(); err != nil {
			return err
		}
	}
	tx.resolved = true

	if tx.savePoint > 0 {
		return tx.ReleaseSavepoint("SP" + strconv.Itoa(tx.savePoint))
	}
	return tx.Transaction.Commit()
}

(軽く解説) NestableTx と名付けた構造体に gorp.Transaction という gorp のトランザクションを埋め込みます。 こいつは gorp.Transaction と同じように振る舞いつつも、すでにトランザクションが開始されている状態で Begin が呼ばれるとトランザクションではなく セーブポイント を新たに作ります。

CommitRollback は セーブポイントの ReleaseRollback に対応させています。

これによりトランザクション内で新たなトランザクションを擬似的に扱うことができるようになります。

ファクトリ

テストケースごとにDBが初期化されるのでテストデータは私達が毎回登録する必要がありますが、 必要のないテーブルの依存関係を満たしつつレコードを何件もインサートするのはちょっと辛いですよね。

実は Ruby(Rails) だと factorybot (これはよくしらない) Pythonだと factoryboy という呼び出すだけで依存関係を含めてレコードを作ってくれる便利なやつがあります。

しかし、残念ながらGoには今の所そこまでのものはありません。(ないよね?)

今回は構造体に自動的に値を入れてくれるっぽいライブラリを見つけたのでこれを使って実現してみようと思います。 https://github.com/bluele/factory-go

src/factories 配下にプログラムを配置していきます。

  • 📁gorp-tips
  • 📁src
  • 📁factories
  • 🗒factories.go
  • 🗒jets_factory.go
  • 🗒pilots_factory.go
pilots_factory.go
package factories

import (
	"github.com/bluele/factory-go/factory"

	"gorp-tips/db"
	"gorp-tips/models"
)

var PilotFactory = factory.NewFactory(
	&models.Pilot{},
).SeqInt("ID", func(n int) (interface{}, error) {
	return n, nil
}).Attr("Name", func(args factory.Args) (interface{}, error) {
	return "Tester", nil
})

// MakePilot Pilotのファクトリを作る
func MakePilot(fields Fields, deps []db.Dependency) (*models.Pilot, []db.Dependency) {
	m := PilotFactory.MustCreateWithOption(fields).(*models.Pilot)
	deps = append(deps, m)
	return m, deps
}

var LanguageFactory = factory.NewFactory(
	&models.Language{},
).SeqInt("ID", func(n int) (interface{}, error) {
	return n, nil
}).Attr("Language", func(args factory.Args) (interface{}, error) {
	return "English", nil
})

// MakeLanguage Languageのファクトリを作る
func MakeLanguage(fields Fields, deps []db.Dependency) (*models.Language, []db.Dependency) {
	m := LanguageFactory.MustCreateWithOption(fields).(*models.Language)
	deps = append(deps, m)
	return m, deps
}

var PilotLanguageFactory = factory.NewFactory(
	&models.PilotLanguage{},
)

// MakePilotLanguage PilotLanguageのファクトリを作る
func MakePilotLanguage(fields Fields, deps []db.Dependency) (*models.PilotLanguage, []db.Dependency) {
	m := PilotLanguageFactory.MustCreateWithOption(fields).(*models.PilotLanguage)
	if m.PilotID == 0 {
		pilot, _deps := MakePilot(nil, nil)
		m.PilotID = pilot.ID
		deps = append(deps, _deps...)
	}
	if m.LanguageID == 0 {
		lang, _deps := MakeLanguage(nil, nil)
		m.LanguageID = lang.ID
		deps = append(deps, _deps...)
	}
	deps = append(deps, m)
	return m, deps
}

一応使い方を説明しておくと、 factory.NewFactory で構造体と格納する値のルールを指定することで任意の構造体を作ることができます。 が、これだけでは外部キーが解決できません。

そこでこれを更にラップした MakeXXXX という関数を作り、外部キーのフィールドがゼロ値(今回はintなので0)の場合に、 さらに別の MakeYYYY 関数を内部で呼び出すようにしています。 この関数は第1仮引数は初期値、第2仮引数は依存レコード(スライス)を受け取ります。

初期値はファクトリにそのまま引き渡されるからわかるとして、依存レコードとはなんでしょうか?

テーブルによっては外部キーを持ち、別テーブルのレコードを必要とします。 上記の例で言えば pilot_languages (中間テーブル)は pilotslanguages テーブルに依存しているので、 MakePilotLanguagePilotIDLanguageID がない(ゼロ値の)場合、 MakePilotMakeLanguage で作った構造体から抽出した ID をフィールドに格納したあと、 それぞれの deps を上位(MakePilotLanguages)の deps に追加します。

この仕組みにより連鎖的に依存関係を解決します。

この deps が解決した依存レコードであり、 引数として受け取りさらに返却することで最後まで引き継げるようになります。 なお、 deps に含まれる依存レコードの順番は外部キー制約を満たす上で非常に重要なので作る場合は 依存されるほうが先 に入るようにしてあげてください。

info
  • 実際私が案件に導入したファクトリは 依存レコードを引数としては受け取らず 、ファクトリごとに作った依存レコードを呼び出し側で別々に管理していましたが、 作るべきレコードが増えてくると全部に変数名をつけるのが結構めんどくさいので一つの deps に追記する方式にしてみました。再帰ではないけどちょっと継続渡しっぽい感じですね。

渡す引数は増えますが、多分こちらのほうがなんぼか楽です。

テスト実行関数

テストケースごとにトランザクションを張って初期化したいといっても go の テストケース関数はそんな事情を知らないので私達が対応してあげる必要があります。 そこで、トランザクションを作り終了時に破棄するような関数を RunTest を以下のように定義します。

testutils.go
package db

import (
	"context"
	"database/sql"
	"testing"

	"gorp-tips/models"

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

// TestingBlock テスト対象のブロック(関数)
type TestingBlock func(ctx context.Context, tx *NestableTx)

// Dependency 依存レコード(自身を含む)
type Dependency interface{}

func initDb(t *testing.T) *gorp.DbMap {
	db, err := sql.Open("mysql", "usr:pw@tcp(testing_mysql:3306)/db")
	if err != nil {
		t.Fatalf("Failed to connect db. %s", err)
	}
	dbmap := &gorp.DbMap{Db: db, Dialect: gorp.MySQLDialect{Engine: "InnoDB", Encoding: "utf8mb4"}}
	models.MapStructsToTables(dbmap)
	return dbmap
}

// RunTest テストを実行する
func RunTest(ctx context.Context, t *testing.T, block TestingBlock, deps ...Dependency) {
	dbmap := initDb(t)
	defer dbmap.Db.Close()
	// トランザクション作成
	tx, err := dbmap.Begin()
	if err != nil {
		t.Errorf("Failed to start transaction. %w", err)
		return
	}
	ntx := &NestableTx{Transaction: tx}
	defer ntx.Rollback()

	// dependencies 投入
	for _, m := range deps {
		if err := ntx.Insert(m); err != nil {
			t.Fatalf("Failed to load dependencies. %s, %+v", err, m)
		}
	}
	// テスト実行
	block(ctx, ntx)
}

関数内では テスト用の dbmap から NestableTx を生成し、終了時に Rollback するように defer に仕込んでおきます。

warning
  • dbmap の DSN は環境ごとに異なると思うのでよしなに書き換えてください。
  • 通常テスト用のDBを指定することになると思うので、開発で使っているDBを指定しないように注意しましょう。
  • 今回は別途コンテナを作りました。

引数として特筆するべきなのは 3番目の block と 4番目の deps です。 block にはDBの中を参照するような実行処理を関数形式で定義したものを高階関数として指定します。 言葉では伝わりづらいので後で実際のテストコードを見てもらうのが早いでしょう。

deps は先ほど説明したファクトリで作った依存レコードです。実行前の時点ではDBに入っていないただの構造体にすぎず、 引数として受け取って block 実行前にDBに入れてあげることでようやくレコードとして参照できるようになります。 可変長引数にしたのは指定しない場合は省略できるようにしたかったからです。必須ということで普通の引数にしてもいいと思います。

ということで必要なパーツは整いました。

ユニットテスト

テスト対象

早速テストを書いていきたいところですが、一応対象の関数というかメソッドを説明しておきます。

今回は以下をテストします。

jet_repository0.go
package repositories

import (
	"context"
	"strings"

	"github.com/go-gorp/gorp"
	"github.com/labstack/gommon/log"

	"gorp-tips/models"
)

type JetRepository interface {
	GetJets(ctx context.Context, req models.Request) ([]models.Result, error)
}

type jetRepository0 struct {
	exec gorp.SqlExecutor
}

func NewJetRepository0(exec gorp.SqlExecutor) JetRepository {
	return &jetRepository0{
		exec: exec,
	}
}

func (r *jetRepository0) GetJets(ctx context.Context, req models.Request) ([]models.Result, error) {
	query := "SELECT jets.name AS jetName, jets.age AS jetAge, jets.color AS jetColor, pilots.name AS pilotName, languages.language "
	query += "FROM jets "
	query += "JOIN pilots ON pilots.id = jets.pilot_id "
	query += "LEFT JOIN pilot_languages ON pilot_languages.pilot_id = jets.pilot_id "
	query += "LEFT JOIN languages ON languages.id = pilot_languages.language_id "
	conds, variables := makeCondition(req)
	if conds != "" {
		query += "WHERE " + conds
	}
	query += " ORDER BY jets.age, jets.id"
	log.Debug(query)

	var results []models.Result
	if _, err := r.exec.Select(&results, query, variables); err != nil {
		log.Error(err)
		return nil, err
	}
	return results, nil
}

func makeCondition(req models.Request) (string, map[string]interface{}) {
	conds := []string{}
	context := map[string]interface{}{}

	if req.Age > 0 {
		conds = append(conds, "jets.age = :age")
		context["age"] = req.Age
	}
	if req.PilotName != "" {
		conds = append(conds, "pilots.name LIKE :pilot_name")
		context["pilot_name"] = "%" + req.PilotName + "%"
	}
	if req.JetName != "" {
		conds = append(conds, "jets.name LIKE :jet_name")
		context["jet_name"] = "%" + req.JetName + "%"
	}
	if req.Language != "" {
		conds = append(conds, "languages.language = :language")
		context["language"] = req.Language
	}

	return strings.Join(conds, " AND "), context
}

処理自体には特に大きな意味はなく適当に結合してフィルタしたレコードを返却するだけの関数ですが、 リポジトリ構造体には多少の工夫があります。 このリポジトリ構造体はコネクションを exec フィールドに持ち、 NewJetRepository0 関数によって初期化されます。 引数に渡すのは DBコネクションに相当する構造体になりますが、型は gorp.SqlExecutor というインタフェースです。

通常は dbmap が与えられ、テスト時は 先程説明した ネスト可能なトランザクション NestableTx が指定されます。 これらは異なる型のためインタフェースのような抽象的な型でないと受けることができません。 gorp の場合はそれが gorp.SqlExecutor だったというわけです。

info
  • SQLBoiler の場合は boil.ContextExecutor
  • Gorm の場合は gorm.DB かな? (使ってないから不明)
  • 冒頭で言った条件というのはここのことで、コネクションとトランザクションを抽象化して扱えるような機構がORMに用意されているかどうかがポイントです。
  • ここにないORMは君の目でたしかみてみろ!

テストコード

おまたせしました。ようやくユニットテストのコードです。

  • 📁gorp-tips
  • 📁src
  • 📁controllers
  • 🗒jet_controller.go
  • 📁db
  • 🗒sql.go
  • 🗒sql2.go
  • 📁statik
  • 🗒dummy.go
  • 🗒statik.go
  • 🗒testutils.go
  • 🗒transaction.go
  • 📁factories
  • 🗒factories.go
  • 🗒jets_factory.go
  • 🗒pilots_factory.go
  • 🗒go.mod
  • 🗒go.sum
  • 🗒main_with_template.go
  • 🗒main_with_template_in_bin.go
  • 🗒main_without_template.go
  • 📁models
  • 🗒jet_model.go
  • 🗒request.go
  • 🗒result.go
  • 📁repositories
  • 🗒jet_repository0.go
  • 🗒jet_repository1.go
  • 🗒jet_repository2.go
  • 🗒jet_repository_test.go
  • 📁sql
  • 🗒query.sql
jet_repository_test.go
package repositories_test

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"

	"gorp-tips/db"
	"gorp-tips/factories"
	"gorp-tips/models"
	"gorp-tips/repositories"
)

func TestRepository(t *testing.T) {
	var deps []db.Dependency

	ken, deps := factories.MakePilot(factories.Fields{"Name": "Ken"}, deps)
	kyle, deps := factories.MakePilot(factories.Fields{"Name": "Kyle"}, deps)
	kim, deps := factories.MakePilot(factories.Fields{"Name": "Kim"}, deps)

	jp, deps := factories.MakeLanguage(factories.Fields{"Language": "Japanese"}, deps)
	en, deps := factories.MakeLanguage(factories.Fields{"Language": "English"}, deps)
	kr, deps := factories.MakeLanguage(factories.Fields{"Language": "Korean"}, deps)

	_, deps = factories.MakePilotLanguage(factories.Fields{"PilotID": ken.ID, "LanguageID": jp.ID}, deps)
	_, deps = factories.MakePilotLanguage(factories.Fields{"PilotID": kyle.ID, "LanguageID": jp.ID}, deps)
	_, deps = factories.MakePilotLanguage(factories.Fields{"PilotID": kyle.ID, "LanguageID": en.ID}, deps)
	_, deps = factories.MakePilotLanguage(factories.Fields{"PilotID": kim.ID, "LanguageID": jp.ID}, deps)
	_, deps = factories.MakePilotLanguage(factories.Fields{"PilotID": kim.ID, "LanguageID": kr.ID}, deps)

	falcon, deps := factories.MakeJet(factories.Fields{"Age": uint8(40), "Name": "Falcon", "PilotID": ken.ID}, deps)
	hawk, deps := factories.MakeJet(factories.Fields{"Age": uint8(30), "Name": "Hawk", "PilotID": kyle.ID}, deps)
	swallow, deps := factories.MakeJet(factories.Fields{"Age": uint8(20), "Name": "Swallow", "PilotID": kyle.ID}, deps)
	dove, deps := factories.MakeJet(factories.Fields{"Age": uint8(10), "Name": "Dove", "Color": "gray"}, deps)
	eagle, deps := factories.MakeJet(factories.Fields{"Age": uint8(10), "Name": "Eagle", "PilotID": kim.ID}, deps)

	cases := []struct {
		name     string
		req      models.Request
		expected []models.Result
	}{
		{
			name: "age filter",
			req:  models.Request{Age: 10},
			expected: []models.Result{
				{JetName: dove.Name, JetAge: 10, JetColor: dove.Color, PilotName: "Tester", Language: nil},
				{JetName: eagle.Name, JetAge: 10, JetColor: eagle.Color, PilotName: kim.Name, Language: &jp.Language},
				{JetName: eagle.Name, JetAge: 10, JetColor: eagle.Color, PilotName: kim.Name, Language: &kr.Language},
			},
		},
		{
			name: "pilot name filter",
			req:  models.Request{PilotName: "en"},
			expected: []models.Result{
				{JetName: falcon.Name, JetAge: falcon.Age, JetColor: falcon.Color, PilotName: ken.Name, Language: &jp.Language},
			},
		},
		{
			name: "jet name filter",
			req:  models.Request{JetName: "awk"},
			expected: []models.Result{
				{JetName: hawk.Name, JetAge: hawk.Age, JetColor: hawk.Color, PilotName: kyle.Name, Language: &jp.Language},
				{JetName: hawk.Name, JetAge: hawk.Age, JetColor: hawk.Color, PilotName: kyle.Name, Language: &en.Language},
			},
		},
		{
			name: "language filter",
			req:  models.Request{Language: "English"},
			expected: []models.Result{
				{JetName: swallow.Name, JetAge: swallow.Age, JetColor: swallow.Color, PilotName: kyle.Name, Language: &en.Language},
				{JetName: hawk.Name, JetAge: hawk.Age, JetColor: hawk.Color, PilotName: kyle.Name, Language: &en.Language},
			},
		},
	}

	db.RunTest(context.Background(), t, func(ctx context.Context, ntx *db.NestableTx) {
		repo := repositories.NewJetRepository(ntx)
		for _, c := range cases {
			t.Run("GetJets "+c.name, func(t *testing.T) {
				got, err := repo.GetJets(ctx, c.req)
				if err != nil {
					t.Error(err)
					return
				}
				if r := cmp.Diff(got, c.expected); r != "" {
					t.Errorf("failed. expected: %v, got: %v", c.expected, got)
				}
			})
		}
	}, deps...)
}

今までのパーツを全部つなぎ合わせただけですが順に説明していきます。

関数の先頭で依存レコードの入れ物 deps を作ります。

その後テストケースで利用するフィクスチャを作るべく それぞれの MakeXXXX を呼び出します。

今回は複数の条件が指定できるので条件ごとにサブケースとしてテーブルテストを実行していこうと思います。ここでサブケースと言っているのが cases 変数です。 検索条件と期待結果をフィールドに持つ構造体のスライスです。まぁ見ればわかりますね。

最後に RunTest です。先程お茶を濁した block (第3引数)がここで活躍します。 実行するテスト処理がずらっと書かれているのがわかりますか? この block 関数の仮引数には ctx (割とどうでもいい) と ntx が渡ってきます。

最後に添えてある deps... によって依存レコードを可変長引数として展開して db.RunTest に渡し、 block関数を実行する直前にDBに格納します。

仮引数の ntx はここで格納されたレコードを参照できる唯一のトランザクションであり、 repo := repositories.NewJetRepository(ntx) のように与えることで、この repo はトランザクションにアクセスできるようになりました。 めでたしめでたし。

このトランザクションは RunTest 終了時に必ず破棄されるのでテスト用のDBは常にクリーンに保たれるわけですね。

ちなみに今回はやりませんでしたが、 ntx はトランザクションのネストが可能なのでテスト対象の中でトランザクションを発行していても当然問題ありません。

最後にテストを実行してみましょう。

~/src# go test repositories/jet_repository_test.go ok command-line-arguments 0.030s

うん、うまく動いてるみたいです。

とはいえ依存先レコードのフィールドにも値を入れようとした結果、結構コードが多くなってしまいました。依存先フィールドの指定はやっぱり factoryboy とかのほうが楽ですね〜。 まぁ依存先を指定してないやつ (doveとか)もレコードが作れているので上出来でしょう。

info
  • 今回は RunTest 関数の中で cases をループさせていましたが、対象関数によってテーブルの内容が変化する場合は cases を外にして、 サブケースごとに RunTest を実行したほうがよいです。
    • 前の結果があとの結果に影響してしまうためです。今回は参照だけなので実行パフォーマンスを考慮しこのような関係にしています。
  • トランザクションのロールバックでDBを初期化するという考えは古代から伝わる良い手法ではあるのですが、 トランザクションやセーブポイントを生でいじるような関数を対象とする場合は NestableTx でも期待通り動作しないおそれがあります。
    • この場合はテーブルを個別に TRUNCATE するとかもありかもしれません。
      • "SET FOREIGN_KEY_CHECKS = 0" とかで外部キー制約のチェックを外す必要がある。

この記事があなたの助けになればうれしいです。 いいなと思ったらシェアお願いしますね。