認真一點學 Go:19. 單元測試

瀟灑哥老苗發表於2021-12-13

收錄於 《Go 基礎系列》,作者:瀟灑哥老苗,原文連結

大家好,我是 “瀟灑哥老苗”。

該系列上篇講解了 《18. 併發》,今天我們學學 Go 語言中的單元測試。

依賴 Go 版本:1.16.4。

原始碼地址:

github.com/miaogaolin/gobasic

學到什麼

  1. 什麼是單元測試?

  2. 如何編寫單元測試?

  3. 什麼是程式碼覆蓋率?

  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 協議》,轉載必須註明作者和本文連結
瀟灑哥老苗

相關文章