The Go Blog

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

Marcel van Lohuizen
2016年10月3日

はじめに

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"},     // incorrect location name
        {"12:31", "America/New_York", "7:31"}, // should be 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.7以前では、strconvパッケージのAppendFloatのベンチマークは次のようになっていました

func benchmarkAppendFloat(b *testing.B, f float64, fmt byte, prec, bitSize int) {
    dst := make([]byte, 30)
    b.ResetTimer() // Overkill here, but for illustrative purposes.
    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メソッドを使用すると、同じ一連のベンチマークが単一のトップレベルベンチマークとして表現されるようになりました

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メソッドを呼び出す包含ベンチマーク関数は一度だけ実行され、測定されません。

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

サブテストを使用したテーブル駆動型テスト

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とその同種はサブテストをスキップさせますが、その親や後続のサブテストはスキップさせません。

もう1つ注意すべき点は、新しい実装のエラーメッセージが短いことです。サブテスト名がサブテストを一意に識別するため、エラーメッセージ内でテストを再度識別する必要はありません。

以下のセクションで明確にされているように、サブテストまたはサブベンチマークを使用することには他にもいくつかの利点があります。

特定のテストまたはベンチマークの実行

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

サブテストまたはサブベンチマークの完全な名前は、その名前とすべての親の名前をスラッシュで区切ったリストであり、トップレベルから始まります。トップレベルのテストとベンチマークの名前は対応する関数名であり、それ以外の場合はRunの最初の引数です。表示と解析の問題を避けるため、名前はスペースをアンダースコアに置き換え、印刷不可能な文字をエスケープしてサニタイズされます。同じサニタイズが、-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を使用してもどのテストにも一致しません。これは、ロケーション名に存在するスラッシュもセパレーターとして扱われるためです。代わりに、次を使用します

$ 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) {
    // <setup code>
    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()
        }
    })
    // <tear-down code>
}

セットアップコードとティアダウンコードは、含まれるサブテストのいずれかが実行された場合に実行され、最大1回だけ実行されます。これは、サブテストのいずれかがSkipFail、またはFatalを呼び出した場合でも適用されます。

並列性の制御

サブテストは、並列性に対してきめ細やかな制御を可能にします。サブテストをこの方法で使用する方法を理解するには、並列テストのセマンティクスを理解することが重要です。

各テストはテスト関数に関連付けられています。テスト関数がtesting.TのインスタンスでParallelメソッドを呼び出す場合、テストは並列テストと呼ばれます。並列テストはシーケンシャルテストと同時に実行されることはなく、その実行は、その呼び出し元のテスト関数(親テストのテスト関数)が返されるまで中断されます。-parallelフラグは、並列に実行できる並列テストの最大数を定義します。

テストは、テスト関数が返され、すべてのサブテストが完了するまでブロックします。これは、シーケンシャルテストによって実行される並列テストが、他の連続するシーケンシャルテストが実行される前に完了することを意味します。

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

テストのグループを並列で実行する

上記のセマンティクスにより、テストのグループを互いに並列に実行できますが、他の並列テストとは並列に実行できません

func TestGroupedParallel(t *testing.T) {
    for _, tc := range testCases {
        tc := tc // capture range variable
        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が正しいインスタンスにバインドされるように、範囲変数をキャプチャする必要があることに注意してください。

並列テストのグループのクリーンアップ

前の例では、セマンティクスを使用して、他のテストを開始する前に並列テストのグループが完了するのを待っていました。同じ手法を使用して、共通のリソースを共有する並列テストのグループをクリーンアップできます

func TestTeardownParallel(t *testing.T) {
    // <setup code>
    // This Run will not return until its parallel subtests complete.
    t.Run("group", func(t *testing.T) {
        t.Run("Test1", parallelTest1)
        t.Run("Test2", parallelTest2)
        t.Run("Test3", parallelTest3)
    })
    // <tear-down code>
}

並列テストのグループを待つ動作は、前の例と同じです。

まとめ

Go 1.7のサブテストとサブベンチマークの追加により、既存のツールとうまく融合する自然な方法で構造化されたテストとベンチマークを作成できます。このことについて考える1つの方法は、testingパッケージの以前のバージョンには1レベルの階層があったということです。パッケージレベルのテストは、個々のテストとベンチマークのセットとして構造化されていました。現在、その構造は、これらの個々のテストとベンチマークに再帰的に拡張されています。実際、実装では、トップレベルのテストとベンチマークは、暗黙のマスターテストとベンチマークのサブテストとサブベンチマークであるかのように追跡されます。すべてのレベルで扱いは本当に同じです。

テストがこの構造を定義する機能により、特定のテストケースのきめ細やかな実行、共有セットアップとティアダウン、およびテストの並列性のより良い制御が可能になります。人々がどのような他の用途を見つけるかを見るのが楽しみです。お楽しみください。

次の記事:HTTPトレースの紹介
前の記事:Go 1.7バイナリの小型化
ブログインデックス