LoginSignup
0
0

Go+Gin+GORM開発環境で食品管理APIを作る~単体テスト編~

Posted at

はじめに

こちらの記事でクリーンアーキテクチャを意識した構成に変更した食品管理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

テストを実装するにあたり、mocktestifyを導入します。

$ 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/mockmock_cooking_repository.goが作成されたかと思います。

mock_cooking_repository.goの中身はこちら
./domain/repository/mock/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が必要になりますので、同じように作成します。

usecaseのmockを作る場合の作成例
$ cd ./usecase
$ mockgen -source=cooking_usecase.go -destination=./mock/mock_cooking_usecase.go

※本書ではhandlerのテストの実装はしません。

テストケースの実装

mockを使ったテストケースを実装していきます。

Go言語では試験対象となるソースファイルの末尾に_testとつけたソースファイルを作成します。

$ touch cooking_usecase_test.go
./usecase/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関数が呼び出すCookingRepositoryGetAllTheFoodRepository関数の振る舞いを定義します。

cooking_repository.goGetAllTheFoodRepository関数ではDBアクセスして値を返しますが、mockを利用することで実際のGetAllTheFoodRepositoryを呼び出すことなく、処理結果を返却することができます。

    // テスト対象のメソッド
    uc := usecase.NewCookingUsecase(mockRepo)
    result, err := uc.GetFoodsUsecase()

usecase.NewCookingUsecase(mockRepo)mockrepositoryを注入してusecaseを利用できるようにします。
そしてテスト対象であるGetFoodsUsecaseを実行します。

    assert.NoError(t, err)
    assert.Equal(t, expected, result)

処理結果の検証はこちら。
今回は正常に値が返ることを検証する試験ですので、エラーがないことと取得結果を検証しています。

なお、repository.GetAllTheFoodRepositoryから返却される値はrepository.Food構造体ですが、GetFoodsUsecaseではこれを[]stringになるよう整形していますので、GetAllTheFoodRepositoryの戻り値に設定したfoodsexpectedのように整形させて返却されることを期待して、検証しています。

テストケースの実行

作成したらテストを実行してみます。
テストの実施は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

まとめ

いかがだったでしょうか。依存性の注入を行ったことでテストがよりシンプルに、疎で行えるようになったのがわかるかと思います。

もし本書をハンズオンで進めていましたら、ほかのテストケースも実装してみましょう。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0