一個測試工程師走進一家酒吧,要了一杯啤酒;
一個測試工程師走進一家酒吧,要了一杯咖啡;
一個測試工程師走進一家酒吧,要了 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)}
就不用擔心無法發現常量值的變化了。