Go 部落格

使用 Subtests 和 Sub-benchmarks

Marcel van Lohuizen
2016 年 10 月 3 日

引言

在 Go 1.7 中,testing 包在 TB 型別上引入了 Run 方法,允許建立子測試 (subtests) 和子基準測試 (sub-benchmarks)。引入子測試和子基準測試使得更好地處理失敗、從命令列對執行哪些測試進行細粒度控制、控制並行性成為可能,並且通常會帶來更簡單、更易維護的程式碼。

表驅動測試基礎知識

在深入細節之前,我們先討論一下 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 方法的 enclosing 基準測試函式只執行一次,並且不被衡量。

新程式碼的行數更多,但更易維護、更具可讀性,並與測試中常用的表驅動方法一致。此外,現在可以在執行之間共享通用的 setup 程式碼,同時無需重置計時器。

使用子測試進行表驅動測試

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)
            }
        })
    }
}

首先要注意的是兩種實現的輸出差異。原始實現列印

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

即使有兩個錯誤,測試的執行也會在呼叫 Fatalf 時停止,第二個測試永遠不會執行。

使用 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 的第一個引數。為了避免顯示和解析問題,名稱透過將空格替換為下劃線和轉義不可列印字元進行清理。傳遞給 -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) 匹配頂層測試。第二個正則表示式(空字串)匹配任何內容,在本例中是時間和位置的 continent 部分。第三個正則表示式 (New_York) 匹配位置的 city 部分。

將名稱中的斜槓視為分隔符允許使用者在無需更改命名的情況下重構測試的層次結構。它還簡化了轉義規則。如果構成問題,使用者應轉義名稱中的斜槓,例如將它們替換為反斜槓。

對於不唯一的測試名稱,會附加一個唯一的序列號。因此,如果子測試沒有明顯的命名方案,並且可以透過其序列號輕鬆識別,則可以直接向 Run 傳遞一個空字串。

設定和拆卸 (Setup and Tear-down)

子測試和子基準測試可用於管理通用的設定和拆卸程式碼

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>
}

如果執行任何 enclosed 的子測試,設定和拆卸程式碼將執行,並且最多執行一次。即使任何子測試呼叫了 SkipFailFatal,這也是適用的。

控制並行性

子測試允許對並行性進行細粒度控制。要理解如何以這種方式使用子測試,瞭解並行測試的語義非常重要。

每個測試都關聯一個測試函式。如果其測試函式呼叫了其 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 新增的子測試和子基準測試允許您以一種自然的方式編寫結構化測試和基準測試,與現有工具 nicely 融合。可以這樣想,早期版本的 testing 包只有 1 級層次結構:包級測試被構造為一組獨立的測試和基準測試。現在,該結構已遞迴地擴充套件到這些獨立的測試和基準測試。實際上,在實現中,頂層測試和基準測試被視為隱式主測試和基準測試的子測試和子基準測試來跟蹤:所有級別的處理方式確實相同。

測試定義這種結構的能力使得可以對特定測試用例進行細粒度執行,共享設定和拆卸,以及更好地控制測試並行性。我們很高興看到人們會發現其他哪些用途。祝您使用愉快。

下一篇文章:引入 HTTP 追蹤
上一篇文章:更小的 Go 1.7 二進位制檔案
部落格索引