Goのdatabase周りのテストではinterfaceを最大限に活用しようという話
はじめに
- Goで色々とアプリケーションを書いていてストレージ(主にRDB)との結合部分のテストを書く事があると思います
- だいたいは実際のストレージにアクセスするテストを実行して、テストをするかと思います
- 最近、自分はこのストレージ部分とのテストをより良くするにはどうすべきかと考える時があります
- そこでdatabaseの結合部分のテスト周りで最近改善してみた事をまとめたいと思います
今までどうしてたか
localの場合
- まず、実際のテスト用のHOST,DB,USER,PASSWORDをconfigなり、環境変数化しておいて、起動時に切り替えれるようにしておく
- unit test用のDBを用意しておく
- テーブルスキーマのmigrationを流しておく
- test用のconfigで起動してgo testを流す。
- 実際にテスト用のDBに対して、select,update,deleteのテストを行う
circleciの場合
- まず、実際のテスト用のHOST,DB,USER,PASSWORDをconfigなり、環境変数化しておいて、起動時に切り替えれるようにしておく
- mysqlをimageから立てる
- テーブルスキーマのmigrationを流す
- test用のconfigで起動してgo testを流す。
- 実際にテスト用のDBに対して、select,update,deleteのテストを行う
- (全て.circleci/config.ymlに書く)
まぁ、これでも良いのですが、下記の課題を感じていた
感じてた課題感
1. 何回かに1度落ちる
- goのテストはGOMAXPROCSの値がデフォルトの同時実行数になって起動する
- DBへfixtureデータを投入する時におろらく上記の同時実行の副作用でfixtureデータセットが予期しない値になることがあるっぽい
- 同時実行数を1つにするのも一つの手ではあるが、なんかせっかくの良さが失われてもったいない気が...
2. error時のテスト
例えば下記のコード
tx, err := db.Begin() if err != nil { return err } // sql query const sqlstr = `UPDATE .... WHERE id = ? ` result, err := tx.Exec(sqlstr, id) if err != nil { tx.Rollback() return err } err = tx.Commit() if err != nil { return err } . . .
わりとよくあるコードだと思います
Begin()
,Commit()
が失敗した時のテストって、実際にDBアクセスしたテストを実施していると意図的にエラーを起こさせるのが難しい、またはかなり面倒だったりします
んでどうしたか? ... 題名の通り、interfaceをフル活用した
色々と説明するよりはコードにした方が伝わると思ってサンプル書きました
- mysqlなしで、database周りのテストが実行できるサンプルコード
- 割とちゃんと書いた!! ので、下記の解説と一緒に参考にしてもらえると良い
解説
かんたんな説明
db層
,interface層
,repository層
にまずレイヤーわけしますdb層
- buildinではいっている
database/sql
のメソッドを使ったロジックを構築します database/sql
のメソッドを使う時はinterface経由で使います- そうすることによってmock化が可能になりtestableになります
- returnは
interfaces層
のinterfaceを返却します ... ※1
- buildinではいっている
interfaces層
- 同階層にあるinterfaceを利用して、メソッドを作ります ...
interfaces.sql_repository.go
- 同階層にあるinterfaceを利用して、メソッドを作ります ...
repository層
interfaces層
で作られたメソッド(interfaces.sql_repository.go
)のinterface登録をします
- mainからはdb/sql.goからmysql connectionを取得、各ディレクトリのinterfaceに連続登録
- mockテストで使うScanの実装の最低限使いそうな部分だけ移植してきた
- go-database-sql-sample/sql_repository_test.go at master · nakamura244/go-database-sql-sample · GitHub
- ここはscanしたいstructの型によって変わっていくはず
memo
※1 ... database/sql
の作り的に1つのstructメソッドだけで完結していないのでこのような形になると思われる
実際にやって見ての発見
1. エラーを握りつぶしているパターンに気づけた
例えば下記のコード
func (d *db) Getxxx(id int64) (i []*models.xxx, err error) { sql := `SELECT ...WHERE id = ? ` rows, err := db.conn().Query(sql, id) if err != nil { return } defer func() { err = rows.Close() if err != nil { return } }() . . . for rows.Next() { err := rows.Scan( .... ) if err != nil { return } }
Scan
の時にエラーが発生した場合、returnのerr変数にScan
時のエラーがセットされる- しかし、
defer func()
でrows.Close()
の結果が、err変数に再設定されてしまう - そうすると、エラーがに握りつぶされ、処理が先に進み、後続処理に悪影響を及ぼす
2. ハンドリングしなくても良いかなと思えたメソッドを発見
下記のコード
func (repo *SQLRepository) InsertUserWithTx(u *User) (uint, error) { tx, err := repo.Begin() if err != nil { return 0, err } const sql = `INSERT INTO users ( ` + `email ` + `) VALUES (?) ` res, err := tx.Execute(sql, u.Email) if err != nil { tx.Rollback() return 0, err } lastID, err := res.LastInsertId() if err != nil { tx.Rollback() return 0, err } rowsAffect, err := res.RowsAffected() if err != nil { tx.Rollback() return 0, err } if rowsAffect != 1 { tx.Rollback() return 0, err } err = tx.Commit() if err != nil { return 0, err } return uint(lastID), nil }
- transactionをもちいたinsertメソッドのサンプルコードになります
- 何かエラーが発生した時にtransactionをRollbackします
- この
Rollback()
はハンドリングしなくても良いかなと思いました Rollback()
のメソッドを見るとerrorを返す場合があるので本来であればハンドリングが必要- 仮にハンドリングをするのであれば、リターン値を
(uint, error, error)
としなければ多分厳密にはダメなんだと思った- rollbackメソッドのエラーなのか、rollbackしなきゃいけなくなった前のエラーなのかを見分けないと行けないので
- でもそのようにしているコードをあまり見ない。(自分の視野が狭いだけかもしれないが)
- 再度考え直すと、
Rollback()
メソッドはBeginが実行された時、go tx.awaitDone()
というメソッドも実行されて、connectionがcloseされた時にrollbackを実行するという実装になってるっぽい - なので極端なはなし
Rollback()
がなくても良いのだが、不要になったものは早めに解放してあげる事は良い事だと思うので、ハンドリングなして実行でも良いのかなと思った - 上記のコードにはconnectionのcloseメソッドは書いていないが、dbのconnectionはlifetimeで一定期間が過ぎれば、closeして再度connectが走るので想定通りの動きになるはず ... (dbのconnectionは再利用して構築するのが多いですからねー)
まとめ
- とは言え、実際にデータがselectできるか、updateできるか、deleteできたかは実際のmysqlに対してテストをしないと担保はでいないので、使い分けが必要だと思う
- databaseに関わらず、他の外部APIをコールする時でも同じ要領で実現できると思います。その時は
database/sql
がnet/http
に置き換わるだけで考え方は同じで行けると思う - これでよりテストが行き届いたコードになるのではないかと思うと同時にライブラリに対する知識が増した
- 試しにcoverageをとってみた結果が下記になります
sql.Open
のところはテストしていないが...他は全部通過したtestableなコードとなりました