使用testify和mockery庫簡化單元測試

前言

2016年我寫過一篇關於Go語言單元測試的文章,簡單介紹了 testing 庫的使用方法。後來發現 testify/require 和 testify/assert 能夠大大簡化單元測試的寫法,徹底能夠替代 t.Fatalft.Errorf,並且代碼實現更爲簡短、優雅。javascript

再後來,發現了 mockery 庫,它能夠爲 Go interface 生成一個 mocks struct。經過 mocks struct,在單元測試中咱們能夠模擬全部 normal cases 和 corner cases,完全消除細節實現上的bug。mocks 在測試無狀態函數 (對應 FP 中的 pure function) 中意義不大,其應用場景主要在於處理不可控的第三方服務、數據庫、磁盤讀寫等。若是這些服務的調用細節已經被封裝到 interface 內部,調用方只看到了 interface 定義的一組方法,那麼在測試中 mocks 就能控制第三方服務返回任意指望的結果,進而實現對調用方邏輯的全方位測試。html

關於 interface 的諸多用法,我會單獨拎出來一篇文章來說。本文中,我會經過兩個例子展現 testify/requiremockery 的用法,分別是:java

  1. 使用 testify/require 簡化 table driven test
  2. 使用 mockerytestify/mock 爲 lazy cache 寫單元測試

準備工做

# download require, assert, mock
go get -u -v github.com/stretchr/testify
# install mockery into GoBin
go get -u -v github.com/vektra/mockery/.../

testify/require

首先,咱們經過一個簡單的例子看下 require 的用法。咱們針對函數 Sqrt 進行測試,其實現爲:git

// Sqrt calculate the square root of a non-negative float64 
// number with max error of 10^-9. For simplicity, we don't 
// discard the part with is smaller than 10^-9.
func Sqrt(x float64) float64 {
  if x < 0 {
    panic("cannot be negative")
  }

  if x == 0 {
    return 0
  }

  a := x / 2
  b := (a + 2) / 2
  erro := a - b
  for erro >= 0.000000001 || erro <= -0.000000001 {
    a = b
    b = (b + x/b) / 2
    erro = a - b
  }

  return b
}

這裏咱們使用了一個常規的方法實現 Sqrt,該實現的最大精確度是到小數點後9位(爲了方便演示,這裏沒有對超出9位的部分進行刪除)。咱們首先測試 x < 0 致使 panic 的狀況,看 require 如何使用,測試代碼以下:github

func TestSqrt_Panic(t *testing.T) {
  defer func() {
    r := recover()
    require.Equal(t, "cannot be negative", r)
  }()
  _ = Sqrt(-1)
}

在上面的函數中,咱們只使用 require.Equal 一行代碼就實現了運行結果校驗。若是使用 testing 來實現的話,變成了三行,而且須要手寫一串描述:golang

func TestSqrt_Panic(t *testing.T) {
  defer func() {
    r := recover()
      if r.(string) != "cannot be negative" {
        t.Fatalf("expect to panic with message \"cannot be negative\", but got \"%s\"\n", r)
      }
  }()
  _ = Sqrt(-1)
}

使用 require 以後,不只使測試代碼更易於編寫,並且可以在測試運行失敗時,格式化運行結果,方便定位和修改bug。這裏你不妨把 -1 改爲一個正數,運行 go test,查看運行結果。數據庫

上面咱們可以看到 require 庫帶來的編碼和調試效率的上升。在 table driven test 中,咱們會有更深入的體會。api

Table Driven Test

咱們仍然以 Sqrt 爲例,來看下如何在 table driven test 中使用 require。這裏咱們測試的傳入常規參數的狀況,代碼實現以下:微信

func TestSqrt(t *testing.T) {
  testcases := []struct {
    desc   string
    input  float64
    expect float64
  }{
    {
      desc:   "zero",
      input:  0,
      expect: 0,
    },
    {
      desc:   "one",
      input:  1,
      expect: 1,
    },
    {
      desc: "a very small rational number",
      input: 0.00000000000000000000000001,
      expect: 0.0,
    },
    {
      desc:   "rational number result: 2.56",
      input:  2.56,
      expect: 1.6,
    },
    {
      desc:   "irrational number result: 2",
      input:  2,
      expect: 1.414213562,
    },
  }

  for _, ts := range testcases {
    got := Sqrt(ts.input)
    erro := got - ts.expect
    require.True(t, erro < 0.000000001 && erro > -0.000000001, ts.desc)
  }
}

在上面這個例子,有三點值得注意:函數

  1. 匿名struct 容許咱們填充任意類型的字段,很是方便於構建測試數據集;
  2. 每一個匿名struct都包含一個 desc string 字段,用於描述該測試要處理的情況。在測試運行失敗時,很是有助於定位失敗位置;
  3. 使用 require 而不是 assert,由於使用 require 時,測試失敗之後,全部測試都會中止執行。

關於 require,除了本文中提到的 require.True, require.Equal,還有一個比較實用的方法是 require.EqualValues,它的應用場景在於處理 Go 的強類型問題,咱們不妨看一段代碼:

func Test_Require_EqualValues(t *testing.T) {
  // tests will pass
  require.EqualValues(t, 12, 12.0, "compare int32 and float64")
  require.EqualValues(t, 12, int64(12), "compare int32 and int64")

  // tests will fail
  require.Equal(t, 12, 12.0, "compare int32 and float64")
  require.Equal(t, 12, int64(12), "compare int32 and int64")
}

更多 require 的方法參考 require's godoc

mockery

mockery 與 Go 指令(directive) 結合使用,咱們能夠爲 interface 快速建立對應的 mock struct。即使沒有具體實現,也能夠被其餘包調用。咱們經過 LazyCache 的例子來看它的使用方法。

假設有一個第三方服務,咱們把它封裝在 thirdpartyapi 包裏,並加入 go directive,代碼以下:

package thirdpartyapi

//go:generate mockery -name=Client

// Client defines operations a third party service has
type Client interface {
  Get(key string) (data interface{}, err error)
}

咱們在 thirdpartyapi 目錄下執行 go generate,在 mocks 目錄下生成對應的 mock struct。目錄結構以下:

~ $ tree thirdpartyapi/
thirdpartyapi/
├── client.go
└── mocks
    └── Client.go

1 directory, 2 files

在執行 go generate 時,指令 //go:generate mockery -name=Client 被觸發。它本質上是 mockery -name=Client 的快捷方式,優點是 go generate 能夠批量執行多個目錄下的多個指令(須要多加一個參數,具體能夠參考文檔)。
此時,咱們只有 interface,並無具體的實現,可是不妨礙在 LazyCache 中調用它,也不妨礙在測試中調用 thirdpartyapi 的 mocks client。爲了方便理解,這裏把 LazyCache 的實現也貼出來 (忽略 import):

//go:generate mockery -name=LazyCache

// LazyCache defines the methods for the cache
type LazyCache interface {
  Get(key string) (data interface{}, err error)
}

// NewLazyCache instantiates a default lazy cache implementation
func NewLazyCache(client thirdpartyapi.Client, timeout time.Duration) LazyCache {
  return &lazyCacheImpl{
    cacheStore:       make(map[string]cacheValueType),
    thirdPartyClient: client,
    timeout:          timeout,
  }
}

type cacheValueType struct {
  data        interface{}
  lastUpdated time.Time
}

type lazyCacheImpl struct {
  sync.RWMutex
  cacheStore       map[string]cacheValueType
  thirdPartyClient thirdpartyapi.Client
  timeout          time.Duration // cache would expire after timeout
}

// Get implements LazyCache interface
func (c *lazyCacheImpl) Get(key string) (data interface{}, err error) {
  c.RLock()
  val := c.cacheStore[key]
  c.RUnlock()

  timeNow := time.Now()
  if timeNow.After(val.lastUpdated.Add(c.timeout)) {
    // fetch data from third party service and update cache
    latest, err := c.thirdPartyClient.Get(key)
    if err != nil {
      return nil, err
    }

    val = cacheValueType{latest, timeNow}
    c.Lock()
    c.cacheStore[key] = val
    c.Unlock()
  }

  return val.data, nil
}

爲了簡單,咱們暫時不考慮 cache miss 或 timeout 與cache被更新的時間間隙,大量請求直接打到 thirdpartyapi 可能致使的後果。

介紹測試以前,咱們首先了解一下 "控制變量法",在天然科學中,它被普遍用於各種實驗中。在智庫百科,它被定義爲 指把多因素的問題變成多個單因素的問題,而只改變其中的某一個因素,從而研究這個因素對事物影響,分別加以研究,最後再綜合解決的方法。該方法一樣適用於計算機科學,尤爲是測試不一樣場景下程序是否能如指望般運行。咱們將這種方法應用於本例中 Get 方法的測試。

Get 方法中,可變因素有 cacheStorethirdPartyClienttimeout。在測試中,cacheStoretimeout 是徹底可控的,thirdPartyClient 的行爲須要經過 mocks 自定義指望行爲以覆蓋默認實現。事實上,mocks 的功能要強大的多,下面咱們用代碼來看。

爲 LazyCache 寫測試

這裏,我只拿出 Cache Miss Update Failure 一個case 來分析,覆蓋全部 case 的代碼查看 github repo

func TestGet_CacheMiss_Update_Failure(t *testing.T) {
  testKey := "test_key"
  errTest := errors.New("test error")
  mockThirdParty := &mocks.Client{}
  mockThirdParty.On("Get", testKey).Return(nil, errTest).Once()

  mockCache := &lazyCacheImpl{
    memStore:         map[string]cacheValueType{},
    thirdPartyClient: mockThirdParty,
    timeout:          testTimeout,
  }

  // test cache miss, fails to fetch from data source
  _, gotErr := mockCache.Get(testKey)
  require.Equal(t, errTest, gotErr)

  mock.AssertExpectationsForObjects(t, mockThirdParty)
}

這裏,咱們只討論 mockThirdParty,主要有三點:

  1. mockThirdParty.On("Get", testKey).Return(nil, errTest).Once() 用於定義該對象 Get 方法的行爲:Get 方法接受 testKey 做爲參數,當且僅當被調用一次時,會返回 errTest。若是一樣的參數,被調用第二次,就會報錯;
  2. _, gotErr := mockCache.Get(testKey) 觸發一次上一步中定義的行爲;
  3. mock.AssertExpectationsForObjects 函數會對傳入對象進行檢查,保證預約義的指望行爲徹底被精確地觸發;

在 table driven test 中,咱們能夠經過 mockThirdParty.On 方法定義 Get 針對不一樣參數返回不一樣的結果。

在上面的測試中 .Once() 等價於 .Times(1)。若是去掉 .Once(),意味着 mockThirdParty.Get 方法能夠被調用任意次。

更多 mockery 的使用方法參考 github

小結

在本文中,咱們結合實例講解了 testifymockery 兩個庫在單元測試中的做用。最後分享一個圖,但願你們能重視單元測試。

忽略TDD和Code Review 的代價

相關連接

  1. 示例代碼
  2. testify
  3. mockery
  4. The Outrageous Cost of Skipping TDD & Code Reviews

掃碼關注微信公衆號「深刻Go語言」

在這裏

相關文章
相關標籤/搜索