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をうまいこと比較して、シグネチャが違ったらエラーを出すなどが必要かもしれない。