大家好,我是 “瀟灑哥老苗”。
該系列上篇講解了 《18. 併發》,今天我們學學 Go 語言中的單元測試。
依賴 Go 版本:1.16.4。
原始碼地址:
學到什麼
什麼是單元測試?
如何編寫單元測試?
什麼是程式碼覆蓋率?
如何使用 testify 包?
引入
先不講解 “單元測試” 的概念,在不使用 “單元測試” 的情況下,我們如何測試一個函式或方法的正確性。
例如,如下函式:
// gobasic/unittest/add.go
func Add(num1, num2 int) int {
return num1 + num2
}
這個函式邏輯很簡單,只進行 num1 和 num2 兩數的相加。在實際開發中對這樣的邏輯沒必要進行單元測試,現在我們就假設這個函式邏輯很複雜,需要測試才知道對不對。
測試如下:
package main
import "fmt"
func main() {
excepted := 5
actual := Add(2, 3)
if excepted == actual {
fmt.Println("成功")
} else {
fmt.Println("失敗")
}
}
對於這樣的測試方式,它有如下問題:
測試程式碼和業務程式碼混亂、不分離;
測試完後,測試程式碼必須刪除;
如果不刪除,會參與編譯。
你可能會說,可以使用 debug 方式測試,但這樣,沒有任何測試過程,後期如果修改了程式碼,如何確定當時什麼樣的結果是正確的。
下來,引入 “單元測試” 的概念,以解決上述所說的問題。
什麼是單元測試
根據維基百科的定義,單元測試又稱為模組測試,是針對程式模組(軟體設計的最小單元)來進行正確性檢驗的測試工作。
在 Go 語言中,測試的最小單元常常是函式和方法。
測試檔案
簡單瞭解了概念後,現在就開始建立一個單元測試檔案。
在很多語言中,常常把測試檔案放在一個獨立的目錄下進行管理,而在 Go 語言中會和原始檔放置在一塊,即同一目錄下。
例如,對於上面的 Add
函式,所在檔案是 add.go
,那建立的測試檔案也和它放在一塊,如下:
unitest 目錄
add.go
add_test.go 單元測試
假如原始檔的命名是 xxx.go, 那單元測試檔案的命名則為 xxx_test.go。如果在編譯階段 xxx_test.go 檔案會被忽略。
寫單元測試
下來我們一塊在 add_test.go 檔案中給 Add 函式寫一個單元測試。
1. 基本結構
先看看基本結構,具體的測試內容沒寫,如下:
// gobasic/unittest/add_test.go
package unittest
import "testing"
func TestAdd(t *testing.T) {
// ...
}
匯入 testing 標準包;
建立一個 Test 開頭的函式名 TestAdd,Test 是固定寫法,後面的 Add 一般和你要測試的函式名對應,當然不對應也沒有問題;
引數型別
*tesing.T
用於列印測試結果,引數中也必須跟上。
所有的單元測試函式都要按照該要求定義,定義好後,下來看看如何編寫測試內容。
2. 測試內容
測試 Add 函式的計算結果是否正確。
// gobasic/unittest/add_test.go
package unittest
import "testing"
func TestAdd(t *testing.T) {
excepted := 4
actual := Add(2, 3)
if excepted != actual {
t.Errorf("excepted:%d, actual:%d", excepted, actual)
}
}
excepted 函式期待的結果;
actual 函式真實計算的結果;
如果不相等,列印出錯誤。
在 unittest 目錄下執行 go test
(或 go test ./
)命令,表示執行 unittest 目錄下的單元測試,不會再往下遞迴。如果想往下遞迴,即當前目錄下還有目錄,則執行 go test ./...
命令。
執行結果:
$ go test
--- FAIL: TestAdd (0.00s)
add_test.go:11: excepted:4, actual:5
FAIL
FAIL github.com/miaogaolin/gobasic/unittest 0.228s
FAIL
結果中看出 TestAdd 函式執行失敗,並列印出了錯誤行數 11 和 組裝的日誌。
假如你使用了 Goland 工具,直接點選下圖的紅框位置即可。
*testing.T
現在對引數型別 T 中的幾個方法展開說說,如下:
Error
列印錯誤日誌、標記為失敗 FAIL,並繼續往下執行。Errorf
格式化列印錯誤日誌、標記為失敗 FAIL,並繼續往下執行。Fail
不列印日誌,結果中只標記為失敗 FAIL,並繼續往下執行。FailNow
不列印日誌,結果中只標記為失敗 FAIL,但在當前測試函式中不繼續往下執行。Fatal
列印日誌、標記為失敗,並且內部呼叫了 FaileNow 函式,也不往下執行。Fatalf
格式化列印錯誤日誌、標記為失敗,並且內部呼叫了 FaileNow 函式,也不往下執行。
你可能發現,沒有成功的方法,不過確實也沒有,只要沒有通知錯誤,那就說明是正確的。正確的測試結果是下面這個樣子:
$ go test
ok github.com/miaogaolin/gobasic/unittest 0.244s
測試資源
有時候在你寫單元測試時,可能需要讀取檔案,那這些相關的資原始檔就放置在 testdata 目錄下。
示例:
unittest 目錄
xxx.go
xxx_test.go
testdata 目錄
go test 和 go vet
在執行 go test
命令後,go vet
命令也會自動執行。
簡單說下 go vet
命令,本篇不過多描述。它用於程式碼的靜態分析,檢查編譯器檢查不出的錯誤,例如:
// gobasic/vet/main.go
package main
import "fmt"
func main() {
fmt.Printf("%d", "miao")
}
// 輸出
%!d(string=miao)
看結果是不是很奇怪,是因為佔位符 %d 需要的是整數,但給的是字串。不熟悉佔位符的朋友,直接前往 《詳解 20 個佔位符》。
對於這種類似的錯誤,編譯器是不會報錯的,這時候就用到了 go vet
命令,執行如下:
$ go vet
# github.com/miaogaolin/gobasic/vet
.\main.go:6:2: Printf format %d has arg "miao" of wrong type string
所以在測試時無需單獨執行 go vet
命令,一個 go test
命令就包含了。
表格驅動測試
在對於一個函式或方法進行測試時,很多時候要測試多種情況,那對於多種情況如何進行測試呢?下來看看。
// gobasic/unittest/add_test.go
package unittest
import "testing"
func TestAdd1(t *testing.T) {
excepted := 5
actual := Add(2, 3)
if excepted != actual {
t.Errorf("case1:excepted:%d, actual:%d", excepted, actual)
}
excepted = 10
actual = Add(0, 10)
if excepted != actual {
t.Errorf("case2:excepted:%d, actual:%d", excepted, actual)
}
}
通過上述程式碼,我們可以看出,如果遇到多種情況時,再使用 if 語句判斷即可。你可能心裡會嘀咕: “這還用你說,不是廢話嗎!”。
下來開始我真正想說的,如果我們想要測試的情況比較多,按照上面這種寫法看起來就會很冗餘,所以我們改為下面的寫法:
// gobasic/unittest/add_test.go
package unittest
import "testing"
func TestAddTable(t *testing.T) {
type param struct {
name string
num1, num2, excepted int
}
testCases := []param{
{name: "case1", num1: 2, num2: 3, excepted: 5},
{name: "case2", num1: 0, num2: 10, excepted: 10},
}
for _, v := range testCases {
t.Run(v.name, func(t *testing.T) {
actual := Add(v.num1, v.num2)
if v.excepted != actual {
t.Errorf("excepted:%d, actual:%d", v.excepted, actual)
}
})
}
}
通過切片儲存每種想要測試的情況(測試用例),下來只需要通過迴圈判斷即可;
t.Run
方法,第一個引數是當前測試的名稱,第二個是個匿名函式,用來寫判斷邏輯。
執行結果:
$ go test add.go add_test.go -test.run TestAddTable -v
=== RUN TestAddTable
=== RUN TestAddTable/case1
=== RUN TestAddTable/case2
--- PASS: TestAddTable (0.00s)
--- PASS: TestAddTable/case1 (0.00s)
--- PASS: TestAddTable/case2 (0.00s)
PASS
ok command-line-arguments 0.041s
go test
命令後的 add.go 和 add_test.go 檔案是特意指定需要測試和依賴的檔案;-test.run
指明測試的函式名;-v
展示詳細的過程,如果不寫,測試成功時,不會列印詳細過程。
快取
當執行單元測試時,測試的結果會被快取下來。如果更改了測試程式碼或原始檔,則會重新執行測試,並再次快取。
但不是任何情況都可以快取下來,只有當 go test
命令後跟著目錄、指定的檔案或包名才可以,舉例如下:
go test ./
go test ./pkg
go test add.go add_test.go
go test fmt
如果我在 unittest 目錄下執行測試,第一次和第二的結果如下:
# 第一次
$ go test ./
ok github.com/miaogaolin/gobasic/unittest 0.228s
# 第二次
$ go test ./
ok github.com/miaogaolin/gobasic/unittest (cached)
可以看到第二次的結果中出現了 cached 字樣,如果你問 “刪掉後面的 ./
” 可以嗎?答:不可以,因為不會進行快取。
1. 禁用快取
如果想禁用快取,可以使用如下命令執行:
go test ./ -count=1
2. 其它情況
上面說過,當單元測試檔案或原始檔修改時,會重新快取。
但還有其它情況也會如此,比如當你的單元測試中涉及瞭如下情況:
讀取環境變數的內容更改
讀取檔案的內容更改
這兩種情況不會影響測試檔案和原始檔的修改,但還是會重新快取測試結果。
併發測試
為了提高多個單元測試的執行效率,我們可以採取併發測試。先看一個沒有併發的例子,如下:
func TestA(t *testing.T) {
time.Sleep(time.Second)
}
func TestB(t *testing.T) {
time.Sleep(time.Second)
}
func TestC(t *testing.T) {
time.Sleep(time.Second)
}
該例子中沒有寫任何具體的測試邏輯,只是每個函式休眠了 1s 中,目的只是演示測試的時間。
測試結果如下:
ok command-line-arguments 3.242s
可以看到總共花費了 3.242s。
下來加入併發,如下:
func TestA(t *testing.T) {
t.Parallel()
time.Sleep(time.Second)
}
func TestB(t *testing.T) {
t.Parallel()
time.Sleep(time.Second)
}
func TestC(t *testing.T) {
t.Parallel()
time.Sleep(time.Second)
}
在每個測試函式前增加了 t.Parallel()
實現併發。
測試如下:
ok command-line-arguments 1.049s
很明顯可以看到,測試的時間縮短到了 1s,大概是原來時間的三分之一。
程式碼覆蓋率
程式碼覆蓋率是一個指數,例如:20%、30% 、100% 等。
它體現了你的專案程式碼是否得到了足夠的測試,指數越大,說明測試的覆蓋情況越全面。
命令如下:
$ go test -cover
PASS
coverage: 100.0% of statements
ok github.com/miaogaolin/gobasic/unittest 1.045s
-cover
輸出覆蓋率的識別符號;覆蓋率為 100%,說明被測試的函式程式碼都有執行到,覆蓋率 = 已執行語句數 / 總語句數。
在計算覆蓋率時,還有三種模式,不同的模式在已執行語句的次數統計時存在差異性。
1. 模式 set
這是預設的模式,它的計算方式是 “如果同一語句多次執行只記錄一次”。
舉例看個例子,如下:
func GetSex(sex int) string {
if sex == 1 {
return "男"
} else {
return "女"
}
}
下來給這個函式寫個單元測試,如下:
func TestGetSex(t *testing.T) {
excepted := "男"
actual := GetSex(1)
if actual != excepted {
t.Errorf("excepted:%s, actual:%s", excepted, actual)
}
}
我就不解釋這個測試函式了,你很聰明的。
執行覆蓋率命令:
$ go test -cover
ok command-line-arguments 0.228s coverage: 66.7% of statements
這次的覆蓋率可不是 100% 了,那為啥是 66.7%,往下看。
在終端執行如下命令:
go test -coverprofile profile
執行後,會在當前目錄生成一個覆蓋率的取樣檔案 profile,開啟內容如下:
mode: set
github.com/miaogaolin/gobasic/testcover/sex.go:3.29,4.14 1 1
github.com/miaogaolin/gobasic/testcover/sex.go:4.14,6.3 1 1
github.com/miaogaolin/gobasic/testcover/sex.go:6.8,8.3 1 0
暫時先不介紹這個檔案內容細節,先使用這個檔案生成一個直觀圖,命令如下:
go tool cover -html profile
-html profile
指明將 profile 檔案在瀏覽器渲染出來,執行後會自動在瀏覽器出現如下圖:
灰色不用管,綠色的已覆蓋,紅色的未覆蓋。
下來回到 profile 檔案的內容,看圖說明:
第一行,覆蓋率模式;
剩下三行,對應下圖不同顏色的下劃線。
可得:總語句數為 3,覆蓋語句(執行語句)數為 2,計算覆蓋率為 2/3 = 66.7%。
如果想達到 100% 覆蓋,只需要增加 else 的測試情況,如下:
func TestGetSex2(t *testing.T) {
excepted := "女"
actual := GetSex(0)
if actual != excepted {
t.Errorf("excepted:%s, actual:%s", excepted, actual)
}
}
2. 模式 count
該模式和 set 模式比較相似,唯一的區別是 count 模式對於相同的語句執行次數會進行累計。
使用下面命令生成 profile 檔案:
go test -coverprofile profile -covermode count
這次測試,會將 TestGetSex 和 TestGetSex2 函式都執行,自然也會 100% 覆蓋。
profile 檔案內容:
mode: count
github.com/miaogaolin/gobasic/testcover/sex.go:3.29,4.14 1 2
github.com/miaogaolin/gobasic/testcover/sex.go:4.14,6.3 1 1
github.com/miaogaolin/gobasic/testcover/sex.go:6.8,8.3 1 1
如果再切換到 set 模式下生成,唯一不同點是,內容第二行中的最後一個數字 2 在set 模式下會是 1。
那 count 模式下為啥是 2 呢?
因為 if sex == 1
語句被執行了兩次,看下圖再說明下:
執行 TestGetSex 和 TestGetSex2 函式時,
if sex == 1
都會被執行一次,因此總共 2 次,而剩下的語句只執行了 1 次。綠色表示覆蓋率最高,下來是 low coverage 對應的顏色,表示低覆蓋率。
總結,count 模式下能看出哪些程式碼執行的次數多,而 set 模式下不能。
3. 模式 atomic
該模式和 count 類似,都是統計執行語句的次數,不同點是,在併發情況下 atomic 模式比 count 模式計數更精確。
來看一個沒啥用的併發例子,測試兩者統計的結果,如下:
// gobasic/testatomic/nums.go
package testatomic
import "sync"
func AddNumber(num int) int {
var wg sync.WaitGroup
for i := 0; i < 200; i++ {
wg.Add(1)
go func(i int) {
i += num
wg.Done()
}(i)
}
wg.Wait()
return num
}
該程式碼建立了 200 個 Goroutine,再對 200 個數併發的與 num
引數相加。
單元測試的程式碼就不寫了,只要呼叫了該函式就可以。如果想看,直接在 Github 上看完整程式碼。
count 模式下生成的 profile 檔案內容如下:
mode: count
github.com/miaogaolin/gobasic/testatomic/nums.go:5.29,8.27 2 1
github.com/miaogaolin/gobasic/testatomic/nums.go:15.2,16.12 2 1
github.com/miaogaolin/gobasic/testatomic/nums.go:8.27,10.18 2 200
github.com/miaogaolin/gobasic/testatomic/nums.go:10.18,13.4 2 199
直接看最後一行,對應到原始碼上是 Goroutine 的程式碼塊,即:go func(i int) {...}
。
199 表示的是該語句的執行次數,但迴圈次數總共是 200 次,所以是不準確的。
那再以 atomic 模式執行,命令如下:
go test -coverprofile profile -covermode atomic
profile 檔案內容如下:
mode: atomic
github.com/miaogaolin/gobasic/testatomic/nums.go:5.29,8.27 2 1
github.com/miaogaolin/gobasic/testatomic/nums.go:15.2,16.12 2 1
github.com/miaogaolin/gobasic/testatomic/nums.go:8.27,10.18 2 200
github.com/miaogaolin/gobasic/testatomic/nums.go:10.18,13.4 2 200
直接看內容的最後一個數字,這下正確了。
testify 包
當對一個專案中寫大量的單元測試時,如果按照上述的方式去寫,就會產生大量的判斷語句。
例如這樣的 if 判斷:
func TestAdd(t *testing.T) {
excepted := 4
actual := Add(2, 3)
if excepted != actual {
t.Errorf("excepted:%d, actual:%d", excepted, actual)
}
}
下來我推薦一個第三方包 testfiy,首先在終端執行如下命令,表示下載該包。
go get github.com/stretchr/testify
改寫單元測試程式碼,如下:
package unittest
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestAdd(t *testing.T) {
excepted := 4
actual := Add(2, 3)
assert.Equal(t, excepted, actual)
}
匯入 testify 包下的一個子包 assert;
使用
assert.Equal
函式簡化 if 語句和日誌列印,該函式期待 excepted 和 actual 變數相同,如果不相同會列印失敗日誌。
看看失敗是啥樣子,如下:
--- FAIL: TestAdd (0.00s)
add_test.go:11:
Error Trace: add_test.go:11
Error: Not equal:
expected: 4
actual : 5
Test: TestAdd
FAIL
FAIL command-line-arguments 0.578s
FAIL
也是列印出了期待的值和實際的值,並說明了兩值不相等。
當然該包也不只有 Equal 函式,這個學習就留給自己了,相信你可以的。
小結
本篇講解了 Go 語言中如何寫單元測試,並講了程式碼覆蓋率的 3 種統計方式,對於如何給函式和方法寫單元測試,一定要掌握。
如果在測試程式碼時發現了和我所寫的結果有出入,那可能就是版本差異。
有問題的話,隨意討論。
本作品採用《CC 協議》,轉載必須註明作者和本文連結