👻

Goのテーブル駆動テストを並行実行する場合にケースを正しく渡す

2022/11/30に公開

はじめに

Goにはgoroutineにより並行処理を簡便に記述できるという特徴があります(簡便に正しく動かせるとは限らない)。これはテストの実装においても同様で t.Parallel() を加えることで並行テストが実行できます。

しかしテーブル駆動のサブテストでこれを使う場合に陥りがち且つ気付きにくい罠が潜んでいるので注意が必要です。

前提

以下の関数をテストするものとします。
実装を見てもらえば分かる通り、引数と同じ文字列が返却されることが期待結果となります。

func Echo(s string) string {
	return s
}

ケースが正しく渡せない実装

以下のサブテストの実装にはバグがあり、expectにセットする値のミスにより失敗となるべきテストケース(case1)が失敗せずテストがパスしてしまいます。

環境によっては正しく実行される可能性もありますが、それは偶々の結果であり実装が正しいわけではありません。何度か実行すると結果が変わることもあるでしょう。

func TestEcho(t *testing.T) {
	t.Parallel()

	testCases := map[string]struct {
		in     string
		expect string
	}{
		"case1": {
			in:     "aaa",
			expect: "AAA",
		},
		"case2": {
			in:     "bbb",
			expect: "bbb",
		},
	}

	for tn, tc := range testCases {
		t.Run(tn, func(t *testing.T) {
			t.Parallel()

			actual := Echo(tc.in)
			if actual != tc.expect {
				t.Errorf("mismatch: %s != %s", actual, tc.expect)
			}
		})
	}
}
(略)
--- PASS: TestEcho (0.00s)
    --- PASS: TestEcho/case1 (0.00s)
    --- PASS: TestEcho/case2 (0.00s)
PASS

原因

テストケースを表現する tc はforループ全体で共有される変数であり t.Parallel() が付いた t.Run() でgoroutineに値を渡したつもりでもtcの中身が実際に参照されるのはループが回りきってからとなる可能性が高いためで、恐らく大概の環境ではcase2が2回実行される結果となるからです。

つまりgoroutineを初めて使う人が大概ハマる以下のミスと同様の事象となります。

func f() {
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Println(i)
		}()
	}
	wg.Wait()
}
$ go run main.go
10
10
10
10
10
10
10
10
10
10

こういう実装をすると近頃はLinterが警告してくれますがテストコードでは警告されず、テストがパスするような結果となる場合は中々気が付きにくいので注意が必要です。

対処法

コードの修正

対処法も同様でgoroutine起動前に変数のコピーを取ってクロージャー内部に渡すのが最も一般的でしょう(time.Sleep()等でスイッチさせることも可能ではありますがエレガントには程遠い)。

ちなみにテストケース名である tnt.Run() に渡した時点で引数として値がコピーされるため特に考慮はしなくて大丈夫です。

	for tn, tc := range testCases {
+		tc := tc
		t.Run(tn, func(t *testing.T) {
			t.Parallel()

			actual := Echo(tc.in)
			if actual != tc.expect {
				t.Errorf("mismatch: %s != %s", actual, tc.expect)
			}
		})
	}
}
(略)
=== CONT  TestEcho/case1
    xxx/main_test.go:33: mismatch: aaa != AAA
=== CONT  TestEcho/case2
--- FAIL: TestEcho (0.00s)
    --- FAIL: TestEcho/case1 (0.00s)
    --- PASS: TestEcho/case2 (0.00s)
FAIL

予防

コードレビューだけでは限界があるのでやはりツールをCIに組み込むのがよいでしょう。

https://github.com/kunwardeep/paralleltest

$ go install github.com/kunwardeep/paralleltest@latest
$ 
$ 
$ paralleltest ./...
~/t-parallel/main_test.go:24:2: Range statement for test TestEcho does not reinitialise the variable tc

余談

上記対処法のキモは変数の値をコピーすることにあるので下記のようにfor rangeで定義されるtcのメモリアドレスを参照するようなコードだと解決しません。

	for tn, tc := range testCases {
+		tc := &tc
		t.Run(tn, func(t *testing.T) {

余談の余談ですがこれを検出するツールもあります。

https://github.com/kyoh86/looppointer

$ go install github.com/kyoh86/looppointer/cmd/looppointer@latest
$ looppointer ./...
~/t-parallel/main_test.go:25:10: taking a pointer for the loop variable tc

Discussion