🐷

そのテーブル駆動テスト、並列に実行できますか?

2023/03/10に公開

はじめに

記事を書き終える直前に気がついたのですが、先人がすでに同様の趣旨の記事を書かれていました。そちらの記事を読んでいただけば私の記事は読まなくて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回表示されます。

testingパッケージのt.Run()はどのように実装されているか

ここで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