0%

Golang Test - go test 基本使用方式與詳細解說

前言

Golang 官方提供的測試工具 (go test) 與其他工具 (cover、race detector … 等) 高度整合,讓我們僅使用一個指令即可得到 Test Coverage 數據、偵測 Race Condition 等等。此外,隨工具而來的測試框架讓 Gopher 有規則可以共同遵守,不僅提昇開發者體驗,同時降低團隊間的溝通成本。在我們深入撰寫測試案例之前,先瞭解 go test 相關功能與使用姿勢將會有一定的幫助,這麼好的工具一定要讓它發揮滿滿的作用。

Naming Conventions

Golang 測試框架有幾個命名的習慣

  1. 測試檔案 (Test File) 的檔案名稱皆以 _test.go 結尾,go test 可藉此知道這個檔案包含測試案例的程式碼。
  2. 測試函數 (Test Function) 以 Test 為開頭命名,且 go test 只會執行在 Test File 中的 Test Function。
  3. go test 會忽略以底線 _ 或點 . 開頭作為檔名的檔案,需要特別注意的是 _test.go 雖然符合 Test File 檔名格式,但它會因底線開頭而被忽略。
  4. 名稱為 testdata 的目錄可以用來存放在測試中會使用到的測試資料。

Simple Example

使用簡單的加法函數 AddNumbers (該函數期望收到兩個整數作為輸入參數,並回傳兩數之合) 作為範例,來學習 Golang Test Function 的基本寫法。

目錄結構的長相

1
2
3
4
5
6
$ tree .
.
├── add
│ ├── add.go
│ └── add_test.go
└── go.mod

首先,在包含 go.mod 的 Go 專案中建立兩個檔案

  1. add.go :實做 AddNumbers 函數

    1
    2
    3
    4
    5
    package add

    func AddNumbers(a, b int) int {
    return a + b
    }
  2. add_test.go:撰寫 AddNumbers 函數的測試案例。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    package add

    import "testing"

    func TestAddNumbers(t *testing.T) {
    if AddNumbers(1, 2) != 3 {
    t.Error("result should be 3")
    }
    }
    • testing 是用來配合 go test 工具的套件,每個 Test Function 都要定義 t *testing.T 作為輸入參數,將被用來管理測試狀態 (例如:是否要跳過該 Test Function、是否要平行運行該 Test Function、該 Test Function 是否失敗 ⋯⋯ 等等)。
    • 假設 AddNumbers(1, 2) 的結果不為 3,執行 t.Error 將測試狀態標記為失敗並印出測試失敗訊息 result should be 3

於檔案存在的目錄 (此處為 add 目錄) 中執行 go test 指令進行測試,首先會輸出測試結果 (PASS),再印出每個 Package 測試的狀態 (分別為:成功與否、Package 名稱、測試執行時間 (Elapsed Time))

1
2
3
$ go test
PASS
ok learning-test/add 0.001s

若需要印出更多資訊,可以加上 -v Flag

1
2
3
4
5
$ go test -v
=== RUN TestAddNumbers
--- PASS: TestAddNumbers (0.00s)
PASS
ok learning-test/add 0.001s

讓我們刻意將 AddNumbers 的實做改為 return a * b (從加法改為乘法) 後進行測試,因為修改後的 AddNumbers(1, 2) 回傳值是 2 而不是 3,所以測試結果為 FAIL 並印出我們提供的測試失敗訊息 result should be 3

1
2
3
4
5
6
7
$ go test -v
=== RUN TestAddNumbers
add_test.go:7: result should be 3
--- FAIL: TestAddNumbers (0.00s)
FAIL
exit status 1
FAIL learning-test/add 0.001s

測試檔案的名稱以 _test.go 結尾是必須的,但前綴不一定需要與原檔案名稱相同。舉例來說,可以使用 foo_test.go 作為測試檔案的名稱,我們只需要確保 add.gofoo_test.go 放在同一個目錄下即可進行測試。

Methods of testing.T

type testing.T 提供許多有用的函數,這裡僅說明幾個常在撰寫測試案例時會用到的功能,若想知道更多或更詳細的資訊,可以查看說明文件

t.Error VS. t.Fatal

除了使用 t.Error 將測試狀態設為失敗並印出訊息,也可以使用 t.Fatal 代替 t.Error。兩者差異為

  1. t.Error:將測試狀態標記為失敗,並繼續該 Test Function 的運行。
  2. t.Fatal:將測試狀態標記為失敗,且直接停止該 Test Function,也就是說在 t.Fatal 後的程式碼將不被運行。

以兩個測試函數作為範例,分別於調用 t.Errort.Fatal 後使用 t.Log 輸出訊息

1
2
3
4
5
6
7
8
9
func TestError(t *testing.T) {
t.Error("t.Error invoked")
t.Log("this line is the last line in TestError")
}

func TestFatal(t *testing.T) {
t.Fatal("t.Fatal invoked")
t.Log("this line is the last line in TestFatal")
}

測試結果顯示如下,可以觀察到 TestError 在執行 t.Error 後還會執行 t.Log,而 TestFatal 在調用 t.Fatal 後就被停止運行,所以 t.Log 的訊息沒有被輸出

1
2
3
4
5
6
7
8
9
10
11
$ go test -v
=== RUN TestError
add_test.go:6: t.Error invoked
add_test.go:7: this line is the last line in TestError
--- FAIL: TestError (0.00s)
=== RUN TestFatal
add_test.go:11: t.Fatal invoked
--- FAIL: TestFatal (0.00s)
FAIL
exit status 1
FAIL learning-test/add 0.096s

我們可以根據情境自行選擇 t.Error 或者 t.Fatal 來滿足使用需求。

t.Skip

當我們想要略過某個 Test Function 時,可以使用 t.Skip 來達成

1
2
3
func TestSomething(t *testing.T) {
t.Skip("TestSomething is skipped")
}

執行測試觀察結果,注意到此時 TestSomething 被標注為 Skip,同時 t.Skip 的輸入參數輸也會作為 Log 資訊印在測試報告中

1
2
3
4
5
6
go test -v
=== RUN TestSomething
add_test.go:8: TestSomething is skipped
--- SKIP: TestSomething (0.00s)
PASS
ok learning-test/add 0.372s

t.Run

在單個 Test Function 中,針對同個待測項 (Function、Class ⋯⋯ 等等) 寫多個測試案例 (例如:針對一個函數撰寫多個測試案例,每個測試案例的輸入參數不同) 是一種常見的情境,可以透過使用 t.Run 來達成撰寫子測試 (Subtest)

1
2
3
4
5
6
7
func TestSomething(t *testing.T) {
t.Run("sub test 1", func(t *testing.T) {
t.Error("sub test 1 failed")
})
t.Run("sub test 2", func(t *testing.T) {
})
}
  • t.Run:第一個參數為 Subtest 的名稱;第二個參數為 Subtest 本身的測試案例撰寫。

執行測試觀察結果,go test 會將 Subtest 以階層的方式展示於對應的 Test Function 底下,清楚地顯示每個 Subtest 的狀態

1
2
3
4
5
6
7
8
9
10
11
go test -v
=== RUN TestSomething
=== RUN TestSomething/sub_test_1
add_test.go:9: sub test 1 failed
=== RUN TestSomething/sub_test_2
--- FAIL: TestSomething (0.00s)
--- FAIL: TestSomething/sub_test_1 (0.00s)
--- PASS: TestSomething/sub_test_2 (0.00s)
FAIL
exit status 1
FAIL learning-test/add 0.099s

assert 與 require 套件

套件解決的問題

雖然可以使用 if 搭配 t.Errort.Fatal 來撰寫測試案例,但同時衍生出幾個問題

  1. 大量的樣板代碼 (Boilerplate Code):

    Boilerplate Code 即具有固定模式的代碼,例如:在測試代碼中先用 if 再使用 t.Error 是判斷測試失敗與否的 Boilerplate Code,如下所示

    1
    2
    3
    if some conditions {
    t.Error("reason for failure")
    }

    這樣的寫法本身沒有問題,但當我們因測試所需,一而再,再而三的寫出這樣的 Boilerplate Code 時,也許會想:「每次都要寫這幾行,好令人煩躁。」或者在看大量測試案例時會想:「好多 if,到底想表達什麼?」

  2. 需要重複寫類似的測試失敗訊息:

    測試失敗訊息很重要,但並不是每個測試失敗訊息都很有用,我們可以從測試失敗訊息 result should be 3 知道實際值應該等於 3,但我們無法從訊息中得知實際值是多少,若將輸出訊息修改為 result is 2, but should be 3 便能清楚知道測試失敗當下的實際值與期望值。

    最終,測試失敗訊息都會由相似語句「result is x, but should be y」組成 (也因為這樣的句子具有固定模式,所以算是 Boilerplate Code),但我們僅是想知道實際值 x 與期望值 y 的對應,卻要在撰寫測試案例時,寫一堆文字組出一個句子,以防未來的自己或同事看不懂。

第三方套件 github.com/stretchr/testify 應運而生,包含於其中的 assertrequire 兩個套件可用來解決撰寫測試案例時會遇到大量 Boilerplate Code 與重複撰寫測試失敗訊息的問題。

套件的使用方法

assertrequire 都存在於 testify 套件底下,因此使用前需要下載 testify

1
go get github.com/stretchr/testify

使用 assert 撰寫測試案例如下

1
2
3
4
5
6
7
8
9
import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestAssert(t *testing.T) {
assert.Equal(t, 1, 2)
}
  • assert.Equal(t, 1, 2):相信在還沒閱讀此函數的說明文件前,讀者能從函數名看出要比對的條件是「相等」,並從輸入參數知道比對的對象是 1 與 2。提升可讀性的同時,將我們從重工地獄釋放出來,再也不需寫 Boilerplate Code (if + t.Error)。

執行測試觀察結果,assert 於測試失敗所提供的訊息非常齊全,不只說明了失敗原因 (Not equal),也記錄當下的期望值 (expected: 1) 與實際值 (actual : 2),可謂一目了然!

1
2
3
4
5
6
7
8
9
10
11
12
$ go test -v
=== RUN TestAssert
add_test.go:10:
Error Trace: /project_name/something/something_test.go:10
Error: Not equal:
expected: 1
actual : 2
Test: TestAssert
--- FAIL: TestAssert (0.00s)
FAIL
exit status 1
FAIL learning-test/something 0.458s

assert 套件除了 Equal 外,還有很多函數可以使用,例如:NotNilTrueWithinDuration ⋯⋯ 等等,幾乎每種測試條件都有對應的函數,建議使用 if + t.Error 之前先試著尋找是否有可取代 Boilerplate Code 的函數。

assert VS. require

兩種套件整體功能都相同,差別在於是否會停止 Test Function 的運行

  1. assert:將測試狀態標記為失敗,並繼續該 Test Function 的運行。
  2. require:將測試狀態標記為失敗,且直接停止該 Test Function,也就是說在 require 後的程式碼將不被運行。

有沒有似曾相識的感覺?沒錯,就是和 t.Errort.Fatal 的差異相同!同樣的,沒有一定要使用 assert 或者 require,全看測試案例的情境做選擇。

Testing Mode

go test 有兩種不同運行模式,分別為 Local Directory Mode 與 Package List Mode。

Local Directory Mode

沒有指定要測試的 Package,即為 Local Directory Mode,例如:go testgo test -v。go test 會編譯當前目錄中的 Source Code 與 Test Code 產出測試執行檔 (Test Binary) 並運行以取得測試結果。

此模式不會緩存 (Caching) 測試結果,因此不論何時運行測試,都會真的重新執行測試並取得最新結果。

Package List Mode

有指定要測試的 Package,即為 Package List Mode,例如:go test .go test ./...go test some_package_name。go test 會編譯指定 Packages 的 Source Code 與 Test Code 產出 Test Binaries (每個 Package 一個 Test Binary) 並運行以取得測試結果。

為了避免不必要的重複運行,此模式會 Caching 測試結果,若滿足 Cache 機制 (執行同一個 Test Binary 且使用可緩存的 (Cacheable) Test Flags 如 -benchtime-cpu-list-parallel-run-short-timeout-failfast-v),並且 Cache 中存在對應的測試結果,go test 將會直接印出 Cache 的測試結果,同時在原本 Elapsed Time 的地方標記為 (cached),如下方所示

1
2
3
4
5
6
7
# first test
$ go test .
ok learning-test/add 0.390s

# second test
$ go test .
ok learning-test/add (cached)

雖然 Cache 機制聽起來不錯,但不希望由 Cache 取得測試結果反而是比較常見的情境,在這樣的情況下,我們想要每次都是重新執行測試以獲得最新測試報告。有兩種做法能達到這樣的效果

  1. 使用 go clean 工具在進行測試之前先清除 Test Cache,便能確保 Cache 機制不會被觸發

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # first test
    $ go test .
    ok learning-test/add 0.477s

    # clean the test cache
    $ go clean -testcache

    # second test
    $ go test .
    ok learning-test/add 0.119s
  2. go test 加上 Non-cacheable Test Flag -count (用來指定運行幾次 Test,詳細說明可參考 -count 一節),-count=1 可以在不影響測試行為的同時取消 Cache 機制

    1
    2
    3
    4
    5
    6
    7
    # first test
    go test -count=1 .
    ok learning-test/add 0.441s

    # second test
    go test -count=1 .
    ok learning-test/add 0.100s

Test Flags

-short

通常 Integration Test 需要運行的時間較長,而在某些情境中會希望只執行 Unit Test 等時間較短的測試,這時候可以利用 -short Flag。

在撰寫測試案例時透過 testing.Short 判斷 go test 指令是否有帶 -short Flag,若有的話,可以搭配 t.Skip 將運行所需時間較長的測試跳過

1
2
3
4
5
func TestSomething(t *testing.T) {
if testing.Short() {
t.Skip("go test with -short")
}
}

執行測試觀察結果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# test without short flag
$ go test -v
=== RUN TestSomething
--- PASS: TestSomething (0.00s)
PASS
ok learning-test/add 0.093s

# test with short flag
$ go test -v -short
=== RUN TestSomething
add_test.go:9: go test with -short
--- SKIP: TestSomething (0.00s)
PASS
ok learning-test/add 0.098s

-timeout

go test 預設的逾時時間 (Timeout) 為 10 分鐘,也就是說,如果測試運行的整體時間超過 10 分鐘,go test 會直接終止測試並標記為失敗。若已知測試不需要這麼多時間去運行,可以使用 -timeout 修改逾時時間為合理值,例如 30 秒

1
go test -timeout=30s

-count

-count 讓我們得以指定要執行幾次測試,若我們的測試有隨機的成分 (例如:輸入參數是隨機產生),透過加大 count 可以提昇因隨機數值而導致測試失敗的可能性,提早發現存在的問題

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# test one time
$ go test -v -count=1
=== RUN TestSomething
--- PASS: TestSomething (0.00s)
PASS
ok learning-test/add 0.260s

# test three times
$ go test -v -count=3
=== RUN TestSomething
--- PASS: TestSomething (0.00s)
=== RUN TestSomething
--- PASS: TestSomething (0.00s)
=== RUN TestSomething
--- PASS: TestSomething (0.00s)
PASS
ok learning-test/add 0.099s

除此之外,-count 最常用來取消觸發 Package List Mode 的 Cache 機制,詳細說明可以查看 Package List Mode 一節。

-failfast

想必讀者已經清楚當 t.Fatal 被執行時,該 Test Function 後續的程式碼並不會被執行,也就是說 t.Fatal 造成停止的範圍 (Scope) 只在該 Test Function。

若我們希望在測試過程中只要有任何失敗發生,就要馬上停止後續的測試,則可以使用 -failfast Flag

1
2
3
4
5
6
func TestSomethingFailed(t *testing.T) {
t.Fatal("something wrong")
}

func TestSomethingSuccess(t *testing.T) {
}

執行測試觀察結果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# test without failfast flag
$ go test -v
=== RUN TestSomethingFailed
add_test.go:8: something wrong
--- FAIL: TestSomethingFailed (0.00s)
=== RUN TestSomethingSuccess
--- PASS: TestSomethingSuccess (0.00s)
FAIL
exit status 1
FAIL learning-test/add 0.097s

# test with failfast flag
$ go test -v -failfast
=== RUN TestSomethingFailed
add_test.go:8: something wrong
--- FAIL: TestSomethingFailed (0.00s)
FAIL
exit status 1
FAIL learning-test/add 0.098s

-run

go test 允許我們透過 -run Flag 以正則表達式 (Regular Expression, Regex) 指定某些 Test Function,藉此僅運行被匹配的 Test Function,而非整個 Package。

測試案例範例

1
2
3
4
5
func TestFoo(t *testing.T) {
}

func TestBar(t *testing.T) {
}

執行測試觀察結果,若沒有任何 Test Function 被 -run Flag 匹配,go test 會印出提醒 testing: warning: no tests to run

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# TestFoo matches with the regular expression Foo
$ go test -v -run=Foo
=== RUN TestFoo
--- PASS: TestFoo (0.00s)
PASS
ok learning-test/add 0.097s

# TestBar matches with the regular expression Bar
$ go test -v -run=Bar
=== RUN TestBar
--- PASS: TestBar (0.00s)
PASS
ok learning-test/add 0.280s

# there is no matching test function name
$ go test -v -run=Baz
testing: warning: no tests to run
PASS
ok learning-test/add 0.098s

Q&A

  1. Q:測試的代碼是否會被包含在 go build 產出的 Binary?

    A:When compiling packages, build ignores files that end in '_test.go'.

    Refer to "go build" documentation

  2. Q:如何測試指定的目錄下所有 Packages?

    A:go test $(go list ./... | grep directory_name)

參考文獻

  1. Add a test

  2. Testing in Go: go test

  3. Introducing the Go Race Detector

  4. The cover story

很高興能在這裡幫助到您,歡迎登入 Liker 為我鼓掌 5 次,或者成為我的讚賞公民,鼓勵我繼續創造優質文章。
以最優質的內容回應您的鼓勵