Go 部落格

使用子測試和子基準測試

Marcel van Lohuizen
2016年10月3日

引言

在 Go 1.7 中,testing 包在 TB 型別上引入了 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)
            }
        })
    }
}

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

--- 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)匹配頂級測試。第二個正則表示式(空字串)匹配任何內容,在本例中是時間和位置的大陸部分。第三個正則表示式(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>
}

如果運行了包含的任何子測試,設定和拆卸程式碼將執行,並且最多執行一次。即使任何子測試呼叫了 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 新增的子測試和子基準測試允許您以自然的方式編寫結構化的測試和基準測試,並能很好地融入現有工具。可以這樣理解:早期版本的 testing 包有一個 1 級層次結構:包級測試被構造為一組單獨的測試和基準測試。現在,這種結構已擴充套件到這些單獨的測試和基準測試,並且是遞迴的。事實上,在實現中,頂級測試和基準測試被跟蹤,就好像它們是隱式主測試和基準測試的子測試和子基準測試一樣:所有級別的處理方式都相同。

測試定義這種結構的能力實現了對特定測試用例的精細執行、共享的設定和拆卸,以及對測試並行性的更好控制。我們很期待看到人們還會發現哪些其他用法。盡情享用吧。

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