そのテーブル駆動テスト、並列に実行できますか?
はじめに
記事を書き終える直前に気がついたのですが、先人がすでに同様の趣旨の記事を書かれていました。そちらの記事を読んでいただけば私の記事は読まなくてOKです。とほほ...。
二番煎じでもあなたの煎じた茶が飲みたい、という方は最後までお読みください。
問題
ここから本題です。例えばio.Writerの末尾に文字列Helloを書き込むAppendHello関数を実装したとします。
package example
import (
"fmt"
"io"
)
func AppendHello(dst io.Writer) error {
n, err := io.WriteString(dst, `Hello`)
if err != nil {
return err
}
if n != 5 {
return fmt.Errorf("unexpected written bytes count: %d", n)
}
return nil
}
(直列にテストを実行)成功
テーブル駆動テストを実装します。
package example
import (
"bytes"
"testing"
)
func TestAppendHello(t *testing.T) {
tests := []struct {
name string
arg *bytes.Buffer
want string
}{
{
name: "one",
arg: bytes.NewBufferString("one"),
want: "oneHello",
},
{
name: "two",
arg: bytes.NewBufferString("two"),
want: "twoHello",
},
{
name: "three",
arg: bytes.NewBufferString("three"),
want: "threeHello",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := AppendHello(tt.arg); err != nil {
t.Fatal(err)
}
if got := tt.arg.String(); tt.want != got {
t.Fatalf("expected: %q, actual: %q\n", tt.want, got)
}
})
}
}
テストは成功します。ここまでは順調です。
$ go test
PASS
(並列にテストを実行)失敗
テストケースは各々が独立しているため並列に実行できます。t.Parallel()
を追加して並列に実行されるように変更してみましょう。
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := AppendHello(tt.arg); err != nil {
t.Fatal(err)
}
if got := tt.arg.String(); tt.want != got {
t.Fatalf("expected: %q, actual: %q\n", tt.want, got)
}
})
}
テストを実行してみましょう。
$ go test
--- FAIL: TestAppendHello (0.00s)
--- FAIL: TestAppendHello/two (0.00s)
example_test.go:39: expected: "threeHello", actual: "threeHelloHelloHello"
失敗しました。何が起きているのでしょうか。
forループとgoroutineのおさらい
以下のようにforループ内でgoroutineを実行すると、どのようなメッセージが表示されるでしょうか。
package main
import (
"fmt"
"sync"
)
func main() {
fruits := []string{"apple", "banana", "cherry"}
var group sync.WaitGroup
for _, fruit := range fruits {
group.Add(1)
go func() {
fmt.Println(fruit)
group.Done()
}()
}
group.Wait()
}
結果は常に「cherry」が3回表示されます。
$ go run main.go
cherry
cherry
cherry
goroutineは実行されるタイミングでfruit変数を参照します。そして、各々のgoroutineが実行されるとき、すでにループは終わっています。従って、fruit変数に格納されている文字列「cherry」が3回表示されます。
t.Run()
はどのように実装されているか
testingパッケージのここでtestingパッケージのt.Run()
がどのように実装されているか確認してみましょう。
func (t *T) Run(name string, f func(t *T)) bool {
// 省略
go tRunner(t, f)
// 省略
}
テーブル駆動テストを並列に実行すると失敗する原因が判明しました。t.Run()
に渡したf
がforループ内でgoroutineとして実行されていました。
解決策
なにが起きているのか把握できたので解決策を考えましょう。forループ内のgoroutineの問題については2つの解決策があります。
- 適当な変数に束縛する
- クロージャの引数として渡す
適当な変数に束縛する
以下のように実装します。
package main
import (
"fmt"
"sync"
)
func main() {
fruits := []string{"apple", "banana", "cherry"}
var group sync.WaitGroup
for i := range fruits {
fruit := fruits[i]
group.Add(1)
go func() {
fmt.Println(fruit)
group.Done()
}()
}
group.Wait()
}
クロージャの引数として渡す
以下のように実装します。
package main
import (
"fmt"
"sync"
)
func main() {
fruits := []string{"apple", "banana", "cherry"}
var group sync.WaitGroup
for _, fruit := range fruits {
group.Add(1)
go func(s string) {
fmt.Println(s)
group.Done()
}(fruit)
}
group.Wait()
}
どちらの解決策を選ぶべきか
どちらのアプローチもgoroutineが実行される環境に変数を束縛していることに変わりないので、どちらでも構いません。今回のテーブル駆動テストの例では以下のように変数を束縛するのが最も手軽な解決策かと思います。
for i := range tests {
tt := tests[i]
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := AppendHello(tt.arg); err != nil {
t.Fatal(err)
}
if got := tt.arg.String(); tt.want != got {
t.Fatalf("expected: %q, actual: %q\n", tt.want, got)
}
})
}
go vetやgolangci-lintでミスを防ぐ
testingパッケージのドキュメントをよく読んでいればミスは防げたかもしれません。しかし、ドキュメントの内容が常に正しいとは限りません。かといって毎回実装の詳細を追いかけるのは骨が折れます。
そこで役に立つのがgo vet
です。go vet
を実行すれば実装のミスに気がつくことができます。例えば、修正前の並列版TestAppendHelloの実装には以下のような警告が表示されます。
$ go vet
# example
./example_test.go:35:26: loop variable tt captured by func literal
./example_test.go:38:14: loop variable tt captured by func literal
./example_test.go:38:31: loop variable tt captured by func literal
./example_test.go:39:44: loop variable tt captured by func literal
上記は記事を投稿した時点で最新のgo 1.20.1で実行した結果になります。その他、golangci-lint
などgo vet
に準ずるツールを実行することでもミスに気がつくことができます。
Discussion