一個測試工程師走進一家酒吧……

一個測試工程師走進一家酒吧,要了一杯啤酒;

一個測試工程師走進一家酒吧,要了一杯咖啡;

一個測試工程師走進一家酒吧,要了 0。7 杯啤酒;

一個測試工程師走進一家酒吧,要了-1 杯啤酒;

一個測試工程師走進一家酒吧,要了 2^32 杯啤酒;

一個測試工程師走進一家酒吧,要了一杯洗腳水;

一個測試工程師走進一家酒吧,要了一杯蜥蜴;

一個測試工程師走進一家酒吧,要了一份 asdfQwer@24dg!&*(@;

一個測試工程師走進一家酒吧,什麼也沒要;

一個測試工程師走進一家酒吧,又走出去又從窗戶進來又從後門出去從下水道鑽進來;

一個測試工程師走進一家酒吧,又走出去又進來又出去又進來又出去,最後在外面把老闆打了一頓;

一個測試工程師走進一家酒吧,要了一杯燙燙燙的錕斤拷;

一個測試工程師走進一家酒吧,要了 NaN 杯 Null;

一個測試工程師衝進一家酒吧,要了 500T 啤酒咖啡洗腳水野貓狼牙棒奶茶;

一個測試工程師把酒吧拆了;

一個測試工程師化裝成老闆走進一家酒吧,要了 500 杯啤酒並且不付錢;

一萬個測試工程師在酒吧門外呼嘯而過;

一個測試工程師走進一家酒吧,要了一杯啤酒‘;DROP TABLE 酒吧;

測試工程師們滿意地離開了酒吧。

然後一名顧客點了一份炒飯,酒吧炸了。

上面是網上流行的一個關於測試的笑話,其主要核心思想是——你永遠無法把所有問題都充分測試。

在軟體工程中,測試是極其重要的一環,比重通常可以與編碼相同,甚至大大超過。那麼在 Golang 裡,怎麼樣把測試寫好,寫正確?本文將對這個問題做一些簡單的介紹。 當前文章將主要分兩個部分:

Golang 測試的一些基本寫法和工具

如何寫“正確”的測試,這個部分雖然程式碼是用 golang 編寫,但是其核心思想不限語言

由於篇幅問題,本文將不涉及效能測試,之後會另起一篇來談。

為什麼要寫測試

我們舉個不太恰當的例子,測試也是程式碼,我們假定寫程式碼時出現 bug 的機率是 p(0

P(程式碼出現 bug) * P(測試出現 Bug) = p^2 < p

例如 p 是 1%的話,那麼同時寫出現 bug 的機率就只有 0。01%了。

測試同樣也是程式碼,有可能也寫出 bug,那麼怎麼保證測試的正確性呢?給測試也寫測試?給測試的測試繼續寫測試?

我們定義 t(0)為原始的程式碼,任意的 i,i > 0,t(i+1)為對於 t(i)的測試,t(i+1)正確為 t(i)正確的必要條件,那麼對所有的 i,i>0,t(i)正確都是 t(0)正確的必要條件。。。

測試的種類

測試的種類有非常多,我們這裡只挑幾個對一般開發者來說比較重要的測試,做簡略的說明。

白盒測試、黑盒測試

首先是從測試方法上可以分為白盒測試和黑盒測試(當然還存在所謂的灰盒測試,這裡不討論)

白盒測試 (White-box testing):白盒測試又稱透明盒測試、結構測試等,軟體測試的主要方法之一,也稱結構測試、邏輯驅動測試或基於程式本身的測試。測試應用程式的內部結構或運作,而不是測試應用程式的功能。在白盒測試時,以程式語言的角度來設計測試案例。測試者輸入資料驗證資料流在程式中的流動路徑,並確定適當的輸出,類似測試電路中的節點。

黑盒測試 (Black-box testing):黑盒測試,軟體測試的主要方法之一,也可以稱為功能測試、資料驅動測試或基於規格說明的測試。測試者不瞭解程式的內部情況,不需具備應用程式的程式碼、內部結構和程式語言的專門知識。只知道程式的輸入、輸出和系統的功能,這是從使用者的角度針對軟體介面、功能及外部結構進行測試,而不考慮程式內部邏輯結構。

我們寫的單元測試一般屬於白盒測試,因為我們對測試物件的內部邏輯有著充分了解。

單元測試、整合測試

從測試的維度上,又可以分為單元測試和整合測試:

在計算機程式設計中,單元測試又稱為模組測試,是針對程式模組來進行正確性檢驗的測試工作。程式單元是應用的最小可測試部件。在過程化程式設計中,一個單元就是單個程式、函式、過程等;對於面向物件程式設計,最小單元就是方法,包括基類、抽象類、或者派生類中的方法。

整合測試又稱組裝測試,即對程式模組採用一次性或增值方式組裝起來,對系統的介面進行正確性檢驗的測試工作。整合測試一般在單元測試之後、系統測試之前進行。實踐表明,有時模組雖然可以單獨工作,但是並不能保證組裝起來也可以同時工作。

單元測試可以是黑盒測試,整合測試亦可以是白盒測試

迴歸測試

迴歸測試是軟體測試的一種,旨在檢驗軟體原有功能在修改後是否保持完整。

迴歸測試主要是希望維持軟體的不變性,我們舉一個例子來說明。例如我們發現軟體在執行的過程中出現了問題,在 gitlab 上開啟了一個 issue。之後我們並且定位到了問題,我們可以先寫一個測試(測試的名稱可以帶上 issue 的 ID)來複現問題(該版本程式碼執行此測試結果失敗)。之後我們修復問題後,再次執行測試,測試的結果應當成功。那麼我們之後每次執行測試的時候,透過執行這個測試,可以保證同樣的問題不會復現。

一個基本的測試

我們先來看一個 Golang 的程式碼:

// add。gopackage addfunc Add(a, b int) int {   return a + b}

一個測試用例可以寫成:

// add_test。gopackage addimport (   “testing”)func TestAdd(t *testing。T) {   res := Add(1, 2)   if res != 3 {      t。Errorf(“the result is %d instead of 3”, res)   }}

在命令列我們使用 go test

go test

這個時候 go 會執行該目錄下所有的以_test。go 為字尾中的測試,測試成功的話會有如下輸出:

% go testPASSok      code。byted。org/ek/demo_test/t01_basic/correct       0。015s

假設這個時候我們把 Add 函式修改成錯誤的實現

// add。gopackage addfunc Add(a, b int) int {   return a - b}

再次執行測試命令

% go test——- FAIL: TestAddWrong (0。00s)    add_test。go:11: the result is -1 instead of 3FAILexit status 1FAIL    code。byted。org/ek/demo_test/t01_basic/wrong 0。006s

會發現測試失敗。

只執行一個測試檔案

那麼如果我們想只測試這一個檔案,輸入

go test add_test。go

會發現命令列輸出

% go test add_test。go# command-line-arguments [command-line-arguments。test]。/add_test。go:9:9: undefined: AddFAIL    command-line-arguments [build failed]FAIL

這是因為我們沒有附帶測試物件的程式碼,修改測試後可以獲得正確的輸出:

% go test add_test。go add。gook      command-line-arguments  0。007s

測試的幾種書寫方式

子測試

通常來說我們測試某個函式和方法,可能需要測試很多不同的 case 或者邊際條件,例如我們為上面的 Add 函式寫兩個測試,可以寫成:

// add_test。gopackage addimport (   “testing”)func TestAdd(t *testing。T) {   res := Add(1, 0)   if res != 1 {      t。Errorf(“the result is %d instead of 1”, res)   }}func TestAdd2(t *testing。T) {   res := Add(0, 1)   if res != 1 {      t。Errorf(“the result is %d instead of 1”, res)   }}

測試的結果:(使用-v 可以獲得更多輸出)

% go test -v=== RUN   TestAdd——- PASS: TestAdd (0。00s)=== RUN   TestAdd2——- PASS: TestAdd2 (0。00s)PASSok      code。byted。org/ek/demo_test/t02_subtest/non_subtest     0。007s

另一種寫法是寫成子測試的形式

// add_test。gopackage addimport (   “testing”)func TestAdd(t *testing。T) {   t。Run(“test1”, func(t *testing。T) {      res := Add(1, 0)      if res != 1 {         t。Errorf(“the result is %d instead of 1”, res)      }   })   t。Run(“”, func(t *testing。T) {      res := Add(0, 1)      if res != 1 {         t。Errorf(“the result is %d instead of 1”, res)      }   })}

執行結果:

% go test -v=== RUN   TestAdd=== RUN   TestAdd/test1=== RUN   TestAdd/#00——- PASS: TestAdd (0。00s)    ——- PASS: TestAdd/test1 (0。00s)    ——- PASS: TestAdd/#00 (0。00s)PASSok      code。byted。org/ek/demo_test/t02_subtest/subtest 0。007s

可以看到輸出中會將測試按照巢狀的結構分類,子測試的巢狀沒有層數限制,如果不寫測試名的話,會自動按照順序給予序號作為其測試名(例如上面的#00)

對 IDE(Goland)友好的子測試

有一種測試的寫法是:

tcList := map[string][]int{   “t1”: {1, 2, 3},   “t2”: {4, 5, 9},}for name, tc := range tcList {   t。Run(name, func(t *testing。T) {      require。Equal(t, tc[2], Add(tc[0], tc[1]))   })}

看上去沒什麼問題,然而有一個缺點是,這個測試對 IDE 並不友好:

一個測試工程師走進一家酒吧……

我們無法在出錯的時候對單個測試重新執行 所以推薦儘可能對每個 t。Run 都要獨立書寫,例如:

f := func(a, b, exp int) func(t *testing。T) {   return func(t *testing。T) {      require。Equal(t, exp, Add(a, b))   }}t。Run(“t1”, f(1, 2, 3))t。Run(“t2”, f(4, 5, 9))

一個測試工程師走進一家酒吧……

測試分包

我們上面的 add。go 和 add_test。go 檔案都處於同一個目錄下,頂部的 package 名稱都是 add,那麼在寫測試的過程中,也可以為測試啟用與非測試檔案不同的包名,例如我們現在將測試檔案的包名改為 add_test:

// add_test。gopackage add_testimport (   “testing”)func TestAdd(t *testing。T) {   res := Add(1, 2)   if res != 3 {      t。Errorf(“the result is %d instead of 3”, res)   }}

這個時候執行 go test 會發現

% go test# code。byted。org/ek/demo_test/t03_diffpkg_test [code。byted。org/ek/demo_test/t03_diffpkg。test]。/add_test。go:9:9: undefined: AddFAIL    code。byted。org/ek/demo_test/t03_diffpkg [build failed]

由於包名變化了,我們無法再訪問到 Add 函式,這個時候我們增加 import 即可:

// add_test。gopackage add_testimport (   “testing”   。 “code。byted。org/ek/demo_test/t03_diffpkg”)func TestAdd(t *testing。T) {   res := Add(1, 2)   if res != 3 {      t。Errorf(“the result is %d instead of 3”, res)   }}

我們使用上面的方式來匯入包內的函式即可。 但使用了這種方式後,將無法訪問包內未匯出的函式(以小寫開頭的)。

測試的工具庫

github。com/stretchr/testify

我們可以使用強大的 testify 來方便我們寫測試 例如上面的測試我們可以用這個庫寫成:

// add_test。gopackage correctimport (   “testing”   “github。com/stretchr/testify/require”)func TestAdd(t *testing。T) {   res := Add(1, 2)   require。Equal(t, 3, res)   /*    must := require。New(t)    res := Add(1, 2)    must。Equal(3, res)    */}

如果執行失敗,則會在命令列看到如下輸出:

% go testok      code。byted。org/ek/demo_test/t04_libraries/testify/correct       0。008s——- FAIL: TestAdd (0。00s)    add_test。go:12:                Error Trace:    add_test。go:12                Error:          Not equal:                                expected: 3                                actual  : -1                Test:           TestAddFAILFAIL    code。byted。org/ek/demo_test/t04_libraries/testify/wrong 0。009sFAIL

庫提供了格式化的錯誤詳情(堆疊、錯誤值、期望值等)來方便我們除錯。

github。com/DATA-DOG/go-sqlmock

對於需要測試 sql 的地方可以使用 go-sqlmock 來測試

優點:不需要依賴資料庫

缺點:脫離了資料庫的具體實現,所以需要寫比較複雜的測試程式碼

github。com/golang/mock

強大的對 interface 的 mock 庫,例如我們要測試函式 ioutil。ReadAll

func ReadAll(r io。Reader) ([]byte, error)

我們 mock 一個 io。Reader

// package: 輸出包名// destination: 輸出檔案// io: mock物件的包// Reader: mock物件的interface名mockgen -package gomock -destination mock_test。go io Reader

可以在目錄下看到 mock_test。go 檔案裡,包含了一個 io。Reader 的 mock 實現 我們可以使用這個實現去測試 ioutil。Reader,例如

ctrl := gomock。NewController(t)defer ctrl。Finish()m := NewMockReader(ctrl)m。EXPECT()。Read(gomock。Any())。Return(0, errors。New(“error”))_, err := ioutil。ReadAll(m)require。Error(t, err)

net/http/httptest

通常我們測試服務端程式碼的時候,會先啟動服務,再啟動測試。官方的 httptest 包給我們提供了一種方便地啟動一個服務例項來測試的方法。

其他

其他一些測試工具可以前往 awesome-go#testing 查詢

https://github。com/avelino/awesome-go#testing

如何寫好測試

上面介紹了測試的基本工具和寫法,我們已經完成了“必先利其器”,下面我們將介紹如何“善其事”。

併發測試

在平時,大家寫服務的時候,基本都必須考慮併發,我們使用 IDE 測試的時候,IDE 預設情況下並不會主動測試併發狀態,那麼如何保證我們寫出來的程式碼是併發安全的? 我們來舉個例子,比如我們有個計數器,作用就是計數。

type Counter int32func (c *Counter) Incr() {   *c++}

很顯然這個計數器在併發情況下是不安全的,那麼我們如何寫一個測試來做這個計數器的併發測試呢?

import (   “sync”   “testing”   “github。com/stretchr/testify/require”)func TestA_Incr(t *testing。T) {   var a Counter   eg := sync。WaitGroup{}   count := 10   eg。Add(count)   for i := 0; i < count; i++ {      go func() {         defer eg。Done()         a。Incr()      }()   }   eg。Wait()   require。Equal(t, count, int(a))}

透過多次執行上面的測試,我們發現有些時候,測試的結果返回 OK,有些時候測試的結果返回 FAIL。也就是說,即便寫了測試,有可能在某次測試中被標記為透過測試。那麼有沒有什麼辦法直接發現問題呢?答案就是在測試的時候增加-race 的 flag

-race 標誌不適合 benchmark 測試

go test -race

這時候終端會輸出:

WARNING: DATA RACERead at 0x00c00001ca50 by goroutine 9:  code。byted。org/ek/demo_test/t05_race/race。(*A)。Incr()      /Users/bytedance/go/src/code。byted。org/ek/demo_test/t05_race/race/race。go:6 +0x6f  code。byted。org/ek/demo_test/t05_race/race。TestA_Incr。func1()      /Users/bytedance/go/src/code。byted。org/ek/demo_test/t05_race/race/race_test。go:18 +0x66Previous write at 0x00c00001ca50 by goroutine 8:  code。byted。org/ek/demo_test/t05_race/race。(*A)。Incr()      /Users/bytedance/go/src/code。byted。org/ek/demo_test/t05_race/race/race。go:6 +0x85  code。byted。org/ek/demo_test/t05_race/race。TestA_Incr。func1()      /Users/bytedance/go/src/code。byted。org/ek/demo_test/t05_race/race/race_test。go:18 +0x66Goroutine 9 (running) created at:  code。byted。org/ek/demo_test/t05_race/race。TestA_Incr()      /Users/bytedance/go/src/code。byted。org/ek/demo_test/t05_race/race/race_test。go:16 +0xe4  testing。tRunner()      /usr/local/Cellar/go/1。15/libexec/src/testing/testing。go:1108 +0x202Goroutine 8 (finished) created at:  code。byted。org/ek/demo_test/t05_race/race。TestA_Incr()      /Users/bytedance/go/src/code。byted。org/ek/demo_test/t05_race/race/race_test。go:16 +0xe4  testing。tRunner()      /usr/local/Cellar/go/1。15/libexec/src/testing/testing。go:1108 +0x202

go 主動提示,我們的程式碼中發現了競爭(race)態,這個時候我們就要去修復程式碼

type Counter int32func (c *Counter) Incr() {   atomic。AddInt32((*int32)(c), 1)}

修復完成後再次伴隨-race 進行測試,我們的測試成功透過!

Golang 原生的併發測試

golang 的測試類 testing。T 有一個方法 Parallel(),所有在測試中呼叫了該方法的都會被標記為併發,但是注意,如果需要使用併發測試的結果的話,必須在外層用一個額外的測試函式將其包住:

func TestA_Incr(t *testing。T) {   var a Counter   t。Run(“outer”, func(t *testing。T) {      for i := 0; i < 100; i++ {         t。Run(“inner”, func(t *testing。T) {            t。Parallel()            a。Incr()         })      }   })   t。Log(a)}

如果沒有第三行的 t。Run,那麼 11 行的列印結果將不正確

Golang 的 testing。T 還有很多別的實用方法,大家可以自己去檢視一下,這裡不詳細討論

正確測試返回值

作為一個 gopher 平時要寫大量的 if err != nil,那麼在測試一個函式返回的 error 的時候,我們比如有下面的例子

type I interface {   Foo() error}func Bar(i1, i2 I) error {   i1。Foo()   return i2。Foo()}

Bar 函式希望依次處理 i1 和 i2 兩個輸入,當遇到第一個錯誤就返回,於是我們寫了一個看起來“正確”的測試

import (   “errors”   “testing”   “github。com/stretchr/testify/require”)type impl stringfunc (i impl) Foo() error {   return errors。New(string(i))}func TestBar(t *testing。T) {   i1 := impl(“i1”)   i2 := impl(“i2”)   err := Bar(i1, i2)   require。Error(t, err) // assert err != nil}

這個測試結果“看起來”很完美,函式正確返回了一個錯誤。但是實際上我們知道這個函式的返回值是錯誤的,所以我們應當把測試稍作修改,將 error 當作一個返回值來校驗起內容,而不是簡單的判 nil 處理

func TestBarFixed(t *testing。T) {   i1 := impl(“i1”)   i2 := impl(“i2”)   err := Bar(i1, i2)   // 兩種寫法都可   require。Equal(t, errors。New(“i1”), err)   require。Equal(t, “i1”, err。Error())}

這個時候我們就能發現到,程式碼中出現了錯誤,需要修復了。 同理可以應用到別的返回值,我們不應當僅僅做一些簡單的判斷,而應當儘可能做“精確值”的判斷。

測試輸入引數

上面我們討論過了測試返回值,輸入值同樣需要測試,這一點我們主要結合 gomock 來說,舉個例子我們的程式碼如下:

type I interface {   Foo(ctx context。Context, i int) (int, error)}type bar struct {   i I}func (b bar) Bar(ctx context。Context, i int) (int, error) {   i, err := b。i。Foo(context。Background(), i)   return i + 1, err}

我們想要測試 bar 類是否正確在方法中呼叫了 Foo 方法 我們使用 gomock 來 mock 出我們想要的 I 介面的 mock 實現:

mockgen -package gomock -destination mock_test。go io Reader

接下來我們寫了一個測試:

import (   “context”   “testing”   。 “code。byted。org/ek/testutil/testcase”   “github。com/stretchr/testify/require”)func TestBar(t *testing。T) {   t。Run(“test”, TF(func(must *require。Assertions, tc *TC) {      impl := NewMockI(tc。GomockCtrl)      i := 10      j := 11      ctx := context。Background()      impl。EXPECT()。Foo(ctx, i)。         Return(j, nil)      b := bar{i: impl}      r, err := b。Bar(ctx, i)      must。NoError(err)      must。Equal(j+1, r)   }))}

測試執行成功,但實際上我們看了程式碼發現,程式碼中的 context 並沒有被正確的傳遞,那麼我們應該怎麼去正確測試出這個情況呢? 一種辦法是寫一個差不多的測試,測試中修改 context。Background()為別的 context:

t。Run(“correct”, TF(func(must *require。Assertions, tc *TC) {   impl := NewMockI(tc。GomockCtrl)   i := 10   j := 11   ctx := context。WithValue(context。TODO(), “k”, “v”)   impl。EXPECT()。Foo(ctx, i)。      Return(j, nil)   b := bar{i: impl}   r, err := b。Bar(ctx, i)   must。NoError(err)   must。Equal(j+1, r)}))

另一種辦法是加入隨機測試要素。

為測試加入隨機要素

同樣是上面的測試,我們稍做修改

import (   “context”   “testing”   randTest “code。byted。org/ek/testutil/rand”   。 “code。byted。org/ek/testutil/testcase”   “github。com/stretchr/testify/require”)t。Run(“correct”, TF(func(must *require。Assertions, tc *TC) {   impl := NewMockI(tc。GomockCtrl)   i := 10   j := 11   ctx := context。WithValue(context。TODO(), randTest。String(), randTest。String())   impl。EXPECT()。Foo(ctx, i)。      Return(j, nil)   b := bar{i: impl}   r, err := b。Bar(ctx, i)   must。NoError(err)   must。Equal(j+1, r)}))

這樣就可以很大程度上避免由於固定的測試變數,導致的一些邊緣 case 容易被誤測為正確,如果回到之前的 Add 函式的例子,可以寫成

import (   “math/rand”   “testing”   “github。com/stretchr/testify/require”)func TestAdd(t *testing。T) {   a := rand。Int()   b := rand。Int()   res := Add(a, b)   require。Equal(t, a+b, res)}

經過修改的入參

如果我們修改一下之前的 Bar 的例子

func (b bar) Bar(ctx context。Context, i int) (int, error) {   ctx = context。WithValue(ctx, “v”, i)   i, err := b。i。Foo(ctx, i)   return i + 1, err}

函式基本相同,只是傳遞給 Foo 方法的 ctx 變成了一個子 context,這個時候之前的測試就無法正確執行了,那麼如何來判斷傳遞的 context 是最上層的 context 的一個子 context 呢?

透過手寫實現判斷

一個方法是在測試中,傳遞給 Bar 一個 context。WithValue,然後在 Foo 的實現中去判斷收到的 context 是否帶有特定的 kv

t。Run(“correct”, TF(func(must *require。Assertions, tc *TC) {   impl := NewMockI(tc。GomockCtrl)   i := 10   j := 11   k := randTest。String()   v := randTest。String()   ctx := context。WithValue(context。TODO(), k, v)   impl。EXPECT()。Foo(gomock。Any(), i)。      Do(func(ctx context。Context, i int) {         s, _ := ctx。Value(k)。(string)         must。Equal(v, s)      })。      Return(j, nil)   b := bar{i: impl}   r, err := b。Bar(ctx, i)   must。NoError(err)   must。Equal(j+1, r)}))

gomock。Matcher

還有一種方法是實現 gomock。Matcher 這個 interface

import (    randTest “code。byted。org/ek/testutil/rand”)t。Run(“simple”, TF(func(must *require。Assertions, tc *TC) {   impl := NewMockI(tc。GomockCtrl)   i := 10   j := 11   ctx := randTest。Context()   impl。EXPECT()。Foo(ctx, i)。      Return(j, nil)   b := bar{i: impl}   r, err := b。Bar(ctx, i)   must。NoError(err)   must。Equal(j+1, r)}))

randTest。Context 的主要程式碼如下:

func (ctx randomContext) Matches(x interface{}) bool {   switch v := x。(type) {   case context。Context:      return v。Value(ctx) == ctx。value   default:      return false   }}

gomock 會自動利用這個介面來判斷輸入引數的匹配情況。

測試含有很多子呼叫的函式

我們來看下面的函式:

func foo(i int) (int, error) {   if i < 0 {      return 0, errors。New(“negative”)   }   return i + 1, nil}func Bar(i, j int) (int, error) {   i, err := foo(i)   if err != nil {      return 0, err   }   j, err = foo(j)   if err != nil {      return 0, err   }   return i + j, nil}

這裡的邏輯看起來比較簡單,但是如果我們想象 Bar 的邏輯和 foo 的邏輯都非常複雜,也包含比較多的邏輯分支,那麼測試的時候會遇到兩個問題

測試 Bar 函式的時候可能需要考慮各種 foo 函式返回值的情況,需要根據 foo 的需求特別構造入參

可能需要大量重複測試到 foo 的場景,與 foo 本身的測試重複

那麼如何解決這個問題?我這裡給大家提供一個思路,雖然可能不是最優解。有更好解法的希望能夠在評論區提出。 我的思路是將 foo 函式從固定的函式變成一個可變的函式指標,可以在測試的時候被動態替換

var foo = func(i int) (int, error) {   if i < 0 {      return 0, errors。New(“negative”)   }   return i + 1, nil}func Bar(i, j int) (int, error) {   i, err := foo(i)   if err != nil {      return 0, err   }   j, err = foo(j)   if err != nil {      return 0, err   }   return i + j, nil}

於是在測試 Bar 的時候,我們可以替換 foo:

func TestBar(t *testing。T) {   f := func(newFoo func(i int) (int, error), cb func()) {      old := foo      defer func() {         foo = old      }()      foo = newFoo      cb()   }   t。Run(“first error”, TF(func(must *require。Assertions, tc *TC) {      expErr := randTest。Error()      f(func(i int) (int, error) {         return 0, expErr      }, func() {         _, err := Bar(1, 2)         must。Equal(expErr, err)      })   }))   t。Run(“second error”, TF(func(must *require。Assertions, tc *TC) {      expErr := randTest。Error()      first := true      f(func(i int) (int, error) {         if first {            first = false            return 0, nil         }         return 0, expErr      }, func() {         _, err := Bar(1, 2)         must。Equal(expErr, err)      })   }))   t。Run(“success”, TF(func(must *require。Assertions, tc *TC) {      f(func(i int) (int, error) {         return i, nil      }, func() {         r, err := Bar(1, 2)         must。NoError(err)         must。Equal(3, r)      })   }))}

上面的寫法就可以單獨分別測試 foo 和 Bar 了

使用了這個方法後可能需要多寫比較多的 mock 相關的程式碼(這個部分可以考慮搭配使用 gomock)

這個方法在做併發的測試時候,需要考慮到你 mock 的函式對併發的處理是否正確

這個測試總體上正確的必要條件是 foo 函式的測試正確,並且 foo 函式的 mock 也與正確的 foo 函式的行為一致,所以必要時還是需要額外書寫不 mock foo 函式的總體測試

測試的覆蓋率

寫測試的時候,我們經常會提到一個詞,覆蓋率。那麼什麼是測試覆蓋率呢?

測試覆蓋率是在軟體測試或是軟體工程中的軟體度量,表示軟體程式中被測試到的比例。覆蓋率是一種判斷測試嚴謹程度的方式。有許多不同種類的測試覆蓋率: 程式碼覆蓋率 特徵覆蓋率 情景覆蓋率 螢幕專案覆蓋率 模組覆蓋率 每一種覆蓋率都會假設待測系統已有存在形態基準。因此當系統有變化時,測試覆蓋率也會隨之改變。

一般情況下,我們可以認為,測試覆蓋率越高,我們測試覆蓋的情況越全面,測試的有效性就越高。

Golang 的測試覆蓋率

在 golang 中,我們透過附加-cover 標誌,在測試程式碼的同時,測試其覆蓋率

% go test -coverPASScoverage: 100。0% of statementsok      code。byted。org/ek/demo_test/t10_coverage        0。008s

我們可以看到當前測試覆蓋率為 100%。

100%測試覆蓋率不等於正確的測試

測試覆蓋率越高不等於測試正確,我們分幾種情況分別舉例。

並沒有正確測試輸入輸出

這個在上面已經有所提及,可以參考上面“正確測試返回值”的例子,在例子中,測試覆蓋率達到了 100%,但是並沒有正確測試出程式碼的問題。

並沒有覆蓋到所有分支邏輯

func AddIfBothPositive(i, j int) int {   if i > 0 && j > 0 {      i += j   }   return i}

下面的測試用例覆蓋率達到了 100%,但是並沒有測試到所有的分支

func TestAdd(t *testing。T) {   res := AddIfBothPositive(1, 2)   require。Equal(t, 3, res)}

並沒有處理異常/邊界條件

func Divide(i, j int) int {   return i / j}

Divide 函式並沒有處理除數為 0 的情況,而單元測試的覆蓋率是 100%

func TestAdd(t *testing。T) {   res := Divide(6, 2)   require。Equal(t, 3, res)}

上面的例子說明 100%的測試覆蓋並不是真的“100%覆蓋”了所有的程式碼執行情況。

覆蓋率的統計方法

測試覆蓋率的統計方法一般是: 測試中執行到的程式碼行數 / 測試的程式碼的總行數 然而程式碼在實際執行中,每一行執行到的機率、出錯的嚴重程度等等也是不同的,所以我們在追求高覆蓋率的同時,不能迷信覆蓋率。

測試是不怕重複書寫的

這裡的重複書寫,可以一定程度上認為是“程式碼複用”的反義詞。我們主要從下面的幾方面來說。

重複書寫類似的測試用例

測試用例只要不是完全一致,那麼即便是比較雷同的測試用例,我們都可以認為是有意義的,沒有必要為了程式碼的精簡特地刪除,例如我們測試上面的 Add 函式

func TestAdd(t *testing。T) {   t。Run(“fixed”, func(t *testing。T) {      res := Add(1, 2)      require。Equal(t, 3, res)   })   t。Run(“random”, func(t *testing。T) {      a := rand。Int()      b := rand。Int()      res := Add(a, b)      require。Equal(t, a+b, res)   })}

雖然第二個測試看起來覆蓋了第一個測試,但沒有必要去特地刪除第一個測試,越多的測試越能增加我們程式碼的可靠性。

重複書寫(源)程式碼中的定義和邏輯

比如我們有一份程式碼

package addconst Value = 3func AddInternalValue(a int) int {   return a + Value}

測試為

func TestAdd(t *testing。T) {   res := AddInternalValue(1)   require。Equal(t, 1+Value, res)}

看起來非常完美,但是如果某天內部變數 Value 的值被不小心改動了,那麼這個測試無法反應出這個改動,也就無法及時發現這個錯誤了。如果我們寫成

func TestAdd(t *testing。T) {   const value = 3   res := AddInternalValue(1)   require。Equal(t, 1+value, res)}

就不用擔心無法發現常量值的變化了。