はじめに
単体テストを書くときにパターン化されて作るようになってきたのでここらでちょいと言語化してみようと思い記事にしてみました。(ほぼ自分用のメモです。)
ライブラリ
golang/mock (uber/mock)
モック用ライブラリです。2023年6月28日をもって Public archive となってしまいました。
Update, June 2023: This repo and tool are no longer maintained. Please see go.uber.org/mock for a maintained fork instead.
This project originates from Google's golang/mock repo. Unfortunately Google no longer maintains this project, and given the heavy usage of gomock project within Uber, we've decided to fork and maintain this going forward at Uber.
今後は Uber にてリポジトリが管理されていくとのことです。
google/go-cmp
値比較用ライブラリです。reflect.DeepEqual()
でいいじゃんと思っていましたが、テストに失敗したときの差分出力やある値だけ比較から除外したいというのが便利です。
cwill/gotests
テスト自動生成用ライブラリです。明示的にこのツールを使おうとしてインストールしているのではなく、VSCodeでコーディングをしていて対象メソッド右クリック -> Go: Generate Unit Tests For Function
で使っています。
このツールで自動生成されるテストコードは Table Driven Tests になっています。
実装
実際に私がどんな感じでテストコードを書いていくのか記載します。
0. テスト対象コードを用意
- クリーンアーキテクチャちっくな構成を想定しています。
├── model │ └── model.go ├── errors │ └── errors.go ├── repository │ ├── repository.go │ └── repository_mock.go ├── gateway │ └── gateway.go ├── usecase │ ├── usecase.go │ └── usecase_test.go └── main.go
- model ... ドメインモデル
- errors ... 独自のエラー定義
- repository ... 永続化層の抽象、mockしたい
- gateway ... 永続化層の具象(今回はあんまり関係ない)
- usecase ... アプリケーション処理、repository に依存を持つ
repository.go
package repository
import (
"context"
"github.com/otakakot/sample-go-unit-test-code/model"
)
//go:generate mockgen -source repository.go -destination repository_mock.go -package repository
type Repository interface {
Save(context.Context, model.Model) error
Find(context.Context, string) (model.Model, error)
}
usecase_test.go
package usecase
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/otakakot/sample-go-unit-test-code/model"
"github.com/otakakot/sample-go-unit-test-code/repository"
)
type Usecase struct {
repository repository.Repository
}
func New(
repository repository.Repository,
) *Usecase {
return &Usecase{
repository: repository,
}
}
func (uc *Usecase) Create(
ctx context.Context,
name string,
) (model.Model, error) {
mdl := model.Model{
ID: uuid.NewString(),
Name: name,
}
if err := uc.repository.Save(ctx, mdl); err != nil {
return model.Model{}, fmt.Errorf("failed to save model: %w", err)
}
return mdl, nil
}
func (uc *Usecase) Read(
ctx context.Context,
id string,
) (model.Model, error) {
mdl, err := uc.repository.Find(ctx, id)
if err != nil {
return model.Model{}, fmt.Errorf("failed to find model: %w", err)
}
return mdl, nil
}
1. テストコードを自動生成
- 前述した通り、VSCode により自動生成します。
usecase_test.go
package usecase
import (
"context"
reflect "reflect"
"testing"
"github.com/otakakot/playground-gomock/model"
"github.com/otakakot/playground-gomock/repository"
)
func TestUsecase_Create(t *testing.T) {
type fields struct {
repository repository.Repository
}
type args struct {
ctx context.Context
name string
}
tests := []struct {
name string
fields fields
args args
want model.Model
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
uc := &Usecase{
repository: tt.fields.repository,
}
got, err := uc.Create(tt.args.ctx, tt.args.name)
if (err != nil) != tt.wantErr {
t.Errorf("Usecase.Create() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Usecase.Create() = %v, want %v", got, tt.want)
}
})
}
}
func TestUsecase_Read(t *testing.T) {
type fields struct {
repository repository.Repository
}
type args struct {
ctx context.Context
id string
}
tests := []struct {
name string
fields fields
args args
want model.Model
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
uc := &Usecase{
repository: tt.fields.repository,
}
got, err := uc.Read(tt.args.ctx, tt.args.id)
if (err != nil) != tt.wantErr {
t.Errorf("Usecase.Read() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Usecase.Read() = %v, want %v", got, tt.want)
}
})
}
}
2. テストコードを整形
- テスト並列化のためのおまじないを追加します。
- コマンドラインで
gotests
を使えば並列化のおまじないも自動生成されるはずです。
- コマンドラインで
-
gomock
をテストパターンごとに作成するために依存するrepository
をfunc()
で都度都度設定するように修正します。
usecase_test.go
package usecase_test // <- _testを追加 外部パッケージから呼ばれるぞを意識するため
import (
"context"
"reflect"
"testing"
"github.com/otakakot/playground-gomock/model"
"github.com/otakakot/playground-gomock/repository"
"github.com/otakakot/playground-gomock/usecase"
)
func TestUsecaseCreate(t *testing.T) {
t.Parallel() // <- 追加
type fields struct {
repository func(*testing.T) repository.Repository // <- 修正
}
type args struct {
ctx context.Context
name string
}
tests := []struct {
name string
fields fields
args args
want model.Model
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
tt := tt // <- 追加
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // <- 追加
uc := usecase.New(tt.fields.repository(t)) // <- 追加
got, err := uc.Create(tt.args.ctx, tt.args.name)
if (err != nil) != tt.wantErr {
t.Errorf("Usecase.Create() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Usecase.Create() = %v, want %v", got, tt.want)
}
})
}
}
func TestUsecaseRead(t *testing.T) {
t.Parallel() // <- 追加
type fields struct {
repository func(*testing.T) repository.Repository // <- 修正
}
type args struct {
ctx context.Context
id string
}
tests := []struct {
name string
fields fields
args args
want model.Model
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
tt := tt // <- 追加
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // <- 追加
uc := usecase.New(tt.fields.repository(t)) // <- 修正
got, err := uc.Read(tt.args.ctx, tt.args.id)
if (err != nil) != tt.wantErr {
t.Errorf("Usecase.Read() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Usecase.Read() = %v, want %v", got, tt.want)
}
})
}
}
3. テストケースを追加
- テストケースは日本語で書きます。
- 正常系と異常系、網羅的に呼び出されることを意識します。
- 適切な
error
型が返ってくるかも場合によっては検証します。
usecase_test.go
package usecase_test
import (
"context"
"fmt"
"reflect"
"testing"
"github.com/golang/mock/gomock"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
"github.com/otakakot/sample-go-unit-test-code/errors"
"github.com/otakakot/sample-go-unit-test-code/model"
"github.com/otakakot/sample-go-unit-test-code/repository"
"github.com/otakakot/sample-go-unit-test-code/usecase"
)
func TestUsecaseCreate(t *testing.T) {
t.Parallel()
type fields struct {
repository func(*testing.T) repository.Repository
}
type args struct {
ctx context.Context
name string
}
tests := []struct {
name string
fields fields
args args
want model.Model
wantErr bool
}{
{
name: "作成に成功", // テストケースは日本語で書く
fields: fields{
repository: func(t *testing.T) repository.Repository {
t.Helper() // おまじない
ctrl := gomock.NewController(t) // おまじない
mock := repository.NewMockRepository(ctrl)
mock.EXPECT().Save(
gomock.Any(), // <- contextなので任意の値にしておく
gomock.Any(), // <- model.Model{}はusecase内で生成されるので一旦任意の値にしておく(比較対象の値をごにょごにょしたい)
).Return(
nil, // <- ここでSaveメソッドの返り値を指定する error は発生しないので nil を返す
).Do(func(ctx context.Context, got model.Model) { // <- Do()を用いて Save()に与えられる model.Model{} を検証する
if _, err := uuid.Parse(got.ID); err != nil { // <- IDが uuid で採番されているか検証する
t.Errorf("failed to parse uuid")
}
opts := []cmp.Option{
cmpopts.IgnoreFields(model.Model{}, "ID"), // <- IDを比較対象から除外する
}
want := model.Model{
// IDは比較対象から除外しているので指定しない
Name: "otakakot",
}
if diff := cmp.Diff(got, want, opts...); diff != "" {
t.Errorf("Save() = %v, want %v", got, want)
}
})
// mock.EXPECT().Find()は定義しない = Create() では呼ばれないのだとわかる
return mock
},
},
args: args{
ctx: context.Background(),
name: "otakakot",
},
want: model.Model{
// ID は usecase 内で採番されるのでここでは指定しない
Name: "otakakot",
},
wantErr: false,
},
{
name: "作成に失敗",
fields: fields{
repository: func(t *testing.T) repository.Repository {
t.Helper() // おまじない
ctrl := gomock.NewController(t) // おまじない
mock := repository.NewMockRepository(ctrl)
mock.EXPECT().Save(
gomock.Any(), // <- contextなので任意の値にしておく
gomock.Any(), // <- usecase内で生成されるので一旦任意に値にしておく
).Return(
fmt.Errorf("failed to save"), // <- Save()メソッドでエラーが発生したと仮定する 独自で定義した errors パッケージを利用したいので fmt.Errorf() を使用
).Do(func(ctx context.Context, got model.Model) { // <- error は発生するが Save() メソッドに適切な値が渡っているかは検証する
if _, err := uuid.Parse(got.ID); err != nil {
t.Errorf("failed to parse uuid")
}
opts := []cmp.Option{
cmpopts.IgnoreFields(model.Model{}, "ID"),
}
want := model.Model{
Name: "otakakot",
}
if diff := cmp.Diff(got, want, opts...); diff != "" {
t.Errorf("Save() = %v, want %v", got, want)
}
})
return mock
},
},
args: args{
ctx: context.Background(),
name: "otakakot",
},
want: model.Model{}, // 空structが返ってくる。ここはポインタ返しておくようにしておく方が interface の設計として適切なんじゃないかと最近思っている
wantErr: true,
},
}
for _, tt := range tests {
tt := tt // <- おまじない
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
uc := usecase.New(tt.fields.repository(t))
got, err := uc.Create(tt.args.ctx, tt.args.name)
if (err != nil) != tt.wantErr {
t.Errorf("Usecase.Create() error = %v, wantErr %v", err, tt.wantErr)
return
}
got.ID = "" // <- 比較で落ちないように空文字いれておく
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Usecase.Create() = %v, want %v", got, tt.want)
}
})
}
}
func TestUsecaseRead(t *testing.T) {
t.Parallel()
type fields struct {
repository func(*testing.T) repository.Repository
}
type args struct {
ctx context.Context
id string
}
id := uuid.NewString() // 比較に使いたいので外で宣言しておく
tests := []struct {
name string
fields fields
args args
want model.Model
wantErr bool
checkErr func(t *testing.T, err error) // <- model.NotFoundError を検証したい
}{
{
name: "見つかる",
fields: fields{
repository: func(*testing.T) repository.Repository {
t.Helper()
ctrl := gomock.NewController(t)
mock := repository.NewMockRepository(ctrl)
mock.EXPECT().Find(
gomock.Any(), // <- context
id, // <- 外で定義している id が渡っていくることを想定
).Return(
model.Model{ // <- repository.Find() で返す値の定積
ID: id, // 整合性取るために受け取ったidを指定しておく
Name: "otakakot", // 期待値と同じ値を定義しておく
},
nil,
) // Find()メソッドの引数で比較ができるため .Do() の実装は不要
// mock.EXPECT().Save()は定義しない = Read() では呼ばれないのだとわかる
return mock
},
},
args: args{
ctx: context.Background(),
id: id,
},
want: model.Model{
ID: id,
Name: "otakakot",
},
wantErr: false,
// checkErr: func(t *testing.T, err error) {} <- 呼ばれないので指定しない
},
{
name: "見つからない",
fields: fields{
repository: func(*testing.T) repository.Repository {
t.Helper()
ctrl := gomock.NewController(t)
mock := repository.NewMockRepository(ctrl)
mock.EXPECT().Find(
gomock.Any(), // <- context
id, // メソッドは失敗する想定だが適切なidが渡ってくるかは検証する
).Return(
model.Model{},
errors.NewNotFoundError(fmt.Errorf("not found")),
)
return mock
},
},
args: args{
ctx: context.Background(),
id: id,
},
want: model.Model{},
wantErr: true,
checkErr: func(t *testing.T, err error) {
t.Helper() // <- おまじない
if !errors.AsNotFoundError(err) { // usecase.Read() から想定通りの error 型が返ってくるか検証する
t.Errorf("failed to assert NotFoundError")
}
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
uc := usecase.New(tt.fields.repository(t))
got, err := uc.Read(tt.args.ctx, tt.args.id)
if (err != nil) != tt.wantErr {
t.Errorf("Usecase.Read() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Usecase.Read() = %v, want %v", got, tt.want)
}
if !tt.wantErr { // <- error は発生しない想定なのでここで終了する
return
}
tt.checkErr(t, err)
})
}
}
4. 実行
-
-v
オプションで詳細を出力してます。 -
-count=1
はキャッシュを無効化しています。
❯ go test ./usecase/... -v -count=1
=== RUN TestUsecaseCreate
=== PAUSE TestUsecaseCreate
=== RUN TestUsecaseRead
=== PAUSE TestUsecaseRead
=== CONT TestUsecaseCreate
=== RUN TestUsecaseCreate/作成に成功
=== PAUSE TestUsecaseCreate/作成に成功
=== RUN TestUsecaseCreate/作成に失敗
=== PAUSE TestUsecaseCreate/作成に失敗
=== CONT TestUsecaseCreate/作成に成功
=== CONT TestUsecaseCreate/作成に失敗
=== CONT TestUsecaseRead
=== RUN TestUsecaseRead/見つかる
=== PAUSE TestUsecaseRead/見つかる
=== RUN TestUsecaseRead/見つからない
=== PAUSE TestUsecaseRead/見つからない
=== CONT TestUsecaseRead/見つかる
=== CONT TestUsecaseRead/見つからない
--- PASS: TestUsecaseCreate (0.00s)
--- PASS: TestUsecaseCreate/作成に成功 (0.00s)
--- PASS: TestUsecaseCreate/作成に失敗 (0.00s)
--- PASS: TestUsecaseRead (0.00s)
--- PASS: TestUsecaseRead/見つかる (0.00s)
--- PASS: TestUsecaseRead/見つからない (0.00s)
PASS
ok github.com/otakakot/sample-go-unit-test-code/usecase 0.139s
おわりに
今回使用したコードは以下のリポジトリに置いておきます。
単体テストを実装するときに便利な書き方やライブラリがあればぜひ教えてください!