サブテストとサブベンチマークの使い方

Using Subtests and Sub-benchmarks by Marcel van Lohuizen

はじめに

Go 1.7 では、testing パッケージの T 型と B 型に Run メソッドが導入され、 サブテストとサブベンチマークを作成できるようになりました。サブテストとサブベンチマークの 導入によって、失敗のより良い扱い、コマンドラインからどのテストを実行するかのきめ細かい制御、 並行実行の制御が可能になり、多くの場合コードがよりシンプルで保守しやすくなります。

テーブル駆動テストの基本

詳細に踏み込む前に、まずGoにおける一般的なテストの書き方について説明しましょう。 一連の関連するチェックは、テストケースのスライスをループすることで実装できます。

func TestTime(t *testing.T) {
    testCases := []struct {
        gmt  string
        loc  string
        want string
    }{
        {"12:31", "Europe/Zuri", "13:31"},     // 誤ったロケーション名
        {"12:31", "America/New_York", "7:31"}, // 07:31になるはず
        {"08:08", "Australia/Sydney", "18:08"},
    }
    for _, tc := range testCases {
        loc, err := time.LoadLocation(tc.loc)
        if err != nil {
            t.Fatalf("could not load location %q", tc.loc)
        }
        gmt, _ := time.Parse("15:04", tc.gmt)
        if got := gmt.In(loc).Format("15:04"); got != tc.want {
            t.Errorf("In(%s, %s) = %s; want %s", tc.gmt, tc.loc, got, tc.want)
        }
    }
}

このアプローチは一般にテーブル駆動テストと呼ばれ、各テストで同じコードを繰り返す場合に比べて 重複するコードの量を減らし、テストケースを追加するのも簡単になります。

テーブル駆動ベンチマーク

Go 1.7より前では、ベンチマークに対して同じテーブル駆動のアプローチを使うことはできませんでした。 ベンチマークは関数全体の性能を計測するものなので、ベンチマークをループで回すと、それらすべてを 1つのベンチマークとしてまとめて計測してしまうことになります。

よくある回避策は、それぞれ異なるパラメータで共通の関数を呼び出す、別々のトップレベルのベンチマークを 定義することでした。たとえば、1.7より前では strconv パッケージの AppendFloat に対する ベンチマークは次のようになっていました。

func benchmarkAppendFloat(b *testing.B, f float64, fmt byte, prec, bitSize int) {
    dst := make([]byte, 30)
    b.ResetTimer() // ここではやり過ぎですが、説明のためのものです。
    for i := 0; i < b.N; i++ {
        AppendFloat(dst[:0], f, fmt, prec, bitSize)
    }
}

func BenchmarkAppendFloatDecimal(b *testing.B) { benchmarkAppendFloat(b, 33909, 'g', -1, 64) }
func BenchmarkAppendFloat(b *testing.B)        { benchmarkAppendFloat(b, 339.7784, 'g', -1, 64) }
func BenchmarkAppendFloatExp(b *testing.B)     { benchmarkAppendFloat(b, -5.09e75, 'g', -1, 64) }
func BenchmarkAppendFloatNegExp(b *testing.B)  { benchmarkAppendFloat(b, -5.11e-95, 'g', -1, 64) }
func BenchmarkAppendFloatBig(b *testing.B)     { benchmarkAppendFloat(b, 123456789123456789123456789, 'g', -1, 64) }
...

Go 1.7で使えるようになった Run メソッドを使うと、同じ一連のベンチマークを1つのトップレベルの ベンチマークとして表現できます。

func BenchmarkAppendFloat(b *testing.B) {
    benchmarks := []struct{
        name    string
        float   float64
        fmt     byte
        prec    int
        bitSize int
    }{
        {"Decimal", 33909, 'g', -1, 64},
        {"Float", 339.7784, 'g', -1, 64},
        {"Exp", -5.09e75, 'g', -1, 64},
        {"NegExp", -5.11e-95, 'g', -1, 64},
        {"Big", 123456789123456789123456789, 'g', -1, 64},
        ...
    }
    dst := make([]byte, 30)
    for _, bm := range benchmarks {
        b.Run(bm.name, func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                AppendFloat(dst[:0], bm.float, bm.fmt, bm.prec, bm.bitSize)
            }
        })
    }
}

Run メソッドを呼び出すたびに、それぞれ別のベンチマークが作られます。Run メソッドを呼び出す 外側のベンチマーク関数自体は1度しか実行されず、計測の対象にはなりません。

新しいコードは行数は増えますが、より保守しやすく、読みやすく、テストでよく使われるテーブル駆動の アプローチとも一貫性があります。さらに、共通のセットアップ用のコードは各実行の間で共有されるように なり、タイマーをリセットする必要もなくなります。

サブテストを使ったテーブル駆動テスト

Go 1.7ではサブテストを作成するための Run メソッドも導入されました。先ほどの例をサブテストを 使って書き直すと次のようになります。

func TestTime(t *testing.T) {
    testCases := []struct {
        gmt  string
        loc  string
        want string
    }{
        {"12:31", "Europe/Zuri", "13:31"},
        {"12:31", "America/New_York", "7:31"},
        {"08:08", "Australia/Sydney", "18:08"},
    }
    for _, tc := range testCases {
        t.Run(fmt.Sprintf("%s in %s", tc.gmt, tc.loc), func(t *testing.T) {
            loc, err := time.LoadLocation(tc.loc)
            if err != nil {
                t.Fatal("could not load location")
            }
            gmt, _ := time.Parse("15:04", tc.gmt)
            if got := gmt.In(loc).Format("15:04"); got != tc.want {
                t.Errorf("got %s; want %s", got, tc.want)
            }
        })
    }
}

まず注目すべきは、2つの実装での出力の違いです。元の実装は次のように出力します。

--- FAIL: TestTime (0.00s)
    time_test.go:62: could not load location "Europe/Zuri"

実際には2つのエラーがあるにもかかわらず、テストの実行は Fatalf の呼び出しで止まってしまい、 2番目のテストは実行されません。

Run を使った実装では、両方が出力されます。

--- FAIL: TestTime (0.00s)
    --- FAIL: TestTime/12:31_in_Europe/Zuri (0.00s)
        time_test.go:84: could not load location
    --- FAIL: TestTime/12:31_in_America/New_York (0.00s)
        time_test.go:88: got 07:31; want 7:31

Fatal とその仲間のメソッドは、そのサブテストをスキップさせますが、親のテストやそれ以降の サブテストには影響しません。

もう一つ注目すべき点は、新しい実装ではエラーメッセージが短くなっていることです。サブテストの 名前によってそのサブテストが一意に識別されるため、エラーメッセージの中で改めてテストを特定する 必要がなくなります。

サブテストやサブベンチマークを使うことには他にもいくつかの利点があり、それらは以降の節で 明らかにしていきます。

特定のテストやベンチマークの実行

サブテストとサブベンチマークはどちらも、 -run または -bench フラグを 使ってコマンドラインから個別に指定できます。どちらのフラグも、サブテストやサブベンチマークの 完全な名前の対応する部分にマッチする正規表現をスラッシュ区切りにしたリストを引数に取ります。

サブテストやサブベンチマークの完全な名前は、トップレベルから始まる、自分自身とすべての親の 名前をスラッシュで区切って並べたものです。名前は、トップレベルのテストやベンチマークの場合は 対応する関数名であり、それ以外の場合は Run の第1引数です。表示やパース時の問題を避けるため、 名前はスペースをアンダースコアに置き換え、印字不可能な文字をエスケープすることでサニタイズされます。 同じサニタイズが -run-bench フラグに渡す正規表現にも適用されます。

いくつか例を見てみましょう。

ヨーロッパのタイムゾーンを使うテストを実行してみます。

$ go test -run=TestTime/"in Europe"
--- FAIL: TestTime (0.00s)
    --- FAIL: TestTime/12:31_in_Europe/Zuri (0.00s)
        time_test.go:85: could not load location

正午以降の時刻のテストだけを実行してみます。

$ go test -run=Time/12:[0-9] -v
=== RUN   TestTime
=== RUN   TestTime/12:31_in_Europe/Zuri
=== RUN   TestTime/12:31_in_America/New_York
--- FAIL: TestTime (0.00s)
    --- FAIL: TestTime/12:31_in_Europe/Zuri (0.00s)
        time_test.go:85: could not load location
    --- FAIL: TestTime/12:31_in_America/New_York (0.00s)
        time_test.go:89: got 07:31; want 7:31

少し意外かもしれませんが、-run=TestTime/New_York を使ってもテストは1つもマッチしません。 これはロケーション名の中に含まれるスラッシュも区切り文字として扱われてしまうからです。 代わりに次のようにします。

$ go test -run=Time//New_York
--- FAIL: TestTime (0.00s)
    --- FAIL: TestTime/12:31_in_America/New_York (0.00s)
        time_test.go:88: got 07:31; want 7:31

-run に渡す文字列中の // に注目してください。タイムゾーン名 America/New_York に含まれる / は、サブテストによって生じた区切り文字であるかのように扱われます。パターンの最初の正規表現 (TestTime)はトップレベルのテストにマッチします。2番目の正規表現(空文字列)は何にでもマッチし、 この場合は時刻とロケーションの大陸部分にマッチします。3番目の正規表現(New_York)はロケーションの 都市部分にマッチします。

名前の中のスラッシュを区切り文字として扱うことで、名前を変更することなくテストの階層構造を リファクタリングできるようになります。またこれによってエスケープのルールも単純になります。 もしこれが問題になる場合は、名前の中のスラッシュを、たとえばバックスラッシュに置き換えるなどして エスケープするべきです。

一意ではないテスト名には、一意になるよう連番が付加されます。そのため、サブテストに対して わかりやすい命名規則がなく、連番だけで簡単にサブテストを識別できるのであれば、単に空文字列を Run に渡すという方法もあります。

セットアップとティアダウン

サブテストとサブベンチマークは、共通のセットアップやティアダウンのコードを管理するために 使うこともできます。

func TestFoo(t *testing.T) {
    // <セットアップ用のコード>
    t.Run("A=1", func(t *testing.T) { ... })
    t.Run("A=2", func(t *testing.T) { ... })
    t.Run("B=1", func(t *testing.T) {
        if !test(foo{B:1}) {
            t.Fail()
        }
    })
    // <ティアダウン用のコード>
}

セットアップとティアダウンのコードは、内包されているサブテストのいずれかが実行されれば実行され、 実行されるのは高々1回だけです。これは、サブテストのいずれかが SkipFailFatal を呼び出した 場合でも同様です。

並行実行の制御

サブテストを使うと、並行実行についてきめ細かい制御ができるようになります。そのような使い方を 理解するには、並行テストのセマンティクスを理解しておくことが重要です。

各テストにはテスト関数が紐づいています。テスト関数がその testing.T のインスタンスに対して Parallel メソッドを呼び出す場合、そのテストは並行テストと呼ばれます。並行テストは逐次テストと 同時に実行されることは決してなく、その実行は呼び出し元のテスト関数、つまり親のテストが終了する まで一時停止されます。-parallel フラグは、同時に実行できる並行テストの最大数を定義します。

テストは、そのテスト関数がリターンし、かつすべてのサブテストが完了するまでブロックします。 つまり、ある逐次テストによって実行された並行テストは、次の逐次テストが実行される前にすべて 完了することになります。

この振る舞いは、Run によって作られたテストとトップレベルのテストとで同一です。実際、内部的には トップレベルのテストは、隠されたマスターテストのサブテストとして実装されています。

テストグループの並行実行

上記のセマンティクスによって、あるグループのテスト同士は並行に実行しつつ、他の並行テストとは 同時に実行しないようにできます。

func TestGroupedParallel(t *testing.T) {
    for _, tc := range testCases {
        tc := tc // range変数を捕捉する
        t.Run(tc.Name, func(t *testing.T) {
            t.Parallel()
            if got := foo(tc.in); got != tc.out {
                t.Errorf("got %v; want %v", got, tc.out)
            }
            ...
        })
    }
}

外側のテストは、Run によって開始されたすべての並行テストが完了するまで完了しません。その結果、 これらの並行テストと同時に他の並行テストが実行されることはありません。

tc が正しいインスタンスに束縛されることを保証するために、range変数を捕捉する必要がある点に 注意してください。

並行テストグループの後片付け

先ほどの例では、あるグループの並行テストの完了を待ってから他のテストを開始するという目的で このセマンティクスを使いました。同じテクニックは、共通のリソースを共有する一連の並行テストの 後片付けを行うのにも使えます。

func TestTeardownParallel(t *testing.T) {
    // <セットアップ用のコード>
    // このRunは、並行実行されるサブテストが完了するまでリターンしません。
    t.Run("group", func(t *testing.T) {
        t.Run("Test1", parallelTest1)
        t.Run("Test2", parallelTest2)
        t.Run("Test3", parallelTest3)
    })
    // <ティアダウン用のコード>
}

並行テストのグループの完了を待つという振る舞いは、先ほどの例と同一です。

結論

Go 1.7でサブテストとサブベンチマークが追加されたことで、既存のツール群にうまく溶け込む自然な形で 構造化されたテストやベンチマークを書けるようになりました。この変化は次のように捉えることもできます。 それまでの testing パッケージは1階層の構造しか持っておらず、パッケージレベルのテストは個々の テストやベンチマークの集合として構造化されているだけでした。今やその構造は、それら個々のテストや ベンチマークにまで再帰的に拡張されています。実際、実装上ではトップレベルのテストやベンチマークも、 暗黙のマスターテストおよびマスターベンチマークのサブテストやサブベンチマークであるかのように 扱われています。つまり、どの階層でも本当に同じ扱いがされているのです。

テストがこのような構造を定義できるようになったことで、特定のテストケースのきめ細かい実行、 セットアップとティアダウンの共有、そしてテストの並行実行に対するより良い制御が可能になりました。 この機能が他にどのように活用されるのか、私たちも楽しみにしています。Enjoy(楽しんでください)。

By Marcel van Lohuizen