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 新增了子測試和子基準測試,讓你可以用自然的方式,撰寫結構化的測試和基準測試,並與現有的工具緊密結合。一種思考方式是,測試套件較早的版本具備 1 層級階層:套件層級測試組成個別的測試和基準測試。現在該結構已延伸到這些個別測試和基準測試,並遞迴套用。實際上,在實作時,頂層測試和基準測試的追蹤方式,就像它們是隱含主測試和基準測試的子測試和子基準測試一樣:在所有層級的處理方式都相同。

透過讓測試定義這種結構,可以微調執行特定測試案例、共用設定和中斷,以及更妥善地控制測試平行性的功能。我們很期待看到大家發現的其他用途。請享受。

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