Open4

【Golang】DIなしでDBへの接続をモックする

きよしろーきよしろー

モチベーション

テスト時、DBに繋がずに単体テストを行うために、DIをするのが一般的。
そのためにはinterfaceを作り、interfaceに依存するようにコードを書く必要がある。

type UserRepository interface {
  FindById(ctx context.Context, id int) (*model.User, error)
}

type userUsecase struct {
  userRepo UserRepository // interfaceに依存
}

ある日、ふと思った。自分はDBへの接続をモックしたいだけで、DIをしたいわけでもinterfaceを作りたいわけでもない。他に方法はないのか。

きよしろーきよしろー

overlayを使うことで、DB接続を含む処理に直接依存できる説

go testには-overlayフラグがある。これに以下のようなjsonを渡すとテスト時に指定したファイルを置き換えてくれる

overlay.json
{
  "Replace": {
    "./db/user.go": "./mock/db/user.go"
  }
}

これを使えば、DBに接続する処理に直接依存できるのでは?

db/user.go
type userRepository struct {
	db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *userRepository {
	return &userRepository{db}
}

// DB接続を含む処理
func (u *userRepository) FindById(ctx context.Context, id int) (*model.User, error) {
	var user *model.User
	res := u.db.First(user, id)
	return user, res.Error
}
mock/db/user.go
type userRepository struct {
	db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *userRepository {
	return &userRepository{db}
}

func (u *userRepository) FindById(ctx context.Context, id int) (*model.User, error) {
	// モックして特定の値のみを返す
	return &model.User{ID: 1, Name: "user 1"}, nil
}
main.go
// テスト対象のメソッド
func run() string {
	ctx := context.Background()
	db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

	// DB接続を含む処理に直接依存している
	userRepo := repository.NewUserRepository(db)
	user, _ := userRepo.FindById(ctx, 1)
	res, _ := json.Marshal(user)
	return string(res)
}
main_test.go
func Test(t *testing.T) {
	got := run()
	assert.Equal(t, `{"id":1,"name":"user 1"}`, got)
}

実行してみる

go test -overlay overlay.json
PASS

うまくいっている

きよしろーきよしろー

問題点と解決方法

先程のmock/db/user.goの実装だと、複数パターンのmockを返すことができない。
つまり、以下のようなtable駆動のテストケースで困る

tests := []struct {
	name string
	want string
}{
	{
		name: "通常時",
		want: `{"id":1,"name":"user 1"}`,
	},
	{
		name: "該当ユーザーがいなかったとき",
		want: "null",
	},
}

解決策の一案として、mock/db/user.goを以下のように書き換えた

mock/db/user.go
type userRepository struct{}

func NewUserRepository(db *gorm.DB) *userRepository {
	return &userRepository{}
}

+ type UserRepositoryFindByIdEnum string

+ const (
+ 	UserRepositoryFindByIdKey = "UserRepositoryFindByIdKey"
+ 
+ 	UserRepositoryFindByIdNormal UserRepositoryFindByIdEnum = "通常時"
+ 	UserRepositoryFindByIdEmpty                             = "該当ユーザーがいなかったとき"
+ )

func (u *userRepository) FindById(ctx context.Context, id int) (*model.User, error) {
+ 	selected := UserRepositoryFindByIdEnum(os.Getenv(UserRepositoryFindByIdKey))
+ 	// 環境変数に設定された値によってreturnするものを出し分ける
+ 	switch selected {
+ 	case UserRepositoryFindByIdNormal:
+ 		return &model.User{ID: 1, Name: "user 1"}, nil
+ 	case UserRepositoryFindByIdEmpty:
+ 		return nil, nil
+ 	}
+ 	return nil, nil
}

テストコードでは以下のように環境変数をセットすることで、希望するモック値が返ってくるようにしている

main_test.go
import (
	"os"
	"testing"

	mockDB "without-di/mock/db"

	"github.com/stretchr/testify/assert"
)

func Test(t *testing.T) {
	tests := []struct {
		name                               string
		selectedUserRepositoryFindByIdMock mockDB.UserRepositoryFindByIdEnum
		want                               string
	}{
		{
			name:                               "first case",
+			selectedUserRepositoryFindByIdMock: mockDB.UserRepositoryFindByIdNormal,
			want:                               `{"id":1,"name":"user 1"}`,
		},
		{
			name:                               "second case",
+			selectedUserRepositoryFindByIdMock: mockDB.UserRepositoryFindByIdEmpty,
			want:                               "null",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
+			os.Setenv(mockDB.UserRepositoryFindByIdKey, string(tt.selectedUserRepositoryFindByIdMock))
			got := run()
			assert.Equal(t, tt.want, got)
		})
	}
}

(なお、環境変数に書き込んだのは、パッケージをまたがって変数の値を共有する方法が他にわからなかったから)

きよしろーきよしろー

依然として残る問題

実装コード(db/user.go)とモックコード(mock/db/user/go)のシグネチャが乖離しても気がつきづらい。

// 実装
func (u *userRepository) FindById(ctx context.Context, id int) (*model.User, error) {

// モック。ctxが引数から抜けているが、テストを実行してみるまでエラーがでてくれない
func (u *userRepository) FindById(id int) (*model.User, error) {

また、モックコード側に余計なメソッドが生えていたりしてもエラーがでない。
overlay.jsonにかかれている、実装コードと対応するモックコード同士のASTをうまいこと比較して、シグネチャが違ったらエラーを出すなどが必要かもしれない。