前言
Golang 官方提供的測試工具 (go test) 與其他工具 (cover、race detector … 等) 高度整合,讓我們僅使用一個指令即可得到 Test Coverage 數據、偵測 Race Condition 等等。此外,隨工具而來的測試框架讓 Gopher 有規則可以共同遵守,不僅提昇開發者體驗,同時降低團隊間的溝通成本。在我們深入撰寫測試案例之前,先瞭解 go test 相關功能與使用姿勢將會有一定的幫助,這麼好的工具一定要讓它發揮滿滿的作用。
Naming Conventions
Golang 測試框架有幾個命名的習慣
- 測試檔案 (Test File) 的檔案名稱皆以
_test.go
結尾,go test 可藉此知道這個檔案包含測試案例的程式碼。 - 測試函數 (Test Function) 以
Test
為開頭命名,且 go test 只會執行在 Test File 中的 Test Function。 - go test 會忽略以底線
_
或點.
開頭作為檔名的檔案,需要特別注意的是_test.go
雖然符合 Test File 檔名格式,但它會因底線開頭而被忽略。 - 名稱為
testdata
的目錄可以用來存放在測試中會使用到的測試資料。
Simple Example
使用簡單的加法函數 AddNumbers
(該函數期望收到兩個整數作為輸入參數,並回傳兩數之合) 作為範例,來學習 Golang Test Function 的基本寫法。
目錄結構的長相
1 | $ tree . |
首先,在包含 go.mod
的 Go 專案中建立兩個檔案
add.go
:實做AddNumbers
函數1
2
3
4
5package add
func AddNumbers(a, b int) int {
return a + b
}add_test.go
:撰寫AddNumbers
函數的測試案例。1
2
3
4
5
6
7
8
9package 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
。
- testing 是用來配合 go test 工具的套件,每個 Test Function 都要定義
於檔案存在的目錄 (此處為 add 目錄) 中執行 go test 指令進行測試,首先會輸出測試結果 (PASS),再印出每個 Package 測試的狀態 (分別為:成功與否、Package 名稱、測試執行時間 (Elapsed Time))
1 | $ go test |
若需要印出更多資訊,可以加上 -v
Flag
1 | $ go test -v |
讓我們刻意將 AddNumbers
的實做改為 return a * b
(從加法改為乘法) 後進行測試,因為修改後的 AddNumbers(1, 2)
回傳值是 2 而不是 3,所以測試結果為 FAIL 並印出我們提供的測試失敗訊息 result should be 3
1 | $ go test -v |
測試檔案的名稱以 _test.go
結尾是必須的,但前綴不一定需要與原檔案名稱相同。舉例來說,可以使用 foo_test.go
作為測試檔案的名稱,我們只需要確保 add.go
與 foo_test.go
放在同一個目錄下即可進行測試。
Methods of testing.T
type testing.T 提供許多有用的函數,這裡僅說明幾個常在撰寫測試案例時會用到的功能,若想知道更多或更詳細的資訊,可以查看說明文件。
t.Error VS. t.Fatal
除了使用 t.Error
將測試狀態設為失敗並印出訊息,也可以使用 t.Fatal
代替 t.Error
。兩者差異為
t.Error
:將測試狀態標記為失敗,並繼續該 Test Function 的運行。t.Fatal
:將測試狀態標記為失敗,且直接停止該 Test Function,也就是說在t.Fatal
後的程式碼將不被運行。
以兩個測試函數作為範例,分別於調用 t.Error
與 t.Fatal
後使用 t.Log
輸出訊息
1 | func TestError(t *testing.T) { |
測試結果顯示如下,可以觀察到 TestError
在執行 t.Error
後還會執行 t.Log
,而 TestFatal
在調用 t.Fatal
後就被停止運行,所以 t.Log
的訊息沒有被輸出
1 | $ go test -v |
我們可以根據情境自行選擇 t.Error
或者 t.Fatal
來滿足使用需求。
t.Skip
當我們想要略過某個 Test Function 時,可以使用 t.Skip
來達成
1 | func TestSomething(t *testing.T) { |
執行測試觀察結果,注意到此時 TestSomething
被標注為 Skip,同時 t.Skip
的輸入參數輸也會作為 Log 資訊印在測試報告中
1 | go test -v |
t.Run
在單個 Test Function 中,針對同個待測項 (Function、Class ⋯⋯ 等等) 寫多個測試案例 (例如:針對一個函數撰寫多個測試案例,每個測試案例的輸入參數不同) 是一種常見的情境,可以透過使用 t.Run
來達成撰寫子測試 (Subtest)
1 | func TestSomething(t *testing.T) { |
t.Run
:第一個參數為 Subtest 的名稱;第二個參數為 Subtest 本身的測試案例撰寫。
執行測試觀察結果,go test 會將 Subtest 以階層的方式展示於對應的 Test Function 底下,清楚地顯示每個 Subtest 的狀態
1 | go test -v |
assert 與 require 套件
套件解決的問題
雖然可以使用 if
搭配 t.Error
或 t.Fatal
來撰寫測試案例,但同時衍生出幾個問題
大量的樣板代碼 (Boilerplate Code):
Boilerplate Code 即具有固定模式的代碼,例如:在測試代碼中先用
if
再使用t.Error
是判斷測試失敗與否的 Boilerplate Code,如下所示1
2
3if some conditions {
t.Error("reason for failure")
}這樣的寫法本身沒有問題,但當我們因測試所需,一而再,再而三的寫出這樣的 Boilerplate Code 時,也許會想:「每次都要寫這幾行,好令人煩躁。」或者在看大量測試案例時會想:「好多
if
,到底想表達什麼?」需要重複寫類似的測試失敗訊息:
測試失敗訊息很重要,但並不是每個測試失敗訊息都很有用,我們可以從測試失敗訊息
result should be 3
知道實際值應該等於 3,但我們無法從訊息中得知實際值是多少,若將輸出訊息修改為result is 2, but should be 3
便能清楚知道測試失敗當下的實際值與期望值。最終,測試失敗訊息都會由相似語句「result is
x
, but should bey
」組成 (也因為這樣的句子具有固定模式,所以算是 Boilerplate Code),但我們僅是想知道實際值x
與期望值y
的對應,卻要在撰寫測試案例時,寫一堆文字組出一個句子,以防未來的自己或同事看不懂。
第三方套件 github.com/stretchr/testify
應運而生,包含於其中的 assert
與 require
兩個套件可用來解決撰寫測試案例時會遇到大量 Boilerplate Code 與重複撰寫測試失敗訊息的問題。
套件的使用方法
assert
與 require
都存在於 testify
套件底下,因此使用前需要下載 testify
1 | go get github.com/stretchr/testify |
使用 assert
撰寫測試案例如下
1 | import ( |
assert.Equal(t, 1, 2)
:相信在還沒閱讀此函數的說明文件前,讀者能從函數名看出要比對的條件是「相等」,並從輸入參數知道比對的對象是 1 與 2。提升可讀性的同時,將我們從重工地獄釋放出來,再也不需寫 Boilerplate Code (if
+t.Error
)。
執行測試觀察結果,assert
於測試失敗所提供的訊息非常齊全,不只說明了失敗原因 (Not equal
),也記錄當下的期望值 (expected: 1
) 與實際值 (actual : 2
),可謂一目了然!
1 | $ go test -v |
assert 套件除了 Equal
外,還有很多函數可以使用,例如:NotNil
、True
、WithinDuration
⋯⋯ 等等,幾乎每種測試條件都有對應的函數,建議使用 if
+ t.Error
之前先試著尋找是否有可取代 Boilerplate Code 的函數。
assert VS. require
兩種套件整體功能都相同,差別在於是否會停止 Test Function 的運行
assert
:將測試狀態標記為失敗,並繼續該 Test Function 的運行。require
:將測試狀態標記為失敗,且直接停止該 Test Function,也就是說在require
後的程式碼將不被運行。
有沒有似曾相識的感覺?沒錯,就是和 t.Error
與 t.Fatal
的差異相同!同樣的,沒有一定要使用 assert
或者 require
,全看測試案例的情境做選擇。
Testing Mode
go test 有兩種不同運行模式,分別為 Local Directory Mode 與 Package List Mode。
Local Directory Mode
沒有指定要測試的 Package,即為 Local Directory Mode,例如:go test
、go 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 | # first test |
雖然 Cache 機制聽起來不錯,但不希望由 Cache 取得測試結果反而是比較常見的情境,在這樣的情況下,我們想要每次都是重新執行測試以獲得最新測試報告。有兩種做法能達到這樣的效果
使用 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.119sgo 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 | func TestSomething(t *testing.T) { |
執行測試觀察結果
1 | # test without short flag |
-timeout
go test 預設的逾時時間 (Timeout) 為 10 分鐘,也就是說,如果測試運行的整體時間超過 10 分鐘,go test 會直接終止測試並標記為失敗。若已知測試不需要這麼多時間去運行,可以使用 -timeout
修改逾時時間為合理值,例如 30 秒
1 | go test -timeout=30s |
-count
-count
讓我們得以指定要執行幾次測試,若我們的測試有隨機的成分 (例如:輸入參數是隨機產生),透過加大 count 可以提昇因隨機數值而導致測試失敗的可能性,提早發現存在的問題
1 | # test one time |
除此之外,-count
最常用來取消觸發 Package List Mode 的 Cache 機制,詳細說明可以查看 Package List Mode 一節。
-failfast
想必讀者已經清楚當 t.Fatal
被執行時,該 Test Function 後續的程式碼並不會被執行,也就是說 t.Fatal
造成停止的範圍 (Scope) 只在該 Test Function。
若我們希望在測試過程中只要有任何失敗發生,就要馬上停止後續的測試,則可以使用 -failfast
Flag
1 | func TestSomethingFailed(t *testing.T) { |
執行測試觀察結果
1 | # test without failfast flag |
-run
go test 允許我們透過 -run
Flag 以正則表達式 (Regular Expression, Regex) 指定某些 Test Function,藉此僅運行被匹配的 Test Function,而非整個 Package。
測試案例範例
1 | func TestFoo(t *testing.T) { |
執行測試觀察結果,若沒有任何 Test Function 被 -run
Flag 匹配,go test 會印出提醒 testing: warning: no tests to run
1 | # TestFoo matches with the regular expression Foo |
Q&A
Q:測試的代碼是否會被包含在
go build
產出的 Binary?A:When compiling packages, build ignores files that end in '_test.go'.
Q:如何測試指定的目錄下所有 Packages?
A:
go test $(go list ./... | grep directory_name)