nakamura244 blog

所属団体とは関係なく、個人的なblog

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データセットが予期しない値になることがあるっぽい
    • それでテストが落ちることがあった
    • fixtureデータをセットする時にtransactionを用いてデータセットしても改善はされず、rerunでワークアラウンドしてた
  • 同時実行数を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をフル活用した

色々と説明するよりはコードにした方が伝わると思ってサンプル書きました

github.com

  • mysqlなしで、database周りのテストが実行できるサンプルコード
  • 割とちゃんと書いた!! ので、下記の解説と一緒に参考にしてもらえると良い

解説

f:id:tsuyoshi_nakamura:20190630134042j:plain

かんたんな説明

  • db層,interface層,repository層にまずレイヤーわけします
  • db層
    • buildinではいっているdatabase/sqlのメソッドを使ったロジックを構築します
    • database/sqlのメソッドを使う時はinterface経由で使います
    • そうすることによってmock化が可能になりtestableになります
    • returnはinterfaces層のinterfaceを返却します ... ※1
  • interfaces層
    • 同階層にあるinterfaceを利用して、メソッドを作ります ... interfaces.sql_repository.go
  • repository層
    • interfaces層で作られたメソッド(interfaces.sql_repository.go)のinterface登録をします
  • mainからはdb/sql.goからmysql connectionを取得、各ディレクトリのinterfaceに連続登録
  • mockテストで使うScanの実装の最低限使いそうな部分だけ移植してきた

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/sqlnet/httpに置き換わるだけで考え方は同じで行けると思う
  • これでよりテストが行き届いたコードになるのではないかと思うと同時にライブラリに対する知識が増した
  • 試しにcoverageをとってみた結果が下記になります f:id:tsuyoshi_nakamura:20190630144642p:plain f:id:tsuyoshi_nakamura:20190630144630p:plain sql.Openのところはテストしていないが...他は全部通過したtestableなコードとなりました