こんにちは、こちらはLinc'wellアドベントカレンダーの1日目です。
皆さんはDBに対して書き込みが発生する関数のテストをどのように行われているでしょうか?
私はgolang初心者どころかサーバサイド初心者なので、最適解が全くもって分からなかったのですが、いろいろ調べた末「こんな感じで用意すると良さそう」というテスト環境・書き方に落ち着いたのでそれをここに記します。
同じ悩みを持った方に判断基準を提供できるような記事になっていると嬉しいです。(あわよくば強い人からのフィードバック待ってます)
はじめに: どんなテストを書きたいのか
はじめに要件をクリアにするためにどんなテストを書きたいのかの具体例を書きます。
ただ今私はECサイトの開発をしていて、その中で定期購入の仕組みを作っています。
定期購入はユーザが定期購入を申し込んだ際に「次にどの日付にどんな商品を購入するのか」というデータを持った Subscription というデータを作り、日次でその日の日付が指定された定期購入に応じて注文を作るバッチ処理を行う、という仕組みで作ります。
なので、テストの擬似コードを書くとこんな感じです。
func TestCreateSubscriptionOrders(t *testing.T) {
// Given: 今日発送予定のSubscriptionがある
// When: Batchの関数を実行する
// Then: そのSubscriptionに応じたOrderが作られる
}
今回テストを考えたいのはこのように「関数を実行し」「その関数を実行したことによってDBのデータが正しく変わったこと」を確認したいケースです。なので、関数に対してテストコード内から参照できるDBインスタンスを渡してあげる必要があります。
このため、この記事では次の3つの事柄について考えていきます。
- DBインスタンスをどう用意するか
- テストデータをどう用意するか
- 上記例のようにテストを実行するために前提として特定のテストデータが多々あるでしょう。これをどう用意すると良さそうかを考えます
- テストデータのクリーンナップをどうやって行うか
- ユニットテストで前提条件やテスト対象の関数内でINSERTされたデータは他のテストに影響を与えないよう消しておきたいです
- これをどのように行うかを考えます
DBインスタンスをどう用意するか
大方針としては mock を用意するかテスト用にDBを立てるかのどっちかになると思います。
mock
試しに go-sqlmock
のサンプルコードを見てみます。
GitHub - DATA-DOG/go-sqlmock: Sql mock driver for golang to test database interactions
func TestShouldUpdateStats(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)
}
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()
// now we execute our method
if err = recordStats(db, 2, 3); err != nil {
t.Errorf("error was not expected while updating stats: %s", err)
}
// we make sure that all expectations were met
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
mock では mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
のように「こういうSQLが来た時はこういう結果を返す」という指定の仕方ができます。
要するに「期待するSQLが来たこと」は確認できますが「想定した値がDBから返ってくること」は担保できません。
個人的にはこれだけで今回の要件には合わないかなと感じました。(あと弊社のケースで言うと、sqlboilerというORマッパーを使っていて、それのSQLを再現するのがめんどくさいと言う事情もありました。ちゃんと探せば生SQL発行するメソッドとかあるのでかもしれないですが)
test用のDBを立てる
次にテスト用のDBを立てる方法について、色々あるとは思いますが、探したところ次のどっちかっぽいです。
- 開発用のものと同じコンテナ内にテスト用のDBを作る
- dockertestを使って専用のイメージ作る
今の自分たちの開発環境だとすでに開発用の postgres プロセスがあって、そこにテスト用のDB作っちゃうので簡単に済ませられそうだったのでそうすることにしました。ちょっとユニットテストがDocker立てていることに一抹の気持ち悪さがないわけではないですが、開発中は基本的に常に動かしているものなので今のところ困っていないです。
CI環境ではどうしているのか
ただ今GitHub Actionsを使っているのでそれを使っての例になりますが、CI環境ではdockerも使わないで postgres立てて migration (goose)を流すというのをやっています。
name: ci-backend
on: [push]
jobs:
build:
name: setup
runs-on: ubuntu-latest
services:
postgres:
image: postgres:10
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@master
- uses: actions/setup-go@v1
with:
go-version: '1.12'
- name: setup bash_profile
run: |
echo 'export GOPATH="$HOME/go"' >>~/.bash_profile
echo 'PATH="$GOPATH/bin:$PATH"' >>~/.bash_profile
- name: run migration
working-directory: server
shell: bash -l {0}
run: |
go get -u github.com/pressly/goose/cmd/goose
goose --dir=internal/db/migrate postgres "user=postgres port=5432 password=postgres host=localhost dbname=postgres sslmode=disable" up
- name: test server
working-directory: server
env:
DB_PORT: ${{ job.services.postgres.ports[5432] }}
DB_USER: postgres
DB_PASSWORD: postgres
DB_HOST_TEST: localhost
DB_NAME_TEST: postgres
run: |
go test ./...
テストデータの準備
考えたい観点は二つあって
- いかに簡単にテストデータを用意するか
- いかに事前に用意するテストデータが他のテストに影響しないようにするか
例えば冒頭の定期購入のケースについて考えてみると、「注文を作るためにアクティブな定期購入があること」を前提としています。
なので「注文が作られること」をテストしたい場合は前提条件である定期購入のデータを入れなければいけません。テストデータを用意するのは非常に詰まらない作業なのでなるべく簡略化したいところです。
ただ、ではグローバルな seed データのようなものに定期購入のデータを入れて最初に入れればいいと言うものでもなく、例えば上記以外に「定期購読がない場合に注文が作られないこと」もテストしたいとなった場合に不可能になってしまいます。(それかなんらかデータが入っていることを前提として「そのテストの前に定期購読のデータ消す」という余計な知識が入ってしまいます)
なので「テストデータを簡単に用意すること」と「テスト間で扱うデータが干渉しない」ために考えたいのは次の二つです
- どのタイミングでデータをINSERTするのがいいか
- テストデータを簡単に用意する実装の仕方
どのタイミングでデータをINSERTするのがいいか
ざっくり言うと事前に seed みたいなのを入れるか各ユニットテストで前提条件として入れるかです。(正確には二者択一というより、テスト固有のデータは登場するに決まっているので seedを用意するかどうかという問いの方が正しいですが)
今後は何かしら作るかもしれないですが一旦 seed のようなものは作らないことにしました。
理由としてはあるユニットテストがグローバルに作られたテストデータを前提としている時に、暗黙的な知識を持っているのがテスト自体の保守性下げてなんか気持ち悪いと感じたからです。そのテストだけ見ればそのテストの前提条件は何で見たいことは何か分かるようにしたいと感じました。なので多少面倒は増えるかもしれないですがきっちり一つ一つのユニットテストにデータの準備を書いていこうと思います。
ただ、いくつかテスト書いてみて思ったのは User みたいなどんなシステムでもコアとなる概念はもう全てのテストの前提条件にしてしまっても問題ないのではという気はしてきています。こういったものは漸進的に改善していきたいなと思います。
テストデータを簡単に用意する実装の仕方
月並みですがファクトリ関数を作っていこうと思います。
「思います」というのはこの記事執筆時点では大して書いてないので、何も書くことが無いということを指しています。頑張ります。
普通に自作で良さそうだけどこういうライブラリ使った方が楽なのかな
GitHub - bluele/factory-go: A test fixtures replacement inspired by factory_boy and factory_girl.
3.テストデータのクリーンナップをどうやって行うか
大きくは次の二つかなと思います。
- テスト内の処理はトランザクションとして持っておいて、テストが終わったらロールバック。
- 毎回データを消す
結論から述べると1個目の毎回トランザクション貼るようにします。理由はそちらの方が脳死で書けて、特にデメリットも思い当たらないからです。
具体的には次のような感じで書くようにします。
func TestCreateAppSubscriptionOrders(t *testing.T) {
// トランザクションはる
tx := db.GetTestTransaction()
// テスト
// 終わったらロールバック
tx.Rollback()
}
めっちゃシンプルですね。シンプルですが毎回書くのもう一段楽にできないかと思ったので、beforeEach/afterEachした方がいいかというのも考えてみました。
なぜ beforeEach/afterEach したいのか
分解すると次の二つのモチベーションがあります。
- うっかり書き忘れを無くしたい
- ただ、これを実現したい場合は全パッケージのテストに対して適用するのが必要となるができないっぽいです(できるなら教えて欲しい)
- そうでない場合、DBアクセスが必要なテストのパッケージ内で書くことになるが、それであればこの目的は満たせないので、このモチベーションはどう転んでも実現できない
- 手間を減らしたい
- 同一パッケージ内にかなりの数のDBを参照するテストがある場合
ただ、残念なことに golang 標準の testing パッケージには beforeEach/afterEach は存在しません。
実現しようとする場合は ginkgo などのテスティングフレームワークを使ったりする必要があるので、それを入れるほどのモチベーションではないかなーと思いました。
結論
頑張ってそれぞれのテストケース内でトランザクションの開始とRollbackを書こう。
ただ関数としては次のように書いておく。
package db
func GetTestTransaction() *sql.Tx {
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
os.Getenv("DB_HOST_TEST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME_TEST"), os.Getenv("DB_SSL"))
db, err := sql.Open("postgres", psqlInfo)
if err != nil {
panic(err.Error())
}
err = db.Ping()
if err != nil {
panic(err)
}
tx, _ := db.Begin()
return tx
}
// 使う側
func TestCreateAppSubscriptionOrders(t *testing.T) {
tx = db.GetTestTransaction()
/**********
テストの処理
**********/
tx.Rollback()
}
まとめ
私のプロジェクトでは次のように用意するようにしました。
- DBインスタンスはそれ専用のコンテナを立ち上げる
- テストデータはSeedは用意せずファクトリ関数のみで一旦行く
- テストのクリーンナップはトランザクションを用いて行う
もちろんプロダクトの性質によって方針も変わるとは思いますが、一旦これで進んでいきます。
お読みいただきありがとうございました。
追記
早速フィードバックいただけて激しく感謝なのですが、テストケース毎に db create, migration などを行うことによってクリーンナップを実現するというのが良さそうです。
私のトランザクションを用いてやる方法は、全てのテストケースに対して一回だけDB立てることを前提とした時に一々テストに使ったデータを指定してDELETEするのが面倒だったからで、この方法なら脳死で書けるし「transactionが出来ていることを試すテスト」も他のテストと違う書き方せず実現できるのでいいなと感じました。
seedの削除はtransactionではやってないです。というのもtransactionが出来ていることを試すテストもあるので。それでも数十件くらいのテストが2分くらいで完了しているのでまず問題ないかな、と思ってそうしてます
— Yoshinori Kosaka (@wawoon_jp) December 1, 2019