單元測試在 golang 中的實踐

單元測試是什麼?

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

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

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

單元測試的意義

從我自己的切身體會上來說,單元測試意義在於:

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

明確自己到底測什麼

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

例如下面這段代碼:

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/...

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章