gomockとは?
gomockとは、インターフェース定義からモックの生成を行うことができるgoのライブラリで、go公式から出されています。
しかし、READMEには
Update, June 2023: This repo and tool are no longer maintained. Please see go.uber.org/mock for a maintained fork instead.
と書かれており2023でメンテナンスが終了しているようでした。
READMEにも書かれているようにgo.uber.org/mock
がその代わりとして使用されています。
go.uber.org/mock
は、かの有名なUberEatsを運営しているUberがgomock
のメンテナンスを引き継いだライブラリです。
今回は、go.uber.org/mock
でモックを作成してみようと思います。
そもそもモックって?
個人開発のみをしている学生やプログラミングやりたての人からすると、あまり聞き慣れた単語ではないと思います。
モックは、その名の通り模型のことで、アプリケーションのテストをするために必要な部品を作成することを言います。
使用ケースとしては、データベースなどの外部に影響を受ける部分をテストする場合に、テストしたい部分がデータベースからデータを取り出すことでないことがあります。その場合は、取り出せたと仮定して動いてくれた方がテストで調べたい内容を正確に調べることができるため使用されます。
こう説明してみても、抽象的でわかりずらいと思うので、実際に実装してみようと思います。
実装してみる
userの登録機能を例に、ユースケース層でのテストを実装してみようと思います。
例えば、下のような感じの構造で実装したとします。
.
├── domain
│ ├── entity
│ ├── mockreposiotry
│ ├── mockrepository
│ └── repository
├── infrastructure
├── main.go
├── repository
└── service(ユースケース)
ただ、今回のようにユースケースでテストを行うだけなら下のような感じでも動きます。
.
├── domain
│ ├── entity
│ ├── mockreposiotry
│ ├── mockrepository
│ └── repository
├── main.go
├── repository
└── service(ユースケース)
下準備
今回は、ユースケースでのテスト実装に焦点を当てるので、最小限の実装コードのみを載せます。
- Userモデル定義
dbから取り出して扱うGoの構造体を定義します。
package entity
type User struct {
ID int
Name string
}
- repositoryのインターフェース
dbから取り出す操作をインターフェースとして抽象定義しています。
package repository
import "example.com/user/domain/entity"
type IUserRepository interface {
CreateUser(name string) (*entity.User, error)
}
- repositoryの実装
先ほど定義したインターフェースの中身を実装しています。
package repository
import (
"example.com/user/domain/entity"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) CreateUser(name string) (*entity.User, error) {
user := &entity.User{Name: name}
r.db.Create(user)
return user, nil
}
Goのインターフェース継承は、ダックタイピング
で行われます。
ダックタイピング
は、インターフェースに定義したメソッドを実装した構造体があれば、自動的にインターフェースが継承されたとみなされます。
- serviceの実装
ユースケースを実装しています。
ここでは、repositoryインターフェースからデータを取り出すことを行っています。
package service
import (
"example.com/user/domain/entity"
"example.com/user/domain/repository"
)
type UserService struct {
repo repository.IUserRepository
}
func NewUserService(repo repository.IUserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) CreateUser(name string) (*entity.User, error) {
user, err := s.repo.CreateUser(name)
if err != nil {
return nil, err
}
return user, nil
}
モックの作成
ここから実際にモックを作成してみます。
インストール
go.uber.org/mock
のインストールは下のようにします。
go get go.uber.org/mock/mockgen
go install go.uber.org/mock/mockgen@latest
go.uber.org/mock
のGithubリポジトリのREADMEにgo get
の文面見つからないかもしれませんがつけてください。
go install
だけだと依存関係を管理するgo mod
には追加されません。
go getも行ってください
インストールできたら下のコマンドで入っているかを確認します。
mockgen -version
もし入っていなかった場合は、GOPATHを通してください
export PATH=$PATH:$(go env GOPATH)/bin
モックを生成する
下のコマンドでモックを作成します。
mockgen -source=domain/repository/user.go -destination=domain/mockreposiotry/user.go -package mockrepository
-source
でモックにしたいインターフェースが定義されているファイルを指定します。
-destination
でモックを生成する場所を指定します。
package
でモックのパッケージ名を指定します。
生成されたコードは下のようになります。
// Code generated by MockGen. DO NOT EDIT.
// Source: domain/repository/user.go
//
// Generated by this command:
//
// mockgen -source=domain/repository/user.go -destination=domain/mockreposiotry/user.go -package mockrepository
//
// Package mockrepository is a generated GoMock package.
package mockrepository
import (
reflect "reflect"
entity "example.com/user/domain/entity"
gomock "go.uber.org/mock/gomock"
)
// MockIUserRepository is a mock of IUserRepository interface.
type MockIUserRepository struct {
ctrl *gomock.Controller
recorder *MockIUserRepositoryMockRecorder
}
// MockIUserRepositoryMockRecorder is the mock recorder for MockIUserRepository.
type MockIUserRepositoryMockRecorder struct {
mock *MockIUserRepository
}
// NewMockIUserRepository creates a new mock instance.
func NewMockIUserRepository(ctrl *gomock.Controller) *MockIUserRepository {
mock := &MockIUserRepository{ctrl: ctrl}
mock.recorder = &MockIUserRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockIUserRepository) EXPECT() *MockIUserRepositoryMockRecorder {
return m.recorder
}
// CreateUser mocks base method.
func (m *MockIUserRepository) CreateUser(name string) (*entity.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateUser", name)
ret0, _ := ret[0].(*entity.User)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CreateUser indicates an expected call of CreateUser.
func (mr *MockIUserRepositoryMockRecorder) CreateUser(name any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockIUserRepository)(nil).CreateUser), name)
}
コメントアウトにも書かれていますが、このファイルは編集するのはダメです。
モックを使ってみる
実際に生成したモックでテストを書いてみます。
package service
import (
"testing"
"example.com/user/domain/entity"
mockrepository "example.com/user/domain/mockreposiotry"
gomock "go.uber.org/mock/gomock"
)
func TestCreateUser(t *testing.T) {
ctrl := gomock.NewController(t)
mr := mockrepository.NewMockIUserRepository(ctrl)
svc := NewUserService(mr)
mr.EXPECT().CreateUser("John").Return(&entity.User{ID: 1, Name: "John"}, nil).Times(1)
user, err := svc.CreateUser("John")
if err != nil {
t.Errorf("Error was not expected, got: %v", err)
}
if user.Name != "John" {
t.Errorf("User name is not correct, got: %s", user.Name)
}
if user.ID != 1 {
t.Errorf("User ID is not correct, got: %v", user.ID)
}
}
実行してみると下のようになります。
go test -v
=== RUN TestCreateUser
--- PASS: TestCreateUser (0.00s)
PASS
ok example.com/user/service 0.411s
テストコードの説明
まず、モックの呼び出しに必要なコントローラーを生成します。
ctrl := gomock.NewController(t)
生成したコントローラーを使ってモックリポジトリを生成させます。
mr := mockrepository.NewMockIUserRepository(ctrl)
作成したモックを依存関係として、NewUserService()
にDIすることで、serviceオブジェクトを生成します。
svc := NewUserService(mr)
DI(Dependacy Injection)とは
依存性注入
と呼ばれる方法で、インターフェースを依存関係として入れることで、複雑なオブジェクトを依存させない方法です。
オブジェクト指向プログラミングでは、コンストラクタにインスタンスの生成に必要な依存関係を記述します。
Goではコンストラクタにインターフェースが依存関係にあることを明記することで、実際にインスタンスを生成するときにインターフェースを継承したものであればどんなものでもインスタンスメソッドの引数に入れることができます。
今回でいうとNewUserService()
の引数は、インターフェースを継承したMockIUserRepository
もNewUserRepository
も入れてインスタンスを生成することが可能です。
モックリポジトリにデータを入れます。
mr.EXPECT().CreateUser("John").Return(&entity.User{ID: 1, Name: "John"}, nil).Times(1)
EXPECT()
は、mockgenで生成させたmockrepository/user.go
の中で下のように定義されています。
ここでは、recorder(今回だとMockIUserRepositoryMockRecorder
)を返しています
func (m *MockIUserRepository) EXPECT() *MockIUserRepositoryMockRecorder {
return m.recorder
}
CreateUser()
は、実は生成されたモックに2つ定義されています。
func (m *MockIUserRepository) CreateUser(name string) (*entity.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateUser", name)
ret0, _ := ret[0].(*entity.User)
ret1, _ := ret[1].(error)
return ret0, ret1
}
func (mr *MockIUserRepositoryMockRecorder) CreateUser(name any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockIUserRepository)(nil).CreateUser), name)
}
直前のEXPECT()
でrecorderを返しているので、今回は下のCreateUser()
が使用されています。
Return()
では、モックが結果的にどのようなデータを返すかを定義しています。
今回は、モックリポジトリがdbにIDが1の名前が"John"という人のデータを入れたという仮の結果を想定しています。
Times(1)
では、使用するサービスのメソッド内で何回CreateUser()
が呼び出されるかを制御しています。
再掲しますが、svc.CreateUser()
は下のような実装になっていて、リポジトリのCreateUser()
は1回しか呼び出されていません。なので、Times(1)
と設定しています。
func (s *UserService) CreateUser(name string) (*entity.User, error) {
user, err := s.repo.CreateUser(name)
if err != nil {
return nil, err
}
return user, nil
}
Times()
について
Times(1)と書いていますが、実はデフォルトでTimes(1)
となっているため、書かなくてもいいです。
しかし、Times()の引数の数字はもし1回以上呼ばれる場合、Times()
もしくはAnyTimes
(実行回数制限かけないメソッド)を使わないとエラーを吐いてしまうので、気をつけてください。
Times()がデフォルトで1となっているのは、MinTimes()に下のような記述があります。
MinTimes requires the call to occur at least n times. If AnyTimes or MaxTimes have not been called or if MaxTimes was previously called with 1, MinTimes also sets the maximum number of calls to infinity.