go-sqlmockで行うクリーンアーキテクチャにおけるgateways層のテスト

みなさんこんちは!株式会社BLAMでエンジニアをしている池田です。 普段はテクノロジー本部で、自社サービス「カイコク」の開発を行なっています。

BLAMではこちらの記事でも紹介したようにクリーンアーキテクチャを採用しリアーキテクトに取り組んでいました。それに伴い課題であったテストについて現在注力しています!

blam.hatenablog.com

今回は、クリーンアーキテクチャでのgateways層(DB操作の実装を書く)のユニットテストについて、BLAMではどのようにしているかを書いていきます!

テストとは

テストを行うことでコードを修正する際などに期待通りの挙動になっているのかを確認してバグを防ぐことができます。 また、テストが実装されていると、改修を行う際にデグレーションのリスクと検品コストを下げることができます。

テストで起こりがちな問題
  • 外部依存する部分をテストするのが難しい(本記事でいうとDB)
  • テストデータの投入などでリソースを割く必要がある。
モックテストとは
  • 依存しているものをモックで差し替えてテスト対象の動作を確かめることができる。
  • 責務の分割を行えるようになる。(テスト対象を明確にすることでテストのスコープを定めるから)
  • 依存するオブジェクトはインスタンスを生成して注入する。(DI)

テストの種類

テストの種類に関して、分け方の切り口は複数ありますが、今回ご紹介するユニットテストに関しては以下のような分け方になります。

  • ユニットテスト: 関数やメソッド単位で行うテスト
  • 結合テスト: 関数やメソッドを結合した機能を対象にするテスト
  • 総合テスト: システム全体を対象にするテスト

今回は、ユニットテストを対象にご紹介します!

BLAMではDBを操作するメソッドのテストをどのように行なっているか

使用しているライブラリと標準パッケージは下記の通りです。実際にどのように使っているかは次の章で説明します!

使用しているライブラリ

BLAMではgo-sqlmockというライブラリを使って単体テストを書いています。

github.com

go-sqlmockは実際のデータベース接続を必要とせずに、テストでSQLドライバーの動作をシミュレートしてくれます。本物のDBの代わりにSQLドライバのような振る舞いをしてくれるモックライブラリです。

使用している標準パッケージ
  • errors エラーを操作する関数が実装されているパッケージ
  • regexp 正規表現に関する関数が実装されているパッケージ
  • testing テストを実行してくれるパッケージ

実際にどのようにテストを書いているか

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文のテストや、 ExpectCommitExpectRollbackなどを使ったトランザクションのテストなども書け、 go-sqlmockには様々な便利関数があります。 今後もテストを拡充させて効率的な開発が行えるようにしていきます!

参照