🖊️

【Go】モックを利用したトランザションを含むテストのやり方を考える(サービス層編)

2024/05/10に公開

はじめに

こんにちは!@nerusanです。

以前の記事で、【Go】レイアードアーキテクチャーによる構成案を考えてみたというアーキテクチャーに関する記事を書きました。このアーキテクチャーでは、ハンドラー層、サービス層、リポジトリ層に分けています。各層は独自に定義したインターフェースを介してアクセスでき、これによりテストがしやすくなります。また、各層へのアクセスにはモックを利用しており、これにより他の層に依存しない単体のテストを作成できます。

本記事では、その中でサービス層のテストを紹介します。サービス層は、ドメインとリポジトリを使ってユースケースを組み立てます。リポジトリではDBにアクセスしますが、インターフェースを利用してモックを作成し、モックを利用してテストします。そのため、実際にデータベースにアクセスすることはありません。実際にデータベースにアクセスするテストはリポジトリのテストで行います(後日記事を出す予定です)。

DBのトランザクションを利用するのはサービス層になります。そのため、今回はトランザクションを含むテストのやり方について紹介したいと思います。

技術スタックは以下の通りです。

Golang: 1.22
sqlx: 1.3.5

実装した実際のアプリケーションのリポジトリは下記に示しておりますので、見てみてください:+1:

https://github.com/hack-31/point-app-backend

なぜモックを利用して各層を分離してテストするのか?

なぜモックを利用して各層を分離してテストするのか?を述べたいと思います。
モックを使用してハンドラー層、サービス層、リポジトリ層をテストするメリットはいくつかあります。

  1. 依存性の分離: モックを使用することで、各層を独立してテストできます。モックは実際の依存関係を分離し、各コンポーネントが単独でテストできるようにします。これにより、特定の層の変更が他の層に影響を与えることなく、テストを維持できます。
  2. テストの速度向上: モックを使用すると、外部の依存関係をシミュレートできます。これにより、外部リソースへのアクセスが必要なくなり、テストをより速く実行できます。例えば、データベースやネットワークリクエストの代わりに、モックオブジェクトを使用することで、テストの実行速度が向上します。
  3. テストの安定性向上: 外部リソース(データベース、APIなど)は、テストの安定性に影響を与える可能性があります。たとえば、ネットワークの遅延や接続エラーが発生すると、テストが失敗する可能性があります。モックを使用すると、これらの問題を回避し、テストの安定性を向上させることができます。
  4. テストの一貫性の確保: モックを使用すると、テスト中に一貫性のある状態を維持できます。実際のデータベースや外部サービスを使用する場合、テストの実行順序や状態によって結果が異なる可能性があります。モックを使用すると、テストの一貫性を確保し、再現性のあるテストを実行できます。

これらのメリットにより、モックを使用してハンドラー層、サービス層、リポジトリ層をテストすることで、効率的で信頼性の高いテストを実行できます。

テスト対象のコード

今回は、お知らせ詳細を取得するユースケースをテストすることを考えます。具体的な処理の流れは以下の通りです。

  1. リクエストユーザーの情報を取得
  2. トランザクションを開始
  3. お知らせ閲覧チェック済みに更新
  4. お知らせ詳細を取得
  5. Pub/Subに対してお知らせを通知(お知らせ数をプッシュ通知するのに利用)
  6. トランザクションをコミット

以下に具体的なコード例を示します。

service/get_notification.go
// GetNotification は、お知らせ詳細取得サービスである
func (gn *GetNotification) GetNotification(ctx *gin.Context, notificationID entity.NotificationID) (GetNotificationResponse, error) {
	// ユーザID確認
	userID := utils.GetUserID(ctx)
  
	// トランザクション開始
	tx, err := gn.Tx.BeginTxx(ctx, nil)
	if err != nil {
    return GetNotificationResponse{}, errors.Wrap(err, "failed to begin transaction")
	}
	defer func() { _ = tx.Rollback() }()

	// 閲覧したので、お知らせをチェック済みとする
	if err := gn.NotifRepo.CheckNotification(ctx, tx, userID, notificationID); err != nil {
		return GetNotificationResponse{}, errors.Wrap(err, "failed to check notification in db")
	}

	// お知らせ詳細取得
	n, err := gn.NotifRepo.GetNotificationByID(ctx, tx, userID, notificationID)
	if err != nil {
		return GetNotificationResponse{}, errors.Wrap(err, "failed to get notification by id")
	}
	res := GetNotificationResponse{
		ID:          n.ID,
		Title:       n.Title,
		IsChecked:   n.IsChecked,
		Description: n.Description,
		CreatedAt:   model.NewTime(n.CreatedAt).Format(),
	}

	// お知らせチェックしたので、お知らせを通知
	channel := fmt.Sprintf("notification:%d", userID)
	payload, err := json.Marshal(n)
	if err != nil {
		return res, errors.Wrap(err, "failed to marshal notification")
	}
	if err := gn.Cache.Publish(ctx, channel, string(payload)); err != nil {
		return res, errors.Wrapf(err, "failed to publish to %s channel", channel)
	}

	// トランザクションコミット
	if err := tx.Commit(); err != nil {
		return GetNotificationResponse{}, errors.Wrap(err, "failed to commit transaction")
	}

	return res, nil
}

詳しくは、githubを見てください!

https://github.com/hack-31/point-app-backend/blob/main/service/get_notification.go

テストの作成

今回のテストコードでは、以下の手順でテストを実装します。

  1. テスト用のデータベースのモックを設定
  2. リポジトリ層のモックの設定
  3. テストを組み立てる
  4. テストの実行

1. テスト用のデータベースのモックを設定

このテストでは、リポジトリ層の CheckNotificationGetNotificationByID などを利用してユースケースを実装しています。これらのメソッドは、独自に定義したインターフェースを介していますので、それに対応したモックを作成します。その方法は、「2. リポジトリのモックの設定」で紹介します。tx.Rollback()tx.Commit() は、sqlx パッケージのメソッドです。これらに対するモックは、DB接続を模倣するためのものを利用します。この目的を達成するには、DATA-DOG/go-sqlmock を使用します。

以下にモックの定義を示します。

testutil/db.go
package testutil

import (
	"context"
	"database/sql"
	"fmt"
	"os"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
	"github.com/stretchr/testify/assert"
)

// NewTxForMock はモック用のトランザクションを作成する
//
// 実際にはDBには接続しない
//
// begin, commitは正常系を返すようにモックする
func NewTxForMock(t *testing.T, ctx context.Context) *sqlx.Tx {
	t.Helper()

	db, mock, err := sqlmock.New()
	if err != nil {
		assert.Fail(t, err.Error())
	}

	xdb := sqlx.NewDb(db, "sqlmock")
	t.Cleanup(
		func() { _ = xdb.Close() },
	)
	if err := xdb.Ping(); err != nil {
		assert.Error(t, err)
	}

	// トランザクションの開始、コミット、ロールバックをモックする
	mock.ExpectBegin()
	mock.ExpectCommit()
	mock.ExpectRollback()

	mockTx, err := xdb.BeginTxx(ctx, nil)
	if err != nil {
		assert.Error(t, err)
	}
	return mockTx
}

まず、t.Helper()を呼び出しています。これは、テストヘルパー関数内で使用され、エラーレポートがこの関数をスキップして、実際のテスト関数に対して行われるようにします。

次に、sqlmock.New()を呼び出して、モックデータベースとモックオブジェクトを作成しています。これにより、データベースへの実際の接続を必要とせずに、データベースの操作をシミュレートすることができます。

作成したモックデータベースをsqlx.NewDbに渡して、sqlx.DBオブジェクトを作成します。

次に、t.Cleanup関数を使用して、テストが終了したときにデータベース接続を閉じるクリーンアップ関数を登録します。これにより、テストが終了したときにリソースが適切に解放されます。

その後、xdb.Ping()を呼び出してデータベースへの接続を確認します。これは、データベースが利用可能であることを確認するためのものです。

次に、mock.ExpectBegin(), mock.ExpectCommit(), mock.ExpectRollback()を呼び出して、トランザクションの開始、コミット、ロールバックをモックします。これにより、これらの操作が呼び出されたときに何もしないモックの動作を設定します。

エラーを返すには、以下のようにするとできます。

testutil/db.go
// begin側
mock.ExpectBegin().WillReturnError(fmt.Errorf("begin error"))
// commit側
mock.ExpectCommit().WillReturnError(fmt.Errorf("commit error"))

最後に、xdb.BeginTxxを呼び出してトランザクションを開始し、そのトランザクションを返しています。これにより、この関数を呼び出すテスト関数は、モックデータベース上でトランザクションを開始できます。

返却値であるmockTxgn.Tx.BeginTxx(ctx, nil)のレスポンスに指定することでモックの呼び出しに対応できます。

2. リポジトリ層のモックの設定

定義したインターフェースに対するモック定義には、以下のモジュールを利用します。

https://github.com/uber-go/mock

リポジトリ層のインターフェースは domain/interface.go に、ORM(sqlx)で扱えるメソッドのインターフェースは repository/repository.go に記載しています。モックの定義は、それぞれのインターフェースが定義されている箇所の _mock ディレクトリに作成します。

モックの生成は、以下のコマンドを使用します。Makefileに記載することで、複数のコマンドを一括で実行できるようになっています。$ make mock を実行することで、モックの生成が行われます。

Makefile
mock: ## mock作成
	mockgen -source=./repository/repository.go -destination=./repository/_mock/mock_repository.go
	mockgen -source=./domain/interface.go -destination=./domain/_mock/mock_interface.go

実行すると、domain/_mock/mock_interface.go および repository/_mock/mock_repository.go にモック用のファイルが生成されます。

テストを書く際には、これらのモックを使用して、引数や返却値を設定してテストを進めていきます。テストの具体的な実装は、次の「3. テストを組み立てる」で紹介します。

3. テストを組み立てる

実際のテストコードを示します。

service/get_notification_test.go
package service

import (
	"database/sql"
	"fmt"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/hack-31/point-app-backend/auth"
	mock_domain "github.com/hack-31/point-app-backend/domain/_mock"
	"github.com/hack-31/point-app-backend/myerror"
	mock_repository "github.com/hack-31/point-app-backend/repository/_mock"
	"github.com/hack-31/point-app-backend/repository/entity"
	"github.com/hack-31/point-app-backend/testutil"
	"github.com/hack-31/point-app-backend/utils/clock"
	"github.com/stretchr/testify/assert"
	"go.uber.org/mock/gomock"
)

func TestGetNotification(t *testing.T) {
	t.Parallel()
	type input struct {
		notificationID entity.NotificationID
	}
	type want struct {
		notification GetNotificationResponse
		err          error
	}
	type checkNotification struct {
		callCount `int
		err       error
	}
	type getNotificationID struct {
		callCount    int
		notification entity.Notification
		err          error
	}
	type publish struct {
		callCount int
		err       error
	}

	tests := map[string]struct {
		input               input
		checkNotification   checkNotification
		getNotificationByID getNotificationID
		want                want
		publish             publish
	}{
		"GetNotificationサービスはお知らせIDを渡すと、そのIDに応じたお知らせ詳細を返す": {
			input: input{
				notificationID: 1,
			},
			checkNotification: checkNotification{
				callCount: 1,
				err:       nil,
			},
			getNotificationByID: getNotificationID{
				callCount: 1,
				err:       nil,
				notification: entity.Notification{
					ID:          1,
					Title:       "お知らせ",
					Description: "ポイント送付された",
					IsChecked:   false,
					CreatedAt:   clock.FixedClocker{}.Now(),
				},
			},
			publish: publish{
				callCount: 1,
				err:       nil,
			},
			want: want{
				notification: GetNotificationResponse{
					ID:          1,
					Title:       "お知らせ",
					Description: "ポイント送付された",
					IsChecked:   false,
					CreatedAt:   "2022/05/10 12:34:56",
				},
				err: nil,
			},
		},
		"リポジトリに対してお知らせを既読状態にする際、予期せぬエラーが発生した場合は、DB予期せぬエラーを返す": {
			input: input{
				notificationID: 1,
			},
			checkNotification: checkNotification{
				callCount: 1,
				err:       sql.ErrConnDone,
			},
			getNotificationByID: getNotificationID{
				callCount:    0,
				err:          nil,
				notification: entity.Notification{},
			},
			publish: publish{
				callCount: 0,
				err:       nil,
			},
			want: want{
				notification: GetNotificationResponse{},
				err:          sql.ErrConnDone,
			},
		},
		"リポジトリに対してお知らせ詳細を取得する際、予期せぬエラーが発生した場合は、DB予期せぬエラーを返す": {
			input: input{
				notificationID: 1,
			},
			checkNotification: checkNotification{
				callCount: 1,
				err:       nil,
			},
			getNotificationByID: getNotificationID{
				callCount:    1,
				err:          sql.ErrConnDone,
				notification: entity.Notification{},
			},
			want: want{
				notification: GetNotificationResponse{},
				err:          sql.ErrConnDone,
			},
		},
		"チャネルに対するpublishが失敗すると、キャッシュ予期せぬエラーとユーザ情報を返す": {
			input: input{
				notificationID: 1,
			},
			checkNotification: checkNotification{
				callCount: 1,
				err:       nil,
			},
			getNotificationByID: getNotificationID{
				callCount: 1,
				err:       nil,
				notification: entity.Notification{
					ID:          1,
					Title:       "お知らせ",
					Description: "ポイント送付された",
					IsChecked:   false,
					CreatedAt:   clock.FixedClocker{}.Now(),
				},
			},
			publish: publish{
				callCount: 1,
				err:       myerror.ErrCacheException,
			},
			want: want{
				notification: GetNotificationResponse{
					ID:          1,
					Title:       "お知らせ",
					Description: "ポイント送付された",
					IsChecked:   false,
					CreatedAt:   "2022/05/10 12:34:56",
				},
				err: myerror.ErrCacheException,
			},
		},
	}

	for n, tt := range tests {
		tt := tt
		t.Run(n, func(t *testing.T) {
			t.Parallel()
			// コンテキスト作成
			w := httptest.NewRecorder()
			ctx, _ := gin.CreateTestContext(w)
			ctx.Set(auth.UserID, entity.UserID(1))

			// モックの定義
			ctrl := gomock.NewController(t)
			mockTx := testutil.NewTxForMock(t, ctx)

			mockBeginner := mock_repository.NewMockBeginner(ctrl)
			mockBeginner.
				EXPECT().
				BeginTxx(ctx, nil).
				Return(mockTx, nil)

			mockCache := mock_domain.NewMockCache(ctrl)
			mockCache.
				EXPECT().
				Publish(ctx, fmt.Sprintf("notification:%d", tt.input.notificationID), gomock.Any()).
				Return(tt.publish.err).
				Times(tt.publish.callCount)

			mockNotifRepo := mock_domain.NewMockNotificationRepo(ctrl)
			mockNotifRepo.
				EXPECT().
				CheckNotification(ctx, mockTx, entity.UserID(1), tt.input.notificationID).
				Return(tt.checkNotification.err).
				Times(tt.checkNotification.callCount)
			mockNotifRepo.
				EXPECT().
				GetNotificationByID(ctx, mockTx, entity.UserID(1), tt.input.notificationID).
				Return(tt.getNotificationByID.notification, tt.getNotificationByID.err).
				Times(tt.getNotificationByID.callCount)

			// サービス実行
			gn := &GetNotification{
				Cache:     mockCache,
				Tx:        mockBeginner,
				NotifRepo: mockNotifRepo,
			}
			gotNs, gotErr := gn.GetNotification(ctx, tt.input.notificationID)

			// アサーション
			assert.ErrorIs(t, gotErr, tt.want.err)
			assert.Equal(t, tt.want.notification, gotNs)
		})
	}
}

まず、testsというマップをループしています。このマップは、テストケースの集合を表しています。各テストケースは、ttという変数に格納されます。

次に、t.Run関数を使用して、各テストケースを個別のサブテストとして実行しています。これにより、各テストケースが独立して実行され、結果が個別に報告されます。

テストケース内では、まずhttptest.NewRecordergin.CreateTestContextを使用して、HTTPリクエストとレスポンスをシミュレートするためのテストコンテキストを作成しています。次に、ユーザーIDをコンテキストに設定しています。

その後、gomock.NewControllertestutil.NewTxForMockを使用して、モックコントローラとモックトランザクションを作成しています。これらは、テスト中にデータベース操作をシミュレートするために使用されます。
testutl.NewTxForMockは「1. テスト用のデータベースのモックを設定」で作成した関数になります。

次に、mock_repository.NewMockBeginnermock_domain.NewMockCachemock_domain.NewMockNotificationRepoを使用して、モックオブジェクトを作成しています。これらのモックオブジェクトは、それぞれデータベーストランザクションの開始、キャッシュの操作、通知の取得といった操作をシミュレートします。これは、「2. リポジトリ層のモックの設定」で作成したモックになります。

作成したモックオブジェクトに対して、EXPECTメソッドを使用して期待する動作を定義しています。これにより、テスト対象の関数がこれらのメソッドを呼び出したときの振る舞いを制御します。

Return関数は、モックオブジェクトが返すべき値を設定します。
Times関数は、関数が呼び出される回数を指定します。

これらの関数を組み合わせることで、関数が呼び出されたときの期待値を設定し、その結果を検証することができます。これにより、関数が正しく動作していることを確認することができ、テスト実行時に、BeginTxxPublishCheckNotificationGetNotificationByIDが呼ばれると、ここで定義した値が返却されます。

その後、テスト対象のGetNotification関数を呼び出しています。この関数には、作成したモックオブジェクトとテストケースの入力値が渡されます。

最後に、assertパッケージのErrorIsEqual関数を使用して、テスト対象の関数から得られた結果が期待する結果と一致することを確認しています。これにより、テスト対象の関数が正しく動作していることを検証します。

4. テストの実行

最後にテストを実行してみましょう!

shell
$ go test -timeout 30s -run ^TestGetNotification$ github.com/hack-31/point-app-backend/service
=== RUN   TestGetNotification
=== PAUSE TestGetNotification
=== CONT  TestGetNotification
=== RUN   TestGetNotification/GetNotificationサービスはお知らせIDを渡すと、そのIDに応じたお知らせ詳細を返す
=== PAUSE TestGetNotification/GetNotificationサービスはお知らせIDを渡すと、そのIDに応じたお知らせ詳細を返す
=== RUN   TestGetNotification/リポジトリに対してお知らせを既読状態にする際、予期せぬエラーが発生した場合は、DB予期せぬエラーを返す
=== PAUSE TestGetNotification/リポジトリに対してお知らせを既読状態にする際、予期せぬエラーが発生した場合は、DB予期せぬエラーを返す
=== RUN   TestGetNotification/リポジトリに対してお知らせ詳細を取得する際、予期せぬエラーが発生した場合は、DB予期せぬエラーを返す
=== PAUSE TestGetNotification/リポジトリに対してお知らせ詳細を取得する際、予期せぬエラーが発生した場合は、DB予期せぬエラーを返す
=== RUN   TestGetNotification/チャネルに対するpublishが失敗すると、キャッシュ予期せぬエラーとユーザ情報を返す
=== PAUSE TestGetNotification/チャネルに対するpublishが失敗すると、キャッシュ予期せぬエラーとユーザ情報を返す
=== CONT  TestGetNotification/GetNotificationサービスはお知らせIDを渡すと、そのIDに応じたお知らせ詳細を返す
=== CONT  TestGetNotification/リポジトリに対してお知らせ詳細を取得する際、予期せぬエラーが発生した場合は、DB予期せぬエラーを返す
--- PASS: TestGetNotification/リポジトリに対してお知らせ詳細を取得する際、予期せぬエラーが発生した場合は、DB予期せぬエラーを返す (0.00s)
=== CONT  TestGetNotification/チャネルに対するpublishが失敗すると、キャッシュ予期せぬエラーとユーザ情報を返す
--- PASS: TestGetNotification/チャネルに対するpublishが失敗すると、キャッシュ予期せぬエラーとユーザ情報を返す (0.00s)
=== CONT  TestGetNotification/リポジトリに対してお知らせを既読状態にする際、予期せぬエラーが発生した場合は、DB予期せぬエラーを返す
--- PASS: TestGetNotification/GetNotificationサービスはお知らせIDを渡すと、そのIDに応じたお知らせ詳細を返す (0.00s)
--- PASS: TestGetNotification/リポジトリに対してお知らせを既読状態にする際、予期せぬエラーが発生した場合は、DB予期せぬエラーを返す (0.00s)
--- PASS: TestGetNotification (0.00s)
PASS
ok      github.com/hack-31/point-app-backend/service    0.021s

テストも成功しました!

まとめ

サービス層のテストを実例を交えながら紹介しました。
ハンドラーのテスト、リポジトリのテストもまた別の機会に紹介します:+1:

GitHubで編集を提案

Discussion