0
Help us understand the problem. What are the problem?

posted at

もうタイムアウトまで待たない!gomockでゴルーチンをまたいでエラーを出す方法

TL; DR

  • gomockのモックをテストと別のゴルーチンで動かすと、モック未定義エラーが起きてもテストが終了しない
  • テストが失敗した際、go test のタイムアウトまでフリーズしてつらみ
  • そこで、 *testing.T をラップした独自のReporterを渡し即時終了するようにする
func TestHoge(t *testing.T) {
	ctrl := gomock.NewController(NewConcurrentTestReporter(t))
	defer ctrl.Finish()
}

type ConcurrentTestReporter struct {
	*testing.T
}

func NewConcurrentTestReporter(t *testing.T) *ConcurrentTestReporter {
	return &ConcurrentTestReporter{t}
}

func (r *ConcurrentTestReporter) Fatalf(format string, args ...interface{}) {
	// os.Exit(1) で全ゴルーチンを殺す
	log.Fatalf(format, args...)
}

はじめに

gomockはインターフェースからモックを手軽に生成できるライブラリです。依存オブジェクトのモックを作って、単体テストを手軽に行うことができます。

単にダミー実装を提供するだけでなく、メソッド呼び出しが想定したものと異なる場合エラーを返してくれます。振る舞いのテストもできて便利ですね。

モックのメソッドが呼び出されないと...
func TestFailed(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mockSrv := proto.NewMockPersonServer(ctrl)
	mockSrv.EXPECT().
		Get(gomock.Any(), gomock.Any()).
		Return(&proto.GetResponse{
			Id:   1234,
			Name: "Taro",
		}, nil)
}
エラーを返してくれる!
=== RUN   TestFailed
    /home/syuparn/work/gomock-goroutine-sample/controller.go:269: missing call(s) to *proto.MockPersonServer.Get(is anything, is anything) /home/syuparn/work/gomock-goroutine-sample/client_test.go:82
    /home/syuparn/work/gomock-goroutine-sample/controller.go:269: aborting test due to missing call(s)
--- FAIL: TestFailed (0.00s)
FAIL
FAIL	github.com/syuparn/gomock-goroutine-sample	0.002s

ただし、モックを テストと別のゴルーチンで動かした際には、テストが上手く止まってくれません
gRPC/HTTP呼び出しのテストで、モックサーバーを別ゴルーチンで動かした際にハマってしまいました。

const bufSize = 1024 * 1024

// モックgRPCサーバーとそのクライアントを作成
func testClient(
	ctx context.Context,
	mockSrv *proto.MockPersonServer,
) (proto.PersonClient, error) {
	lis := bufconn.Listen(bufSize)

	dialer := func(context.Context, string) (net.Conn, error) {
		return lis.Dial()
	}
	conn, err := grpc.DialContext(ctx, "", grpc.WithInsecure(), grpc.WithContextDialer(dialer))
	if err != nil {
		return nil, fmt.Errorf("%+v", err)
	}

	s := grpc.NewServer()
	proto.RegisterPersonServer(s, mockSrv)

	// モックサーバー起動
	go func() {
		if err := s.Serve(lis); err != nil {
			log.Fatal(err)
		}
	}()

	return proto.NewPersonClient(conn), nil
}

// gRPCクライアントのテスト
// バリデーションエラーの場合はリクエスト前にエラーが起きる想定(だけど未実装)
func TestGetNameValidationErr(t *testing.T) {
	ctx := context.TODO()

	// モックサーバー作成
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mockSrv := proto.NewMockPersonServer(ctrl) // リクエスト前にバリデーションエラーが起きるので何も呼ばれない想定

	// クライアント生成
	client, err := testClient(ctx, mockSrv)

	// test
	h := NewPersonHandler(client)
	_, err = h.GetName(ctx, PersonID(-1))
	if err == nil {
		t.Error("validation error must be occurred")
	}
}
失敗したことは分かってるのに延々と待たされる...
Running tool: /usr/bin/go test -run ^TestGetNameValidationErr$ github.com/syuparn/gomock-goroutine-sample -v -tags=unit -timeout 120s
=== RUN   TestGetNameValidationErr
    /home/syuparn/work/gomock-goroutine-sample/service_grpc.pb.go:79: Unexpected call to *proto.MockPersonServer.Get([context.Background.WithValue(type transport.connectionKey, val <not Stringer>).WithCancel.WithValue(type peer.peerKey, val <not Stringer>).WithValue(type metadata.mdIncomingKey, val <not Stringer>).WithValue(type grpc.streamKey, val <not Stringer>) id:-1]) at /home/syuparn/work/gomock-goroutine-sample/proto/service_grpc.pb.go:79 because: there are no expected calls of the method "Get" for that receiver
panic: test timed out after 2m0s
(... 以下ゴルーチンのスタックトレース。長い ...)
FAIL	github.com/syuparn/gomock-goroutine-sample	120.007s

テストは途中でフリーズし go test のタイムアウトまで待たされてしまいます。TDDの「Red」のタイミングで出鼻をくじかれ :angry: となっていました

バージョン

  • gomock v1.6.0 (記事投稿時点の最新版)

なぜ動かない?

解決策も含めて、以下のissueを参考にしました。もうこの記事の役目無し

テストがフリーズする理由をざっくり言うと、「gomock でモック呼び出しエラーが起きても、自分が動いているゴルーチンしか終了しない」からです。
コードを順を追って見ていきましょう。

モックの内部で gomock.Controller#Call が呼ばれる

モックのメソッドが呼ばれると、自身が持っているcontrollerの Call を呼び出します。この際、メソッド名を渡すことで何のメソッドが呼ばれたか判断しています。

// MockPersonServer is a mock of PersonServer interface.
type MockPersonServer struct {
	ctrl     *gomock.Controller
	recorder *MockPersonServerMockRecorder
}

// Get mocks base method.
func (m *MockPersonServer) Get(arg0 context.Context, arg1 *GetRequest) (*GetResponse, error) {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "Get", arg0, arg1)
	ret0, _ := ret[0].(*GetResponse)
	ret1, _ := ret[1].(error)
	return ret0, ret1
}

gomock.Controller#Call は想定しないメソッド呼び出しがあると gomock.TestReporter#Fatalf を呼び出す

Call では、メソッド名からマッチするモック実装を探し出し実行します。

gomock/controller.go(関連部分だけ抜粋)
func (ctrl *Controller) Call(receiver interface{}, method string, args ...interface{}) []interface{} {
	actions := func() []func([]interface{}) []interface{} {
		expected, err := ctrl.expectedCalls.FindMatch(receiver, method, args)
		if err != nil {
			ctrl.T.Fatalf("Unexpected call to %T.%v(%v) at %s because: %s", receiver, method, args, origin, err)
		}
		// ...
	}()
	// ...
}

対応するモック実装が見つからない場合は、想定外の呼び出しが発生しているのでエラーを返します。このエラーは CallFatalf で表示されます。

gomock/callset.go(関連部分だけ抜粋)
func (cs callSet) FindMatch(receiver interface{}, method string, args []interface{}) (*Call, error) {

	// cs.expected は、これから呼び出されることが期待されるメソッド一覧
	expected := cs.expected[key]

	// cs.exhausted は、すでに呼び出されたメソッド一覧
	exhausted := cs.exhausted[key]

	// 呼び出される予定もなく、過去に呼び出されたものでもなければ想定外なのでエラー
	if len(expected)+len(exhausted) == 0 {
		_, _ = fmt.Fprintf(&callsErrors, "there are no expected calls of the method %q for that receiver", method)
	}

	return nil, errors.New(callsErrors.String())
}

ctrl.T は、controllerのコンストラクタ gomock.NewController に渡した引数です。gomock.TestReporter を実装していればなんでもよいのですが、たいていは *testing.T を渡していると思います。

testing.T#Fatalfruntime.GoExit を呼び出す

テストで使ったことのある方も多いと思いますが、 t.Fatalf はエラーメッセージを表示しすぐにテストを終了(Fail)させます(内部で testing.T#FailNow 利用)。結果、 モックで想定外なメソッド呼び出しが発生すると即座にテストが終了する という仕掛けです。

しかしこの T#Fatalf 、説明にも書かれている通り テストが動くゴルーチンと別のゴルーチンで動かすとテスト終了ができません

FailNow marks the function as having failed and stops its execution by calling runtime.Goexit (which then runs all deferred calls in the current goroutine). Execution will continue at the next test or benchmark. FailNow must be called from the goroutine running the test or benchmark function, not from other goroutines created during the test. Calling FailNow does not stop those other goroutines.

FailNow は runtime.Goexit を呼び出すことで関数が失敗したことを示し、その関数の実行を停止させます (その後現在のゴルーチン上のdeferされた呼び出しをすべて実行します)。次のテストまたはベンチマークで実行を継続します。FailNow は、テスト中に作られた別のゴルーチンではなく、テストまたはベンチマークが実行されているゴルーチンで呼び出されなければなりません。FailNow を呼び出しても、これらの別ゴルーチンは停止しません。

runtime.Goexit は自分が動いているゴルーチンを終了させる

上記の説明に出てきた runtime.Goexit は、この関数を呼び出したゴルーチンだけを終了させます。

実装を読んでみたのですが、複雑で仕組みを追いきれませんでした...いつかGoの処理系に詳しくなったら再チャレンジしたい

解決策

上記のように、 t.Fatalf はテストのゴルーチン以外で動作しないためテストが終了できませんでした。なので、gomock.NewController に、Fatalf でテストのゴルーチンが落ちるようなTestReporterを渡します。

log.Fatal は内部で os.Exit(1) を呼び出し 即座にプログラム全体を終了させる ので、今回の用途にぴったりです。

type ConcurrentTestReporter struct {
	// *testing.Tをラップしているので、Fatalf以外はTのメソッドをそのまま利用
	*testing.T
}

func NewConcurrentTestReporter(t *testing.T) *ConcurrentTestReporter {
	return &ConcurrentTestReporter{t}
}

func (r *ConcurrentTestReporter) Fatalf(format string, args ...interface{}) {
	// os.Exit(1) 全ゴルーチン(当然テストのゴルーチンも)を殺す
	// もちろんエラーログも出力される
	log.Fatalf(format, args...)
}

これで、失敗するテストに待たされることが無くなりました

func TestGetNameValidationErrRevised(t *testing.T) {
	ctx := context.TODO()

	// 自作したTestReporterを渡す
	ctrl := gomock.NewController(NewConcurrentTestReporter(t))
	defer ctrl.Finish()

	mockSrv := proto.NewMockPersonServer(ctrl) // no methods are expected to be called

	client, err := testClient(ctx, mockSrv)

	// test
	h := NewPersonHandler(client)
	_, err = h.GetName(ctx, PersonID(-1))
	if err == nil {
		t.Error("validation error must be occurred")
	}
}
=== RUN   TestGetNameValidationErrRevised
2022/06/17 09:43:33 Unexpected call to *proto.MockPersonServer.Get([context.Background.WithValue(type transport.connectionKey, val <not Stringer>).WithCancel.WithValue(type peer.peerKey, val <not Stringer>).WithValue(type metadata.mdIncomingKey, val <not Stringer>).WithValue(type grpc.streamKey, val <not Stringer>) id:-1]) at /home/syuparn/work/gomock-goroutine-sample/proto/service_grpc.pb.go:79 because: there are no expected calls of the method "Get" for that receiver
FAIL	github.com/syuparn/gomock-goroutine-sample	0.004s

コード全文は以下のリポジトリにあります。

おわりに

以上、gomockで並行処理テストのエラーを拾う方法でした。今回紹介した実装はかなり粗削りなので、後始末や排他制御等を考えるならもっと作りこむ必要がありそうです。

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
0
Help us understand the problem. What are the problem?