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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Golang 測試的一些基本寫法和工具
  • 如何寫「正確」的測試,這個部分雖然代碼是用 golang 編寫,可是其核心思想不限語言

因爲篇幅問題,本文將不涉及性能測試,以後會另起一篇來談。

爲何要寫測試

咱們舉個不太恰當的例子,測試也是代碼,咱們假定寫代碼時出現 bug 的機率是 p(0<p<1),那麼咱們同時寫測試的話,兩邊同時出現 bug 的機率就是(咱們認爲兩個事件相互獨立)

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.go
package add

func Add(a, b int) int {
   return a + b
}
複製代碼

一個測試用例能夠寫成:

// add_test.go
package add

import (
   "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 test
PASS
ok      code.byted.org/ek/demo_test/t01_basic/correct       0.015s
複製代碼

假設這個時候咱們把 Add 函數修改爲錯誤的實現

// add.go
package add

func Add(a, b int) int {
   return a - b
}
複製代碼

再次執行測試命令

% go test
--- FAIL: TestAddWrong (0.00s)
    add_test.go:11: the result is -1 instead of 3
FAIL
exit status 1
FAIL    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: Add
FAIL    command-line-arguments [build failed]
FAIL
複製代碼

這是由於咱們沒有附帶測試對象的代碼,修改測試後能夠得到正確的輸出:

% go test add_test.go add.go
ok      command-line-arguments  0.007s
複製代碼

測試的幾種書寫方式

子測試

一般來講咱們測試某個函數和方法,可能須要測試不少不一樣的 case 或者邊際條件,例如咱們爲上面的 Add 函數寫兩個測試,能夠寫成:

// add_test.go
package add

import (
   "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)
PASS
ok      code.byted.org/ek/demo_test/t02_subtest/non_subtest     0.007s
複製代碼

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

// add_test.go
package add

import (
   "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)
PASS
ok      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.go
package add_test

import (
   "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: Add
FAIL    code.byted.org/ek/demo_test/t03_diffpkg [build failed]
複製代碼

因爲包名變化了,咱們沒法再訪問到 Add 函數,這個時候咱們增長 import 便可:

// add_test.go
package add_test

import (
   "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.go
package correct

import (
   "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 test
ok      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:           TestAdd
FAIL
FAIL    code.byted.org/ek/demo_test/t04_libraries/testify/wrong 0.009s
FAIL
複製代碼

庫提供了格式化的錯誤詳情(堆棧、錯誤值、指望值等)來方便咱們調試。

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 查找

如何寫好測試

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

併發測試

在平時,你們寫服務的時候,基本都必須考慮併發,咱們使用 IDE 測試的時候,IDE 默認狀況下並不會主動測試併發狀態,那麼如何保證咱們寫出來的代碼是併發安全的? 咱們來舉個例子,好比咱們有個計數器,做用就是計數。

type Counter int32

func (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 RACE
Read 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 +0x66

Previous 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 +0x66

Goroutine 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 +0x202

Goroutine 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 int32

func (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 string

func (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 -cover
PASS
coverage: 100.0% of statements
ok      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 add

const Value = 3

func 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)
}
複製代碼

就不用擔憂沒法發現常量值的變化了。

相關文章
相關標籤/搜索