概要
[Gopher by Renée French, and tenntenn](https://github.com/tenntenn/gopher-stickers/)Go言語では、テストケースの入力値と期待値を分かりやすくする方法として、Table Driven Test(テーブル駆動テスト1)が推奨されています。
また、testing
パッケージに用意されているt.parallel()メソッドを使用して、各テストを並行実行2させるといった手法も、テスト時間を短縮するためによく用いられています3。
Table Driven Testと*testing.T
のt.parallel()
を組み合わせる方法は、結構あるあるかと思うのですが、テストを書く際に気を付けていないと、一部のテストケースしか評価されないという問題が発生してしまいます。
本記事は、上述した問題について
-
どういった場合に発生するのか
-
どうすれば防げるか
-
なぜ発生するのか
を調べ、まとめた内容になります。
環境
どういった場合に発生するのか
簡単な関数のテストを考えます。言語の名前が入ることを期待するstring
型を引数にとり、Gopher(Go開発者)かどうかを判定する関数です。
func IsGopher(language string) bool {
return language == "GoLang"
}
Table Driven Testにのっとって、この関数のテストを書きます。
ここで、今回、テストケースの中には一つだけ失敗するパターンを仕込んであります。そのため、このテストは失敗します。
{
// 失敗することを期待
name: "Rustaceanの場合、trueを期待する",
args: args{
language: "Rust",
},
want: true,
},
テストコード全体
func TestIsGopher(t *testing.T) {
t.Parallel()
type args struct {
language string
}
tests := []struct {
name string
args args
want bool
}{
{
name: "Gopherの場合、trueを期待する",
args: args{
language: "GoLang",
},
want: true,
},
{
// 失敗する
name: "Rustaceanの場合、trueを期待する",
args: args{
language: "Rust",
},
want: true,
},
{
name: "PHPerの場合、falseを期待する",
args: args{
language: "PHP",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// サブテスト関数を並行実行させる
t.Parallel()
if got := IsGopher(tt.args.language); got != tt.want {
t.Errorf("IsGopher() = %v, want %v", got, tt.want)
}
})
}
}
このテストコードでは、以下のように、t.Run()
によるサブテスト関数でt.Parallel()
メソッドを呼びだし、サブテスト関数を並行に実行させるようにしています。
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// サブテスト関数を並行実行させる
t.Parallel()
さて、テストを走らせます。一つだけ、Rust
という文字列を渡してIsGopher
関数がtrue
を返すことを期待するテストケースが存在するため、テストは落ちるはずです。
しかし、実行すると、、
$ go test -v
--- PASS: TestIsGopher (0.00s)
--- PASS: TestIsGopher/Gopherの場合、trueを期待する (0.00s)
--- PASS: TestIsGopher/PHPerの場合、falseを期待する (0.00s)
--- PASS: TestIsGopher/Rustaceanの場合、trueを期待する (0.00s)
PASS
ok
テストが通ってしまいました。
なぜ発生するのか
Table Driven Testでは、テストケースの構造体のスライスに対してfor
ループを回し、各サブテスト関数を順次実行していきます。しかし、今回の例では、t.Parallel()
を使用して、テストを並行に実行させるようにしています。
そのような場合、以下の挙動になります。
-
Go言語では、
for
ステートメントはレキシカルブロックを作る。ループ内の関数値は同じ変数(ループ変数)を「補足」しており、特定の瞬間の値ではなくアドレスを共有している。 -
t.Parallel()
呼び出しによって、並行実行されるまでサブテスト関数の実行は遅延される。 -
遅延したサブテスト関数が全て実行されるとき、テストケースの構造体変数
tt
は、for
ループの最後における値を保持している。 -
そのため、並行実行される全てのサブテスト関数値は
"PHPerの場合、falseを期待する"
になり、失敗するテストケースがあるのにもかかわらず、テストをパスしてしまう。
上記について、書籍「Go言語による並行処理」4では、クロージャとゴルーチンとの観点からより詳細に説明されています。適宜ご参照ください。
どうすれば防げるか
for
ループの内側で、tt
をコピーした変数を宣言することで防ぐことができます。
ループの内側でtt
を宣言し、外側のtt
で初期化することで、ループごとの値を保持させるようにします。
[補足]外側の
tt
はループ変数、forブロック内で宣言したtt
はローカル変数です。スコープが異なるため、全く別物になります。サブテスト関数の引数には、ループ変数の値をコピーしたローカル変数を渡すようにします。
このとき、コピー先の変数名はコピー元と全く同じ名前を命名することが慣習となっています5。
for _, tt := range tests {
+ tt := tt
t.Run(tt.name, func(t *testing.T) {
// サブテスト関数を並行実行させる
t.Parallel()
テスト実行
$ go test -v
--- FAIL: TestIsGopher (0.00s)
--- PASS: TestIsGopher/Gopherの場合、trueを期待する (0.00s)
--- FAIL: TestIsGopher/Rustaceanの場合、trueを期待する (0.00s)
--- PASS: TestIsGopher/PHPerの場合、falseを期待する (0.00s)
FAIL
exit status 1
正しく動作させることができました。
まとめ
Table Driven Testで正しくサブテストを並行実行させるためには、"遅延実行によって、forループ内のクロージャは同じ変数を評価する"というGo言語の特性を理解しておく必要があります。
まとめると以下の通りです。
-
Table Driven Testにおけるサブテスト実行時に**
t.Parallel()
を呼び出すだけだと、ループ最後のテストケース構造体しかテストできない**。 -
ループ内でループ変数を再宣言することにより、遅延したサブテスト関数の関数値が正しくテストケースを実行できるようになる。
追記
本記事を執筆中、他の方が同じ問題についての解決記事をご執筆されていることを知りました。直接的な問題の解決法についてはこちらの記事にお譲りし、本記事ではGoのループ変数に対するスコープ規則に絡めた、別アプローチでのアウトプット内容にしました。
間違い等ありましたら、コメント・編集リクエストでご指摘いただければ幸いです!
参考情報
-
この記事における「並行」: Concurrent(並行)複数の動作が、論理的に、順不同もしくは同時に起こりうること(参考:https://qiita.com/yoshinori_hisakawa/items/486752636cf66225483a#%E4%B8%A6%E5%88%97%E5%87%A6%E7%90%86%E3%81%A8%E4%B8%A6%E8%A1%8C%E5%87%A6%E7%90%86%E3%81%AE%E9%81%95%E3%81%84%E3%81%A8%E3%81%AF) ↩
-
*testing.T
のt.parallel
メソッドの詳しい内容については、他の方が簡潔に、かつ非常に分かりやすくまとめてくださっています。Go言語でのテストの並列化 〜t.Parallel()メソッドを理解する〜 ↩ -
Katherine Cox-Buday 著(山口能迪訳)(2018)『Go言語による並行処理』オライリージャパン, p42。 ↩
-
Alan A. A. Donovan and Brian W. Kernighan(柴田芳樹訳)(2016)『プログラミング言語Go』丸善出版,160-161。 ↩