go-sqlmockで行うクリーンアーキテクチャにおけるgateways層のテスト
みなさんこんちは!株式会社BLAMでエンジニアをしている池田です。 普段はテクノロジー本部で、自社サービス「カイコク」の開発を行なっています。
BLAMではこちらの記事でも紹介したようにクリーンアーキテクチャを採用しリアーキテクトに取り組んでいました。それに伴い課題であったテストについて現在注力しています!
今回は、クリーンアーキテクチャでのgateways層(DB操作の実装を書く)のユニットテストについて、BLAMではどのようにしているかを書いていきます!
テストとは
テストを行うことでコードを修正する際などに期待通りの挙動になっているのかを確認してバグを防ぐことができます。 また、テストが実装されていると、改修を行う際にデグレーションのリスクと検品コストを下げることができます。
テストで起こりがちな問題
- 外部依存する部分をテストするのが難しい(本記事でいうとDB)
- テストデータの投入などでリソースを割く必要がある。
モックテストとは
- 依存しているものをモックで差し替えてテスト対象の動作を確かめることができる。
- 責務の分割を行えるようになる。(テスト対象を明確にすることでテストのスコープを定めるから)
- 依存するオブジェクトはインスタンスを生成して注入する。(DI)
テストの種類
テストの種類に関して、分け方の切り口は複数ありますが、今回ご紹介するユニットテストに関しては以下のような分け方になります。
今回は、ユニットテストを対象にご紹介します!
BLAMではDBを操作するメソッドのテストをどのように行なっているか
使用しているライブラリと標準パッケージは下記の通りです。実際にどのように使っているかは次の章で説明します!
使用しているライブラリ
BLAMではgo-sqlmockというライブラリを使って単体テストを書いています。
go-sqlmockは実際のデータベース接続を必要とせずに、テストでSQLドライバーの動作をシミュレートしてくれます。本物のDBの代わりにSQLドライバのような振る舞いをしてくれるモックライブラリです。
使用している標準パッケージ
実際にどのようにテストを書いているか
sqlmockを生成する関数
package mock_handlers import ( "database/sql/driver" "log" "os" "time" "github.com/DATA-DOG/go-sqlmock" "github.com/hoge/huga/internal/entities/handlers" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) func NewMockSQLHandler() (handlers.SQLHandler, sqlmock.Sqlmock, error) { // 1つ目の返り値はDB接続のためのmock、2つ目の返り値は設定を行うためmock db, sqlMock, err := sqlmock.New() if err != nil { return nil, nil, err } // モックDBに接続したDBインスタンスを定義 mockSQLHandler, err := gorm.Open( mysql.New(mysql.Config{ // 詳細な設定を行う Conn: db, SkipInitializeWithVersion: true, }), ) if err != nil { return nil, nil, err } return mockSQLHandler, sqlMock, nil }
NewMockSQLHandler()
関数は、DBに接続したmockSQLHandler
というmockと、mockの中身を設定するためのsqlmock
を返します。
mock自体はsqlmock.New()
関数で生成します。1つ目の返り値ではDB接続のmock、2つ目の返り値では設定用のmockを受け取ります。
テストファイル
package gateways import ( "errors" "regexp" "testing" "time" "github.com/DATA-DOG/go-sqlmock" mock_handlers "github.com/hoge/huga/internal/entities/handlers/mock" "github.com/hoge/huga/internal/entities/models" "github.com/hoge/huga/internal/entities/repositories" "github.com/go-playground/assert/v2" "gorm.io/gorm" ) func TestItemRepository_FindByCondition(t *testing.T) { itemID := uint(1) minStartAt := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.Local) maxStartAt := time.Date(2000, time.January, 31, 0, 0, 0, 0, time.Local) outputItem := &[]models.Item{{ Model: gorm.Model{ID: itemID}, }} internalServerError := errors.New("Find Internal Server Error") tests := []struct { name string in repositories.ItemRepoSearchCondition out *[]models.Item err error prepareExpectQuery func(sqlMock sqlmock.Sqlmock) }{ { name: "TestItemRepository_FindByCondition_正常系", in: repositories.ItemRepoSearchCondition{ MinStartAt: minStartAt, MaxStartAt: maxStartAt, }, out: outputItem, err: nil, prepareExpectQuery: func(sqlMock sqlmock.Sqlmock) { sqlMock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `items` WHERE start_at >= ? AND start_at < ? AND `items`.`deleted_at` IS NULL")). WithArgs(minStartAt, maxStartAt). WillReturnRows(sqlmock. NewRows([]string{"id"}). AddRow(itemID)) }, }, { name: "TestItemRepository_FindByCondition_異常系", in: repositories.ItemRepoSearchCondition{ MinStartAt: minStartAt, }, out: nil, err: internalServerError, prepareExpectQuery: func(sqlMock sqlmock.Sqlmock) { sqlMock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `items` WHERE start_at >= ? AND `items`.`deleted_at` IS NULL")). WithArgs(minStartAt). WillReturnError(internalServerError) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockSQLHandler, sqlMock, err := mock_handlers.NewMockSQLHandler() if err != nil { t.Error(err) } db, err := mockSQLHandler.DB() if err != nil { t.Error(err) } defer db.Close() tt.prepareExpectQuery(sqlMock) // テスト対象の関数を持つ構造体を生成(DI) itemRepo := NewItemRepo(mockSQLHandler) got, err := itemRepo.FindByCondition(tt.in) if err == nil { assert.Equal(t, got, tt.out) } assert.Equal(t, err, tt.err) // 期待値と実体値を照らし合わせてチェックし、一致していなければエラーを返す。 if err := sqlMock.ExpectationsWereMet(); err != nil { t.Errorf("failed to ExpectationWerMet(): %s", err) } }) } }
よく使われているtestingパッケージを使用しており、テストケースを記述してそれぞれのテストを回し、期待される値と実際の値を比較します。
いくつか変数や、関数についての補足を入れます。
ExpectQuery
では、期待するSELECT文と取得結果の組み合わせをモック化します。- 正規表現が期待されているため
regexp.QuoteMeta
関数を使って期待するクエリを正規表現で書いています。 WithArgs
で期待するクエリパラメータの値を引数として指定して、WillReturnRows
で返答を期待するレコードを指定。NewRows
で取得結果に必要なカラムを指定し、AddRows
で期待するカラムの値を指定します。- エラーを返すテストでは
WillReturnError
の引数に期待されるエラー型を指定します。
まとめ
今回は簡単な例のテストを紹介しましたが、
ExpectExec
関数を使ったUPDATE、INSERT、DELETE文のテストや、
ExpectCommit
、ExpectRollback
などを使ったトランザクションのテストなども書け、
go-sqlmockには様々な便利関数があります。
今後もテストを拡充させて効率的な開発が行えるようにしていきます!