4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

最近ハマっているGo言語で単体テストを書くまでを整理してみた

Posted at

はじめに

単体テストを書くときにパターン化されて作るようになってきたのでここらでちょいと言語化してみようと思い記事にしてみました。(ほぼ自分用のメモです。)

ライブラリ

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をテストパターンごとに作成するために依存するrepositoryfunc()で都度都度設定するように修正します。
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

おわりに

今回使用したコードは以下のリポジトリに置いておきます。

単体テストを実装するときに便利な書き方やライブラリがあればぜひ教えてください!

4
1
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
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?