Goの並列テストでよくあるバグ（tt := tt忘れ）に対する対策

どうも[ナレッジワーク](https://tenn.in/kw)の[tenntenn](https://tenn.in)です。

本記事は、[Gopher塾](https://gopher.school)で扱ったテストの話で、参加者の方から質問が出た並列テストにおけるよくあるバグについての解説とGo 1.20以降で入る対策について書きます。

## 並列テストとサブテスト

Goでは、テスト関数内で`(*testing.T).Parallel`メソッドを呼び出すとテストを並列に実行できます。テストを並列に実行することでテストを効率よく行い、実行時間の削減が見込めます。

また、`*testing.T`型には、サブテスト（子テスト）を実行するための`Run`メソッドがあり、サブテストを並列に実行できます。Goではテーブル駆動テストがよく用いられため、各テストケースがサブテストとして実行されます。テストの効率化を考えて、各テストケースを`Parallel`メソッドを用いて並列に実行することが多いです。

## 並列テストでよくあるバグ

サブテストを並列に実行すると、テストがうまく実行されないというバグに出会うことが多いです。たとえば、次のテストは、うまく実行されるでしょうか？

```go
func IsOdd(x int) bool {
	return x%2 == 1
}

func TestIsOdd(t *testing.T) {
	t.Parallel()
	cases := []struct {
		name string
		in   int
		want bool
	}{
		{"+odd", 5, true},
		{"+even", 6, false},
		{"-odd", -5, true},
		{"-even", -6, false},
		{"zero", 0, false},
	}
	for _, tt := range cases {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			if got := IsOdd(tt.in); tt.want != got {
				t.Errorf("want IsOdd(%d) = %v, got %v", tt.in, tt.want, got)
			}
		})
	}
}
```
[The Go Playgroundで動かす](https://go.dev/play/p/fJClI4TF1uA)

The Go Playgroundで動かしてみると分かりますが、ぱっと見はすべてのテストケースが実行され、そして成功しているように見えます。しかし、このテストはうまく実行されていません。

## クロージャの宣言と実行のタイミング

クロージャを使用する場合、宣言と実行のタイミングが同じではないことに気をつけなければなりません。たとえば、次のコードは、0から2までの値が順に表示されそうですが、そうなりません。

```go
var fs []func()
for i := 0; i < 3; i++ {
	fs = append(fs, func() {
		// 変数iはすべて同じ変数
		fmt.Printf("value: %d, pointer: %p\n", i, &i)
	})
}

for _, f := range fs {
	f() // 実行するタイミングと関数を定義したタイミングが違う
}
```
[The Go Playgroundで動かす](https://go.dev/play/p/yMycYDqPy0R)

実行すると次のように表示されます。

```
value: 3, pointer: 0xc00001c030
value: 3, pointer: 0xc00001c030
value: 3, pointer: 0xc00001c030
```

すべて、値が3になります。
変数`i`はクロージャ内で宣言された変数ではなく、自由変数であり、`for range`文で宣言された変数は繰り返されている間、ずっと同じ変数になります。
そのため、クロージャを呼び出すタイミングが`for`文の実行の後になってしまうと、変数`i`の最後の値である`3`になってしまいます。

これを避けるには、`for`文内で`i := i`のように変数`i`を再定義し、スコープを1回の繰り返し内にしてしまえば、他の回の繰り返しで値が上書きされることはありません。


```go
var fs []func()
for i := 0; i < 3; i++ {
	// 変数iを再定義する
    i := i
	fs = append(fs, func() {
		fmt.Printf("value: %d, pointer: %p\n", i, &i)
	})
}

for _, f := range fs {
	f()
}
```
[The Go Playgroundで動かす](https://go.dev/play/p/zeufwipx8O8)

実行すると値が毎回変わっているので意図通りといえるでしょう。

```
value: 0, pointer: 0xc0000b2000
value: 1, pointer: 0xc0000b2008
value: 2, pointer: 0xc0000b2010
```

## 1つのテストケースしか実行されない

サブテストを並列に実行した場合、テストケースを処理している`for`文でも同様のことが発生します。テストケースは`Run`メソッドの第2引数で渡されるクロージャで実行されます。クロージャ内で参照されるテストデータ（変数`tt`）は、ループ変数かつ自由変数となっており、`for`文全体で同じ変数となります。

そのため、次のように`tt := tt`のように記述していないと、最後のテストケースしか実行されません。残念なことに、`Run`メソッドの第1引数に指定されるテスト名は、クロージャの実行のタイミングとは別で評価されるため、あたかもすべてのテストケースが実行されたように表示されます。

```go
func IsOdd(x int) bool {
	return x%2 == 1
}

func TestIsOdd(t *testing.T) {
	t.Parallel()
	cases := []struct {
		name string
		in   int
		want bool
	}{
		{"+odd", 5, true},
		{"+even", 6, false},
		{"-odd", -5, true},
		{"-even", -6, false},
		{"zero", 0, false},
	}
	for _, tt := range cases {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			if got := IsOdd(tt.in); tt.want != got {
				t.Errorf("want IsOdd(%d) = %v, got %v", tt.in, tt.want, got)
			}
		})
	}
}
```
[The Go Playgroundで動かす](https://go.dev/play/p/XGn5Lmht40v)

実行すると`tt.in`が`-5`のときにテストが失敗します。`tt := tt`を忘れても、テストがうまくいってるように感じられますが、実際はバグを見つけれておらず、大半のテストが実行されていません。テーブルをスライスではなく、マップにすることで、実行順が若干ランダムになり、気付ける余地は増えますが、それでもなかなか難しいでしょう。

## ループ変数のスコープ

この問題は昔からよく言われており、筆者もプロダクトコードのテストで出会ったと報告を受けたことがあります。今年（2022年）になって、[Discussion#56010](https://go.dev/issue/56010)でGoチームの著名なエンジニアであるRuss Cox氏によって議論が提起されました。

Russ Cox氏が調査したところ、実際にいくつかのモジュールで同様のバグが見つかったそうです。このディスカッションでは、`for range`文のループ変数のスコープを`for`文ごとからイテレーション（繰り返し）ごとに変更してはどうかという案を議論しています。

プロポーザルになっている訳ではないため、今後のリリースで取り込まれるかは分かりませんが、筆者個人としてはぜひ取り込んでほしいです。後方互換性についても考慮しないといけないため、今後の議論に注目していきたいです。

## go vetによる検出

[Discussion#56010](https://go.dev/issue/56010)では、Go 1.20で標準のgo vetにここで取り上げたバグを発見する機能（[Issue#55972](https://go.dev/issue/55972)）が追加されると予告されています。実際に、[CL#452155](https://go.dev/cl/452155)で実装が入っています。

すでにツールチェインのmasterブランチにもマージされているため、次のように`gotip`で試すことができます。

```
$ go install golang.org/dl/gotip@master
$ gotip download
$ gotip vet sample_test.go
# command-line-arguments
sample_test.go:13:8: loop variable test captured by func literal
```

予定どおりGo 1.20でリリースされれば、go vetコマンドで試すことができます。go testを実行してもgo vetは実行されますが、デフォルトでは実行されるAnalyzerが限られています（[参考](https://cs.opensource.google/go/go/+/master:src/cmd/go/internal/test/test.go;l=629)）。次のように`-vet loopclosure`をつけると実行されます。

```
$ gotip test -vet loopclosure
# sample
./sample_test.go:13:8: loop variable test captured by func literal
FAIL	sample [build failed]
```

CIなどで`go vet`を実行しておくのが無難でしょう。並列テストのバグによって隠蔽されるバグに深刻なものが含まれる場合もあり、気づくことが難しいため、手元のソースコードに対して`gotip vet`を実行してみるのも良いでしょう。何事もなければ、安心してGo 1.20のリリースを待てます。