本記事は、Gopher塾で扱ったテストの話で、参加者の方から質問が出た並列テストにおけるよくあるバグについての解説とGo 1.20以降で入る対策について書きます。
並列テストとサブテスト
Goでは、テスト関数内で(*testing.T).Parallel
メソッドを呼び出すとテストを並列に実行できます。テストを並列に実行することでテストを効率よく行い、実行時間の削減が見込めます。
また、*testing.T
型には、サブテスト(子テスト)を実行するためのRun
メソッドがあり、サブテストを並列に実行できます。Goではテーブル駆動テストがよく用いられため、各テストケースがサブテストとして実行されます。テストの効率化を考えて、各テストケースをParallel
メソッドを用いて並列に実行することが多いです。
並列テストでよくあるバグ
サブテストを並列に実行すると、テストがうまく実行されないというバグに出会うことが多いです。たとえば、次のテストは、うまく実行されるでしょうか?
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で動かしてみると分かりますが、ぱっと見はすべてのテストケースが実行され、そして成功しているように見えます。しかし、このテストはうまく実行されていません。
クロージャの宣言と実行のタイミング
クロージャを使用する場合、宣言と実行のタイミングが同じではないことに気をつけなければなりません。たとえば、次のコードは、0から2までの値が順に表示されそうですが、そうなりません。
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() // 実行するタイミングと関数を定義したタイミングが違う
}
実行すると次のように表示されます。
value: 3, pointer: 0xc00001c030
value: 3, pointer: 0xc00001c030
value: 3, pointer: 0xc00001c030
すべて、値が3になります。
変数i
はクロージャ内で宣言された変数ではなく、自由変数であり、for range
文で宣言された変数は繰り返されている間、ずっと同じ変数になります。
そのため、クロージャを呼び出すタイミングがfor
文の実行の後になってしまうと、変数i
の最後の値である3
になってしまいます。
これを避けるには、for
文内でi := i
のように変数i
を再定義し、スコープを1回の繰り返し内にしてしまえば、他の回の繰り返しで値が上書きされることはありません。
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()
}
実行すると値が毎回変わっているので意図通りといえるでしょう。
value: 0, pointer: 0xc0000b2000
value: 1, pointer: 0xc0000b2008
value: 2, pointer: 0xc0000b2010
1つのテストケースしか実行されない
サブテストを並列に実行した場合、テストケースを処理しているfor
文でも同様のことが発生します。テストケースはRun
メソッドの第2引数で渡されるクロージャで実行されます。クロージャ内で参照されるテストデータ(変数tt
)は、ループ変数かつ自由変数となっており、for
文全体で同じ変数となります。
そのため、次のようにtt := tt
のように記述していないと、最後のテストケースしか実行されません。残念なことに、Run
メソッドの第1引数に指定されるテスト名は、クロージャの実行のタイミングとは別で評価されるため、あたかもすべてのテストケースが実行されたように表示されます。
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)
}
})
}
}
実行するとtt.in
が-5
のときにテストが失敗します。tt := tt
を忘れても、テストがうまくいってるように感じられますが、実際はバグを見つけれておらず、大半のテストが実行されていません。テーブルをスライスではなく、マップにすることで、実行順が若干ランダムになり、気付ける余地は増えますが、それでもなかなか難しいでしょう。
ループ変数のスコープ
この問題は昔からよく言われており、筆者もプロダクトコードのテストで出会ったと報告を受けたことがあります。今年(2022年)になって、Discussion#56010でGoチームの著名なエンジニアであるRuss Cox氏によって議論が提起されました。
Russ Cox氏が調査したところ、実際にいくつかのモジュールで同様のバグが見つかったそうです。このディスカッションでは、for range
文のループ変数のスコープをfor
文ごとからイテレーション(繰り返し)ごとに変更してはどうかという案を議論しています。
プロポーザルになっている訳ではないため、今後のリリースで取り込まれるかは分かりませんが、筆者個人としてはぜひ取り込んでほしいです。後方互換性についても考慮しないといけないため、今後の議論に注目していきたいです。
go vetによる検出
Discussion#56010では、Go 1.20で標準のgo vetにここで取り上げたバグを発見する機能(Issue#55972)が追加されると予告されています。実際に、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が限られています(参考)。次のように-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のリリースを待てます。