Goブログ

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

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メソッドを呼び出す囲みベンチマーク関数は、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とその兄弟は、サブテストをスキップさせますが、親または後続のサブテストはスキップさせません。

もう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つの方法は、以前のバージョンのテストパッケージには1レベルの階層があったということです。パッケージレベルのテストは、個別のテストとベンチマークのセットとして構造化されていました。その構造が、これらの個々のテストとベンチマークに再帰的に拡張されました。実際、実装では、トップレベルのテストとベンチマークは、暗黙のマスターテストとベンチマークのサブテストとサブベンチマークであるかのように追跡されます。処理は実際にはすべてのレベルで同じです。

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

次の記事: HTTPトレースの導入
前の記事: Go 1.7のバイナリサイズの縮小
ブログインデックス