🎛

gomockのControllerは使いまわすべきなのか?

10 min read

Go言語を使い始めてひたすら、単体テストを書き続ける毎日です。単体テストでは頻繁に、gomockを使用しているのですが、gomockを使う際に引数で渡す*gomock.Controllerがなんなのか?よくわからず使用していました。既存のコードを参考にしながらテストを書き進めていたのですが、人によってgomock.NewController(t)する回数やタイミングが異なるので、何がただしいかわからなかったので少し調べてみました。

gomock.Controllerとは?

まずはgomock本体のgomock.Controllerから。

// A Controller represents the top-level control of a mock ecosystem.
// It defines the scope and lifetime of mock objects, as well as their expectations.
// It is safe to call Controller's methods from multiple goroutines.
type Controller struct {
	mu            sync.Mutex
	t             TestReporter
	expectedCalls *callSet
	finished      bool
}

コントローラは、モックエコシステムのトップレベルのコントロールを表します。
コントローラは、モックオブジェクトのスコープとライフタイム、および期待値を定義します。
コントローラのメソッドは、複数のゴルーチンから呼び出しても安全です。

以下の2点が大事なところだと思います。この2つに注目してしらべていきます。

  • モックオブジェクトのスコープとライフタイム
  • 複数のゴルーチンから呼び出しても安全

実験用Mockの準備

こんな計算機があったとしましょう。CalcのところはMockで処理するので Addを1回よびだして、SubのあたいをReturnしておけばOKです。

type Calculator interface {
	Calc(x int64, y int64) int64
}

type CalculatorImpl struct {
	ArithmeticService Arithmetic
}

func NewCalculator() Calculator {
	return &CalculatorImpl{
		ArithmeticService: NewArithmetic(),
	}
}

func (c *CalculatorImpl) Calc(x int64, y int64) int64 {
	add := c.ArithmeticService.Add(x, y)
	return c.ArithmeticService.Sub(add, 10)
}

モックをつくるためにArithmeticも実装しておきます。モック生成の雛型になってもらうだけなのでAddとSubのロジックはなんでもよいです。

//go:generate mockgen -source=$GOFILE -destination=mock_$GOFILE -package=$GOPACKAGE

type Arithmetic interface {
	Add(x int64, y int64) int64
	Sub(x int64, y int64) int64
}

type ArithmeticImpl struct {
}

func NewArithmetic() Arithmetic {
	return &ArithmeticImpl{}
}

func (a *ArithmeticImpl) Add(x int64, y int64) int64 {
	return x + y
}

func (a *ArithmeticImpl) Sub(x int64, y int64) int64 {
	return x - y
}

1つMock使いまわしと、関数の最大呼び出し回数

一つのMockを複数のテストケース使いまわす場合は注意が必要です。以下のテストケースが2ケースあるテーブルドリブンテストはエラーになります。

func TestCalculatorImpl_Calc_GlobalMock(t *testing.T) {
	ctrl := gomock.NewController(t)
	mock := NewMockArithmetic(ctrl)
	mock.EXPECT().Add(gomock.Any(), gomock.Any()).Return(int64(2))
	mock.EXPECT().Sub(gomock.Any(), gomock.Any()).Return(int64(-8))

	type fields struct {
		ArithmeticSerivce Arithmetic
	}
	type args struct {
		x int64
		y int64
	}
	tests := []struct {
		name   string
		fields fields
		args   args
		want   int64
	}{
		{
			name:   "test case 1",
			fields: fields{ArithmeticSerivce: mock},
			args:   args{x: 1, y: 1},
			want:   -8,
		},
		{
			name:   "test case 2",
			fields: fields{ArithmeticSerivce: mock},
			args:   args{x: 1, y: 1},
			want:   -8,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			a := &CalculatorImpl{ArithmeticService: tt.fields.ArithmeticSerivce}
			if got := a.Calc(tt.args.x, tt.args.y); got != tt.want {
				t.Errorf("CalculatorImpl.Calc() = %v, want %v", got, tt.want)
			}
		})
	}
}
--- FAIL: TestCalculatorImpl_Calc_GlobalMock (0.00s)
    /home/tminamiii/ghq/github.com/tMinamiii/sandbox-go/sgomock/controller.go:150: Unexpected call to *sgomock.MockArithmetic.Add([1 1]) at /home/tminamiii/ghq/github.com/tMinamiii/sandbox-go/sgomock/mock_arithmetic.go:37 because:
        Expected call at /home/tminamiii/ghq/github.com/tMinamiii/sandbox-go/sgomock/calculator_test.go:12 has already been called the max number of times.
    --- FAIL: TestCalculatorImpl_Calc_GlobalMock/test_case_2 (0.00s)
        /home/tminamiii/ghq/github.com/tMinamiii/sandbox-go/sgomock/testing.go:1169: test executed panic(nil) or runtime.Goexit: subtest may have called FailNow on a parent test
FAIL
FAIL	github.com/tMinamiii/sandbox-go/sgomock	0.002s
FAIL

MockのAddやSubを2回以上呼び出したためエラーが発生しました。
Mockの関数には呼び出し回数の制限があり、デフォルトは1回になっています。
Times()を最大呼び出し回数を増やすことができますが、Mockの使用状況が把握しづらくなるため、とくに理由がなければおなじMockを使いまわさないほうがよいかもしれません。

mock.EXPECT().Add(gomock.Any(), gomock.Any()).Return(int64(2))
mock.EXPECT().Sub(gomock.Any(), gomock.Any()).Return(int64(-8))

mock.EXPECT().Add(gomock.Any(), gomock.Any()).Return(int64(2)).Times(2)
mock.EXPECT().Sub(gomock.Any(), gomock.Any()).Return(int64(-8)).Times(2)

1つのControllerの使いまわし

グローバルなMockを使いますときは呼び出し回数に注意が必要なことがわかりました。AnyTimes()で無限回に設定する力技もありますが、できれば呼び出す回数を把握したうえでテストしたいものです。基本的にはMockはテストケースごとに作成するのが良さそうです。

では、こんどはControllerを使いまわしてみます。下記のテストは正常に動作します。Controllerは共通ですが、テストケースごとにMockを作成しているためです。ではControllerを共通化すると内部では何がおきているのでしょう?

func TestCalculatorImpl_Calc_GlobalController(t *testing.T) {
	ctrl := gomock.NewController(t)
	type fields struct {
		ArithmeticSerivce Arithmetic
	}
	type args struct {
		x int64
		y int64
	}
	tests := []struct {
		name   string
		fields fields
		args   args
		want   int64
	}{
		{
			name: "test case 1",
			fields: fields{
				ArithmeticSerivce: func() Arithmetic {
					mock := NewMockArithmetic(ctrl)
					mock.EXPECT().Add(gomock.Any(), gomock.Any()).Return(int64(2))
					mock.EXPECT().Sub(gomock.Any(), gomock.Any()).Return(int64(-8))
					return mock
				}(),
			},
			args: args{x: 1, y: 1},
			want: -8,
		},
		{
			name: "test case 2",
			fields: fields{
				ArithmeticSerivce: func() Arithmetic {
					mock := NewMockArithmetic(ctrl)
					mock.EXPECT().Add(gomock.Any(), gomock.Any()).Return(int64(2))
					mock.EXPECT().Sub(gomock.Any(), gomock.Any()).Return(int64(-8))
					return mock
				}(),
			},
			args: args{x: 1, y: 1},
			want: -8,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			a := &CalculatorImpl{
				ArithmeticService: tt.fields.ArithmeticSerivce,
			}
			if got := a.Calc(tt.args.x, tt.args.y); got != tt.want {
				t.Errorf("CalculatorImpl.Calc() = %v, want %v", got, tt.want)
			}
		})
	}
}

冒頭のコードにのように、Controllerのフィールドにsync.Mutexがあります。go generateで生成されたMockの各関数はController越しに実行(ctrl.Call())されます。そしてctrl.Call()内でControllerのmutexがロックされます。mutexのおかげでスレッドセーフになるため、1つMockを複数のテスト共有してで並行に実行することもできます。ただ、1つMockを複数のテスト共有してで並行に実行 するというのはかなり特殊なテストだととも思います。

func (ctrl *Controller) Call(receiver interface{}, method string, args ...interface{}) []interface{} {
	if h, ok := ctrl.t.(testHelper); ok {
		h.Helper()
	}

	// Nest this code so we can use defer to make sure the lock is released.
	actions := func() []func([]interface{}) []interface{} {
		ctrl.mu.Lock()
		defer ctrl.mu.Unlock()
		...
type Controller struct {
	mu            sync.Mutex
	t             TestReporter
	expectedCalls *callSet
	finished      bool
}

テストケースごとにController生成

https://zenn.dev/articles/5dd419507ed869/edit
この記事にも書いたとおり、t.Run()だけだと逐次実行されます。各MockでControllerが共有されて、mutex.Lock()されても何も影響はありません。「テストケースごとにMockを作成し」かつ 「t.Parallel()を使わない」 のではればControllerを使いまわしは問題はおきないでしょう。
func TestCalculatorImpl_Calc_GlobalController(t *testing.T) {
	type fields struct {
		ArithmeticSerivce Arithmetic
	}
	type args struct {
		x int64
		y int64
	}
	tests := []struct {
		name   string
		fields fields
		args   args
		want   int64
	}{
		{
			name: "test case 1",
			fields: fields{
				ArithmeticSerivce: func() Arithmetic {
					mock := NewMockArithmetic(ctrl)
					mock.EXPECT().Add(gomock.Any(), gomock.Any()).Return(int64(2))
					mock.EXPECT().Sub(gomock.Any(), gomock.Any()).Return(int64(-8))
					return mock
				}(),
			},
			args: args{x: 1, y: 1},
			want: -8,
		},
		{
			name: "test case 2",
			fields: fields{
				ArithmeticSerivce: func() Arithmetic {
					mock := NewMockArithmetic(ctrl)
					mock.EXPECT().Add(gomock.Any(), gomock.Any()).Return(int64(2))
					mock.EXPECT().Sub(gomock.Any(), gomock.Any()).Return(int64(-8))
					return mock
				}(),
			},
			args: args{x: 1, y: 1},
			want: -8,
		},
	}
	ctrl := gomock.NewController(t)
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			a := &CalculatorImpl{
				ArithmeticService: tt.fields.ArithmeticSerivce,
			}
			if got := a.Calc(tt.args.x, tt.args.y); got != tt.want {
				t.Errorf("CalculatorImpl.Calc() = %v, want %v", got, tt.want)
			}
		})
	}
}

ですがt.Parallel()を使ってテスト高速化をしようとした際、Controllerを使いまわすと全Mockでmutexを共有するため、各テストケースでlock&lock解除待ちによる遅延が発生します。とくに理由がなければ1テストケースごとに ctrl := gomock.NewController(t) を実行して新しいコントローラーを作成したほうが高速化できるでしょう。

func TestCalculatorImpl_Calc(t *testing.T) {
	type fields struct {
		ArithmeticSerivce func(*gomock.Controller) Arithmetic
	}
	type args struct {
		x int64
		y int64
	}
	tests := []struct {
		name   string
		fields fields
		args   args
		want   int64
	}{
		{
			name: "test case 1",
			fields: fields{
				ArithmeticSerivce: func(ctrl *gomock.Controller) Arithmetic {
					mock := NewMockArithmetic(ctrl)
					mock.EXPECT().Add(gomock.Any(), gomock.Any()).Return(int64(2))
					mock.EXPECT().Sub(gomock.Any(), gomock.Any()).Return(int64(-8))
					return mock
				},
			},
			args: args{x: 1, y: 1},
			want: -8,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)
			a := &CalculatorImpl{
				ArithmeticService: tt.fields.ArithmeticSerivce(ctrl),
			}
			if got := a.Calc(tt.args.x, tt.args.y); got != tt.want {
				t.Errorf("CalculatorImpl.Calc() = %v, want %v", got, tt.want)
			}
		})
	}
}

結論

  • t.Run()だけなら1つのctrl := gomock.NewController(t)を使いまわしても問題ない
  • t.Parallel()を使用し、かつテストケースごとのMockが完全に独立しているなら、必ずテストケースごとにctrl := gomock.NewController(t)する
  • t.Parallel()を使用し、Mockも共通ならば1つのctrl := gomock.NewController(t)で頑張る

単純なテストで迷ったら、とりあえずテストケースごとにctrl := gomock.NewController(t)しておけばよいかなというのが自分の結論ですが、あまり自信がない。
ctrl := gomock.NewController(t)の使い方が適当でもテストがちゃんと動いている理由はわかった。

Discussion

ログインするとコメントできます