🥇

Goのコード生成を利用してユニットテストを書く方法

2022/07/15に公開

読んでわかること

入出力を保証するユニットテストのテンプレートを自動生成する方法。

私が考えるテストのテンプレートを自動生成メリット

  • テストケースに集中できる。
  • テストコードが統一される。
  • テスト追加を忘れない。テストを書く手間が省ける。

テスト対象

前提として今回はユニットテストです。

引数がaのときの出力がbであることの保証をします。

internal/usecases/user_usecase.go

package usecases

import "sample/internal/models"

type (
	UserUsecase struct {
		userRepo UserRepo
	}

	UserRepo interface {
		Find(id int) models.User
	}
)

func NewUserUsecase(userRepo UserRepo) UserUsecase {
	return UserUsecase{userRepo}
}

func (u UserUsecase) GetMe(id int) (models.User, error) {
	return u.userRepo.Find(id), nil
}

環境

  • macOSMonterey 12.2.1
  • x86_64
  • go1.18

準備

go get -u github.com/cweill/gotests/...
go install github.com/golang/mock/mockgen@latest
go install github.com/sanposhiho/gomockhandler@latest

テストコード生成

gotests -w -all internal/usecases/user_usecase.go

生成物

internal/usecases/user_usecase_test.go

package usecases

import (
	"reflect"
	"sample/internal/models"
	"testing"
)

// Snip ...

func TestUserUsecase_GetMe(t *testing.T) {
	type fields struct {
		userRepo UserRepo
	}
	type args struct {
		id int
	}
	tests := []struct {
		name    string
		fields  fields
		args    args
		want    models.User
		wantErr bool
	}{
		// TODO: Add test cases.
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			u := UserUsecase{
				userRepo: tt.fields.userRepo,
			}
			got, err := u.GetMe(tt.args.id)
			if (err != nil) != tt.wantErr {
				t.Errorf("UserUsecase.GetMe() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("UserUsecase.GetMe() = %v, want %v", got, tt.want)
			}
		})
	}
}

これでテストケース以外のものが生成されました。

具体的には、

  • テスト対象の構造体の初期化
  • テスト対象のメソッドの呼び出し
  • errが返るべきかの確認
  • メソッド呼び出し返り値と期待値のreflect.DeepEqualでの比較

後はテストケースだけ自分で記述すれば、ひとまずテストはできます。

何が足りないか

このままでも最低限は成立しますが、場合によって足りないものがあります

  • モックコード
  • モックのDI
  • errが返ってほしいとき、具体的に何のエラーが返ったかのテスト
  • 対象が状態を持つ場合、その状態のテスト

今回はモックコードの自動生成だけ扱います。

モックの生成

gomockhandler -config=gomockhandler.json -source=internal/usecases/user_usecase.go -destination=internal/mock_usecases/mock_user_usecase.go -package=mock_usecases

生成物

internal/mock_usecases/mock_user_usecase.go

// Code generated by MockGen. DO NOT EDIT.
// Source: internal/usecases/user_usecase.go

// Package mock_usecases is a generated GoMock package.
package mock_usecases

import (
	reflect "reflect"
	models "sample/internal/models"

	gomock "github.com/golang/mock/gomock"
)

// MockUserRepo is a mock of UserRepo interface.
type MockUserRepo struct {
	ctrl     *gomock.Controller
	recorder *MockUserRepoMockRecorder
}

// MockUserRepoMockRecorder is the mock recorder for MockUserRepo.
type MockUserRepoMockRecorder struct {
	mock *MockUserRepo
}

// NewMockUserRepo creates a new mock instance.
func NewMockUserRepo(ctrl *gomock.Controller) *MockUserRepo {
	mock := &MockUserRepo{ctrl: ctrl}
	mock.recorder = &MockUserRepoMockRecorder{mock}
	return mock
}

// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockUserRepo) EXPECT() *MockUserRepoMockRecorder {
	return m.recorder
}

// Find mocks base method.
func (m *MockUserRepo) Find(id int) models.User {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "Find", id)
	ret0, _ := ret[0].(models.User)
	return ret0
}

// Find indicates an expected call of Find.
func (mr *MockUserRepoMockRecorder) Find(id interface{}) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockUserRepo)(nil).Find), id)
}

UserUsecaseが必要としている、UserRepoを自動生成してくれました。

NewMockUserRepo 関数を利用してモックを初期化しましょう

モックを使用したテスト

package usecases

import (
	"reflect"
	"sample/internal/mock_usecases"
	"sample/internal/models"
	"testing"

	"github.com/golang/mock/gomock"
)

// Snip ...

var validUser = models.User{ID: 1, Name: "mock_return", Age: 19}

func TestUserUsecase_GetMe(t *testing.T) {
	type fields struct {
		userRepo UserRepo
	}
	type args struct {
		id int
	}
	tests := []struct {
		name    string
		args    args
		want    models.User
		wantErr bool
		expectMockFn func(mockUserRepo *mock_usecases.MockUserRepo)
	}{
		{"case1", 1, validUser, false, func(mockUserRepo *mock_usecases.MockUserRepo) {
			mockUserRepo.EXPECT().Find(1).Return(validUser)
		}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)
			defer ctrl.Finish()

			mockUserRepo := mock_usecases.NewMockUserRepo(ctrl)
			tt.expectMockFn(mockUserRepo)

			u := UserUsecase{
				userRepo: mockUserRepo,
			}
			got, err := u.GetMe(tt.args.id)
			if (err != nil) != tt.wantErr {
				t.Errorf("UserUsecase.GetMe() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("UserUsecase.GetMe() = %v, want %v", got, tt.want)
			}
		})
	}
}

Go generateで生成できるように

go generate ./...

で全てのテストコードとモックを生成できるようにします。

$SAMPLE_REPO_ROOT を定義して、gomockhandler.jsonをレポジトリのルートに指定しています。

internal/usecases/user_usecase.go

package usecases

//go:generate gotests -w -all $GOFILE
//go:generate gomockhandler -config=$SAMPLE_REPO_ROOT/gomockhandler.json -source=$GOFILE -destination=mock_$GOPACKAGE/mock_$GOFILE -package=mock_$GOPACKAGE

import "sample/internal/models"

type (
	UserUsecase struct {
		userRepo UserRepo
	}

	UserRepo interface {
		Find(id int) models.User
	}
)

func NewUserUsecase(userRepo UserRepo) UserUsecase {
	return UserUsecase{userRepo}
}

func (u UserUsecase) GetMe(id int) (models.User, error) {
	return u.userRepo.Find(id), nil
}

今後

  1. モックのDI
  2. gotestsをモックが使用できるように編集する必要がある。
  3. errが返ってほしいとき、具体的に何のエラーが返ったかのテスト

個人的には上記は解決したいと考えます。

モックのDIに関しては google/wire を使用して解決しようと思っています。
2,3 に関しては gotestsがテンプレートを設定できそうなので試してみようと思います。

次回はこれらを解消してまとめ版の記事としたいと思います。

Discussion