單元測試在 golang 中的實踐

單元測試是什麼?

首先須要明確的就是,單元是什麼?是一個函數?一個接口?仍是一個模塊?html

這個可能每一個人心中都用不一樣的定義。我比較贊同觀點是:單元是指一段邏輯。git

所以,單元測試就是對一段代碼邏輯的正確性進行校驗進行測試github

單元測試的意義

從我本身的切身體會上來講,單元測試意義在於:golang

  1. 最基本的,保證代碼邏輯上的正確性
  2. 可以進行迴歸,避免修改致使把之前的代碼改掛
  3. 迫使本身寫出好的程序
  4. 開發的時候可以快速的把代碼跑起來,驗證效果(在程序體量特別大,構建一次成本特別高;或者程序總體運行條件比較苛刻的時候會更明顯)
  5. 單測即文檔

明確本身到底測什麼

明確好單元的定義後,寫單元測試前必須明確的一個點就是:到底須要測什麼?也就是這個單元的邊界在哪裏。web

例以下面這段代碼:數據庫

func (d *DeploymentModel) DeployConfirm(user string, deployID int64) error {
  var deploy Deploy
  if err := d.db.Find(&deploy, "id = ?", deployID).Error; err != nil {
        return err
  }
  
  if deploy.GetConfirm() == DEPLOY_UNCONFIRMED {
    cfm := &confirmer{} 
    if err := cfm.ConfirmDeployInNeed(deploy.Id, user); err != nil {
      return err
    }
  }
  
  return nil
}

這是一個使用了 gorm 做爲數據庫驅動的 web 項目中的一段代碼。d.db 是 gorm 中的 *gorm.DB 對象。這個函數幹了這麼一件事:編程

  1. 根據 deployID 從數據庫中拿出對應的記錄
  2. 若是這個記錄的狀態是沒有被確認,就去確認一下不然之間返回。

咱們要寫一個 TestDeployConfirm 函數:數組

  1. 若是咱們僅僅是測這個函數的邏輯,那咱們不該該將 Find 函數、ConfirmDeployInNeed 這個函數真正的執行一遍。不然究竟是測 DeployConfirm 仍是測 Find / ConfirmDeployInNeed?
  2. 若是是測 DeployConfirm 這個接口,那麼咱們確實應該真正的將數據庫查詢/ConfirmDeployInNeed執行

可見,不一樣的單元劃分,寫出來的單測是不同的。一般,咱們以:閉包

  1. web服務的接口(常見業務代碼)
  2. 模塊的接口/模塊的接口(團隊合做開發)
  3. 一個函數(我的平常開發)

着三種粒度做爲一個單元。框架

明確本身要乾的事情以後,天然而然的,咱們就從代碼開始着手。

有好代碼,才能寫出測試

一個有問題的例子

剛開始寫單元測試的時候,遇到的最大的問題應該就是,根本寫不出來。構造一個 case 的實在是太困難了。這背後的緣由,是由於代碼的耦合度太高。仍是一上面那段代碼爲例。這段代碼問題不少,先只聚焦單測相關的內容。若是咱們把 DeployConfirm 這個函數做爲一個單元,咱們看看這個單測應該怎麼寫:

  1. d.db.Find 是一個確定會訪問數據庫的動做。這意味着咱們得事先準備好一個數據準備好了,可鏈接的數據庫。同時,得構造出 gorm 訪問數據庫所須要的上下文。
  2. 同時還得準備好 confirmer 相關的代碼。因爲直接使用的是 confirmer 這個具體實現,還得把 ConfirmDeployInNeed 這個函數實現好。甚至若是 ConfirmDeployInNeed 裏面還有其餘的硬依賴,那還得去實現裏面的依賴。

是否是有點暈了?走到這一步的時候,可能就已經放棄了單元測試這個選項了。

最關鍵的,咱們本質上是想測試 DeployConfirm 這個函數的邏輯,可是 ConfirmDeployInNeed 若是有問題,會致使個人數據庫失敗。數據庫鏈接有問題/裏面的數據有問題,都會致使咱們測試不經過。

所以,能不能寫出單元測試,取決於咱們有沒有寫好被測試的代碼。反過來,若是發現單元測試寫起來很麻煩,首先要考慮代碼是否寫的合理。

可測試的代碼應該是怎麼樣的

寫代碼是單元和分離的藝術。作好單元和分離,管理好抽象與實現,代碼就可測試。具體大概的是如下幾點:

有狀態與無狀態分離

咱們寫代碼的時候,都應該儘量的編寫純函數。由於對於同一個輸入有固定的輸出,纔是純邏輯,才能保證測試的結果冪等。將代碼有狀態的部分/和沒有狀態的部分分離,有狀態的部分儘量的少。咱們常見的 MVC 模式,把 Model 層隔離出來就是這麼個思想。例如上面這個函數,正確的作法應該是:

將「根據 deployID 從數據庫中拿出對應的記錄」 這個操做放在 model, 其他邏輯相關的東西都應該拆到 Controller 裏面去,那咱們在 Controller 裏面的函數就是純邏輯,單測就好寫了。

經過接口隔離

接口是抽象,咱們能夠構造本身的 mock 的實現來替換原有的實現。在編寫代碼的時候,引用其餘的包的都應該調用接口。仍是以上面的代碼爲例:

首先要定義一個 interface:

type IConfirmer interface {
  ConfirmDeployInNeed(deployID int64, user string) error
}

而後,任何對象都應該經過構造函數去實現

func NewConfirmer() IConfirmer {
  ...
}

使用的時候:

var confirmer IConfirmer
confirmer := NewConfirmer()

這樣,咱們就能作到只經過抽象耦合,而不是與實現耦合。咱們就能用咱們 Mock 的一個 confirmer(也實現了這個抽象),去替代原有的實現,從而達到爲所欲爲的操縱 ConfirmDeployInNeed 這個方法的返回值的目的。

儘量少的硬耦合

經過上面兩個方法,咱們已經能很是容易的去控制外部依賴了。還有一個問題沒解決 NewConfirmer 這個函數是一個別的包的引過來的,咱們好像沒辦法替換 NewConfirmer() 的返回值呀?

這就是硬耦合(直接在函數裏面去 New 一個對象)帶來麻煩。

咱們在寫代碼的時候,都應該儘量的將外部依賴經過函數傳參進入到一段邏輯以內, 來避免硬耦合。常見的作法有:

若是 confirmer 只在當前方法裏面用到,則直接做爲參數傳入當前函數:

func (d *DeploymentModel) DeployConfirm(user string, deployID int64, cfm IConfirmer) error {
  ...
  cfm.ConfirmDeployInNeed(deployID, user)
}

若是 confirmer 在多處用到,則在構造函數初始化:

func NewDeploymentModel(cfm IConfirmer) {
  ...
}

// or

func NewDeploymentModel() {
  return &DeploymentModel {
    confirmer: NewConfirmer(),
  } 
}

這種方式,咱們就能將初始化和邏輯分開.

知足單測編寫的前提後,咱們能夠開始怎麼構造單測用例。

單測用例構造的思路

原則

一般,咱們使用代碼覆蓋率來衡量單測是否寫的全面。然而,若是咱們的單測作到了代碼覆蓋率 100% ,真的就意味着咱們的測試是完備的嗎?

仍是一上面的這段代碼爲例子,若是咱們構造了兩個單測的用例:

  1. 這兩個用例 GetConfirm() 的結果都等於 DEPLOY_UNCONFIRMED
  2. 一個用例執行 ConfirmDeployInNeed 這個函數拋出異常;另外一個用例執行ConfirmDeployInNeed 這個函數沒有拋出異常

那麼這兩個 case 一跑下來,確實全部的代碼都執行到了,代碼覆蓋率100%。可是咱們的用例並無考慮當 GetConfirm() 不等於 DEPLOY_UNCONFIRMED 的狀況。所以我認爲這樣的測試也是不完備的。

真正完備的測試,應該是要達到測試的用例數等於單元的圈複雜度),保證從邏輯的入口和出口之間的全部可能的代碼運行路徑都覆蓋到。

實踐

可是在平常的敏捷開發中,可能時間不容許/其實也不必維護這麼多的用例,來追求理論上完美的單元測試。這就要求咱們作必定程度的取捨。

在大多數狀況下,咱們的程序都是:

  1. 有一條正常運行的通路,分枝用於拋出/處理異常
  2. 有的分叉口/邊界。好比降級,或者確實就是得根據不一樣的狀況作不一樣的處理。

這個時候咱們要作的就是:

  1. 必定要保證有一個用例覆蓋了正常的通路。這保證了咱們主流程的正確性,同時往後迭代也能迴歸到,加強迭代的信心
  2. 覆蓋常規或者比較關鍵分叉和邊界。好比就是降級能不能正確處理。
  3. 每次線上出問題的地方,補充上單測。
  4. 極端狀況下的邊界能夠視重要程度不考慮。好比某一次數據庫訪問掛了。

明確好構造用例的思路,咱們就真正着手於編寫單測帶麼了。

單測編寫工具

單元測試也是代碼,也須要人去編寫和維護 ,編寫的時候所要遵循的原則也是同樣的。工欲善其事,必先利其器。所以,在實際編寫時我傾向於使用一些現成的框架和庫來協助開發與維護。

常規工具 —— VSCode+Go插件

VSCode 的插件有個很是好用的功能,就是一他能自動根據你的代碼生成對應的單元測試的框架代碼。生成出來的代碼就像是個樣子:

func TestDeploymentModel_DeployConfirm(t *testing.T) {
  type fields struct {
    db *gorm.DB,
  }
  type args struct {
    user     string
    deployID int64
  }
  tests :=[]struct{
    name    string
    fields  fields
    args    args
    wantErr bool
  }{
    // todo: Add test case
  }
  for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
      db: tt.fields.db,
    })
    if err := m.DeployConfirm(tt.args.user, tt.args.deployID); (err != nil) != tt.wantErr {
      t.Errorf("DeploymentModel.DeployConfirm() error = %v, wantErr %v", err, tt.wantErr)
    }
  }
}

而後你只須要把用例有關的數據以結構體實例的方式補充到 tests 數組裏面,每個元素一個用例。我很是喜歡這種 generate code 的方式進行編程,這能讓人更專一於真正有意義的事情上。

單測框架

若是不知足於 VSCode + Go 插件的表達能力(好比你但願對測試分組分類),那麼能夠考慮使用一些單元測試框架。比較值得推薦的單測框架就是 GoConveyTestify

GoConvey

編碼風格

GoConvey 提供了一種函數式編程風格的表達體驗。利用他官方提供的例程:

package package_name

import (
    "testing"
    . "github.com/smartystreets/goconvey/convey"
)

func TestSpec(t *testing.T) {

    // Only pass t into top-level Convey calls
    Convey("Given some integer with a starting value", t, func() {
        x := 1

        Convey("When the integer is incremented", func() {
            x++

            Convey("The value should be greater by one", func() {
                So(x, ShouldEqual, 2)
            })
        })
    })
}

能夠看到,他利用閉包的方式,能無限的嵌套下去。能一步一步的構造本身的上下文,而後構造出本身的用例。

同時,GoConvey 也提供了一系列標準的斷言庫 (如 例程代碼中的 So 函數)。在上面 VSCode 生成的代碼裏面也能夠看到, golang 原生式沒有斷言的,他須要咱們本身實現具體的判斷邏輯,而後經過 t.Errorf()把沒有經過的結果拋出來。利用框架提供的斷言能讓咱們找回原來斷言式編程的熟悉感。

最後,我認爲比較吸引人的一點是它提供了一個 web 服務:

  1. 能監聽本地文件的變化,在編碼過程當中,文件的變更都能自動觸發單元測試。
  2. 單元測試的報告能以一個相對美觀的 web 頁面呈現,提高編程體驗。

Testify

Testify 提供了一種面向對象編程風格的表達體驗。一樣列出他的官方簡化例程:

import (
    "testing"
    "github.com/stretchr/testify/suite"
)

type ExampleTestSuite struct {
    suite.Suite
    VariableThatShouldStartAtFive int
}

func (suite *ExampleTestSuite) SetupTest() {
    suite.VariableThatShouldStartAtFive = 5
}

func (suite *ExampleTestSuite) TestExample() {
    suite.Equal(suite.VariableThatShouldStartAtFive, 5)
}

func TestExampleTestSuite(t *testing.T) {
    suite.Run(t, new(ExampleTestSuite))
}

叢代碼能夠看到, Testify 在測試的各個階段設置了錨點(好比 SetupTest方法用於構造測試用例所須要的上下文),而後 結構體中的每個 Test 開頭的方法,都會被做爲測試用例執行。徹底的使用用例見這個連接

一樣的,Testify 也提供了一套標準的斷言庫

最後 testify 提供了用於構造 Mock 對象的庫,這一點咱們放在下一個話題。

外部對象的 mock

單元測試的框架咱們有了,最關鍵的仍是構造測試用例。在咱們明確了要測試的單元的時候,構造用例的時候就涉及到要模擬的測試單元的外部依賴的返回值。這就兩個咱們可使用的工具選項:GoMock 和 Testify 提供 Mock 模塊。

GoMock

這個是 Google 官方提供的惟一的單元測試的工具。這個工具好用的地方在於,他能自動根據你的 interface 的定義生成 Mock 對象的代碼。寫單元測試的時候直接使用就能夠了。因爲生成的代碼比較長,我就不貼在這裏了,有興趣的同窗能夠本身生成一個看看。

Testify 的 Mock

Testify 框架自己也提供了 Mock 的庫。可是從我我的的感受來講,這個庫的使用體驗並很差。緣由就在於,Mock 對象的實現得本身寫。舉個例子:

假設如今咱們有這麼一個接口:

type IService interface {
  SendHTTPRequest(
    method string,
    link string,
    params map[string]string,
    headers map[string],
    body []byte,
  ) (
    service.IResponse,
    error,
  )
}

那關於這個接口的 Mock 對象就須要這麼寫:

import(
    "github.com/stretchr/testify/mock"
)

type MockIService struct {
  mock.Mock
}

func (ms *MockIService) SendHTTPRequest(
  method string,
  link string,
  params map[string]string,
  headers map[string],
  body []byte,
) (
  service.IResponse,
  error,
) {
  args := ms.Called(method, link, params, headers, body, timeout)
  return args.Get(0).(service.IResponse), args.Error(1)
}

這些代碼徹底就是有規律的,徹底可使用機器去編寫的。設想,若是一個接口有多個接口函數的簽名,同時一個裏面有無數個接口,這裏面的無用的工做量是很是恐怖的。

給函數/變量打樁—— GoStub

有的時候,咱們使用的外部邏輯是一個簡單的輔助函數/或者特定場景下使用了一個全局變量,這種時候 mock 就比較無力了。這個時候,咱們就能夠藉助 GoStub 給一個變量打樁。具體的使用實踐,能夠參考這篇文章,在此不作詳細的展開。

給方法打樁——Monkey

當你的一個方法依賴了當前結構體綁定的另一個方法的時候,就涉及到了方法的打樁。方法的打樁可使用 Monkey 這個工具,使用方法能夠參考這個實踐,在這裏就不展開了。

實踐總結

框架的選擇

我更傾向於 GoConvey + GoMock。緣由是:

  1. GoConvey 使用起來簡單,功可以用,單測組織起來表達能力也強。
  2. GoMock 比 Testify 的 mock 好用,而且 mock 是我目前寫單測的主要依賴解決手段。

庫的使用

在具體寫代碼的時候,若是沒有管理好抽象和具體實現的的依賴關係,讓單測變得異常複雜。

舉個例子:若是一個包依賴了另外一個包的具體實現,好比說示例代碼中的 confirmer。這個時候你想要去寫單測,極可能就使用了 Monkey 去給那個方法打樁。而後打樁還打不了,由於咱們沒辦法把打過樁的這個對象注入到咱們測試的單元中。這時候頗有可能就會把 cfm 這個變量寫成一個全局變量,而後想到用GoStub 去給這個全局變量打樁。代碼這樣去寫很顯然是不合理的。

所以,寫單測的時候不管如何都應該優先使用 Mock(畢竟 Google 官方也只提供了 gomock 這個工具,說明官方認爲這個就已經夠用了)。當要用到 GoStub 和 Monkey 時,先考慮清楚是否是代碼寫的不合理,而後確認使用場景以後,再合理選擇使用。

原文連接:https://blog.coordinate35.cn/...

相關文章
相關標籤/搜索