52
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

tenntennAdvent Calendar 2022

Day 1

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

Last updated at Posted at 2022-11-30

どうもナレッジワークtenntennです。

本記事は、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で動かす

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() // 実行するタイミングと関数を定義したタイミングが違う
}

The Go Playgroundで動かす

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

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()
}

The Go Playgroundで動かす

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

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)
			}
		})
	}
}

The Go Playgroundで動かす

実行すると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のリリースを待てます。

52
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
52
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?