はじめに
こちらの記事でクリーンアーキテクチャを意識した構成に変更した食品管理APIで単体試験(Unit Test)を実装するための手法をまとめました。
単体試験とは
コードの最小機能ユニットをテストするプロセスのことです。
前回記事の最後にも書きましたが、このシステムでいえばfunc
単位で行う試験ということになりますね。
ただし、クリーンアーキテクチャに対応する前のソースコードに対して単体試験を行おうとした場合、試験対象とする処理の中で呼び出される処理まで実行されてしまうため、イマイチという話でした。
クリーンアーキテクチャにおける試験は、抽象(インターフェイス)を参照(依存)するため、実装を簡単に差し替えられることができます。
検証環境
前回記事参照
ディレクトリ構成
最終的には次のようになります。
本書ですべての試験を実装するわけではありません。説明用に未完成な状態となっています。
.
├── .air.toml
├── Dockerfile
├── docker-compose.yml
├── domain
│ └── repository
│ ├── cooking_repository.go
│ └── mock ★本書で追加
│ └── mock_cooking_repository.go ★本書で追加
├── go.mod
├── go.sum
├── handler
│ ├── api_route.go
│ ├── rest
│ │ └── cooking_handler.go
│ ├── wire.go
│ └── wire_gen.go
├── library
│ └── database
│ └── database.go
├── main.go
├── memo.txt
├── tmp
│ ├── build-errors.log
│ └── main
└── usecase
├── cooking_usecase.go
├── cooking_usecase_test.go ★本書で追加
└── mock ★本書で追加(任意)
└── mock_cooking_usecase.go ★本書で追加(任意)
10 directories, 20 files
実行条件
Golang v1.22.3
Gin v1.10.0
GORM v1.25.10
air v1.52.0
wire v0.6.0
mock v1.6.0
testify v1.9.0
MySQL 8.0.29
テストを実装するにあたり、mock
とtestify
を導入します。
$ go get github.com/golang/mock/gomock
$ go get github.com/stretchr/testify/assert
もし本書に記載している以外のテストを実装する際にほかのライブラリが必要となった場合は、適宜追加してください。
テストケースの実装
本書ではusecaseのテストケースを1件、実装してみます。
mockの準備
usecaseではrepositoryを注入しているため、repositoryのmockを作ります。
mockの実装にはgithub.com/golang/mock/gomock
のmockgenコマンドを利用します。
$ cd ./domain/repository
$ mockgen -source=cooking_repository.go -destination=./mock/mock_cooking_repository.go
このコマンドは-source
に指定したファイルのmockを-destination
に作成します。
./domain/repository/mock
にmock_cooking_repository.go
が作成されたかと思います。
mock_cooking_repository.goの中身はこちら
// Code generated by MockGen. DO NOT EDIT.
// Source: cooking_repository.go
// Package mock_repository is a generated GoMock package.
package mock_repository
import (
repository "go_project/domain/repository"
reflect "reflect"
gomock "github.com/golang/mock/gomock"
)
// MockCookingRepository is a mock of CookingRepository interface.
type MockCookingRepository struct {
ctrl *gomock.Controller
recorder *MockCookingRepositoryMockRecorder
}
// MockCookingRepositoryMockRecorder is the mock recorder for MockCookingRepository.
type MockCookingRepositoryMockRecorder struct {
mock *MockCookingRepository
}
// NewMockCookingRepository creates a new mock instance.
func NewMockCookingRepository(ctrl *gomock.Controller) *MockCookingRepository {
mock := &MockCookingRepository{ctrl: ctrl}
mock.recorder = &MockCookingRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockCookingRepository) EXPECT() *MockCookingRepositoryMockRecorder {
return m.recorder
}
// DeleteFoodRepository mocks base method.
func (m *MockCookingRepository) DeleteFoodRepository(food repository.Food) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteFoodRepository", food)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteFoodRepository indicates an expected call of DeleteFoodRepository.
func (mr *MockCookingRepositoryMockRecorder) DeleteFoodRepository(food interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteFoodRepository", reflect.TypeOf((*MockCookingRepository)(nil).DeleteFoodRepository), food)
}
// GetAllTheFoodRepository mocks base method.
func (m *MockCookingRepository) GetAllTheFoodRepository() ([]repository.Food, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAllTheFoodRepository")
ret0, _ := ret[0].([]repository.Food)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAllTheFoodRepository indicates an expected call of GetAllTheFoodRepository.
func (mr *MockCookingRepositoryMockRecorder) GetAllTheFoodRepository() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllTheFoodRepository", reflect.TypeOf((*MockCookingRepository)(nil).GetAllTheFoodRepository))
}
// GetOneFoodRepository mocks base method.
func (m *MockCookingRepository) GetOneFoodRepository(food_name string, preload_flag bool) (repository.Food, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOneFoodRepository", food_name, preload_flag)
ret0, _ := ret[0].(repository.Food)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOneFoodRepository indicates an expected call of GetOneFoodRepository.
func (mr *MockCookingRepositoryMockRecorder) GetOneFoodRepository(food_name, preload_flag interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOneFoodRepository", reflect.TypeOf((*MockCookingRepository)(nil).GetOneFoodRepository), food_name, preload_flag)
}
// RegisterForFoodRepository mocks base method.
func (m *MockCookingRepository) RegisterForFoodRepository(food_name string, ingredients []string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RegisterForFoodRepository", food_name, ingredients)
ret0, _ := ret[0].(error)
return ret0
}
// RegisterForFoodRepository indicates an expected call of RegisterForFoodRepository.
func (mr *MockCookingRepositoryMockRecorder) RegisterForFoodRepository(food_name, ingredients interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterForFoodRepository", reflect.TypeOf((*MockCookingRepository)(nil).RegisterForFoodRepository), food_name, ingredients)
}
// UpdateFoodRepository mocks base method.
func (m *MockCookingRepository) UpdateFoodRepository(food repository.Food, ingredients []string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateFoodRepository", food, ingredients)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateFoodRepository indicates an expected call of UpdateFoodRepository.
func (mr *MockCookingRepositoryMockRecorder) UpdateFoodRepository(food, ingredients interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateFoodRepository", reflect.TypeOf((*MockCookingRepository)(nil).UpdateFoodRepository), food, ingredients)
}
内容の詳しい説明は割愛しますが、要するにインターフェイスのモックが自動的に作成されたということです。
同様にhandlerのテストケースを作るときにはusecaseのmockが必要になりますので、同じように作成します。
$ cd ./usecase
$ mockgen -source=cooking_usecase.go -destination=./mock/mock_cooking_usecase.go
※本書ではhandlerのテストの実装はしません。
テストケースの実装
mockを使ったテストケースを実装していきます。
Go言語では試験対象となるソースファイルの末尾に_test
とつけたソースファイルを作成します。
$ touch cooking_usecase_test.go
package usecase_test
import (
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"go_project/domain/repository"
"go_project/domain/repository/mock"
"go_project/usecase"
)
// 食品全件取得ユースケースのテスト
// 正常系
// 取得結果が期待値と一致することを検証する
func TestGetFoodsUsecase(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mock_repository.NewMockCookingRepository(ctrl)
// テストデータ
foods := []repository.Food{
{
Id: 1,
FoodName: "Pizza",
Ingredients : []repository.Ingredient{
{
Id : 1,
IngredientName : "Cheese",
},
{
Id : 2,
IngredientName : "Tomato",
},
},
},
{
Id: 2,
FoodName: "Burger",
Ingredients : []repository.Ingredient{
{
Id : 1,
IngredientName : "Cheese",
},
{
Id : 3,
IngredientName : "lettuce",
},
},
},
}
// 期待値
expected := []string{"Pizza", "Burger"}
// モックの振る舞いを定義
mockRepo.EXPECT().GetAllTheFoodRepository().Return(foods, nil)
// テスト対象のメソッド
uc := usecase.NewCookingUsecase(mockRepo)
result, err := uc.GetFoodsUsecase()
// 戻り値を検証する
assert.NoError(t, err)
assert.Equal(t, expected, result)
}
/* 他のテストケースも実装する */
それでは細かく見ていきましょう。
import (
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"go_project/domain/repository"
"go_project/domain/repository/mock"
"go_project/usecase"
)
テストケースの実装ではtesting
をインポートします。それ以外は試験や実装に必要になります。
func TestGetFoodsUsecase(t *testing.T) {/*処理*/}
テストケースの関数名は先頭にTest
を付けます。また、引数はt *testing.T
になります。
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mock_repository.NewMockCookingRepository(ctrl)
gomock.NewController(t)
でmock
の利用を宣言します。
試験対象であるGetFoodsUsecase
関数ではCookingRepository
を利用しているため、この試験では先ほどmockgen
で生成したNewMockCookingRepository
を利用します。
// モックの振る舞いを定義
mockRepo.EXPECT().GetAllTheFoodRepository().Return(foods, nil)
GetFoodsUsecase
関数が呼び出すCookingRepository
のGetAllTheFoodRepository
関数の振る舞いを定義します。
cooking_repository.go
のGetAllTheFoodRepository
関数ではDBアクセスして値を返しますが、mock
を利用することで実際のGetAllTheFoodRepository
を呼び出すことなく、処理結果を返却することができます。
// テスト対象のメソッド
uc := usecase.NewCookingUsecase(mockRepo)
result, err := uc.GetFoodsUsecase()
usecase.NewCookingUsecase(mockRepo)
でmock
のrepository
を注入してusecase
を利用できるようにします。
そしてテスト対象であるGetFoodsUsecase
を実行します。
assert.NoError(t, err)
assert.Equal(t, expected, result)
処理結果の検証はこちら。
今回は正常に値が返ることを検証する試験ですので、エラーがないことと取得結果を検証しています。
なお、repository.GetAllTheFoodRepository
から返却される値はrepository.Food
構造体ですが、GetFoodsUsecase
ではこれを[]string
になるよう整形していますので、GetAllTheFoodRepository
の戻り値に設定したfoods
がexpected
のように整形させて返却されることを期待して、検証しています。
テストケースの実行
作成したらテストを実行してみます。
テストの実施はgo test テストケースファイル テスト対象ファイル
で行います。
$ cd ./usecase
$ go test cooking_usecase_test.go cooking_usecase.go
ok command-line-arguments 0.003s
go test
だけでも実行できますが、ほかにもテストケースがある場合はまとめて実行されます。
- テストでエラーがある場合
$ go test cooking_usecase_test.go cooking_usecase.go
--- FAIL: TestGetFoodsUsecase (0.00s)
cooking_usecase_test.go:66:
Error Trace: /app/usecase/cooking_usecase_test.go:66
Error: Not equal:
expected: []string{"Pizza", "Burger", "Cheese"}
actual : []string{"Pizza", "Burger"}
Diff:
--- Expected
+++ Actual
@@ -1,5 +1,4 @@
-([]string) (len=3) {
+([]string) (len=2) {
(string) (len=5) "Pizza",
- (string) (len=6) "Burger",
- (string) (len=6) "Cheese"
+ (string) (len=6) "Burger"
}
Test: TestGetFoodsUsecase
FAIL
FAIL command-line-arguments 0.003s
FAIL
- カバレッジ出力(-coverオプション)
go test -cover
PASS
coverage: 13.7% of statements
ok go_project/usecase 0.003s
- カバレッジ出力(可視化)
$ go test -v -cover -coverprofile=出力ファイル名
$ go tool cover -html=出力ファイル名 -o 出力ファイル名.html
まとめ
いかがだったでしょうか。依存性の注入を行ったことでテストがよりシンプルに、疎で行えるようになったのがわかるかと思います。
もし本書をハンズオンで進めていましたら、ほかのテストケースも実装してみましょう。