單元測試是什麼?
首先需要明確的就是,單元是什麼?是一個函數?一個接口?還是一個模塊?
這個可能每個人心中都用不同的定義。我比較贊同觀點是:單元是指一段邏輯。
因此,單元測試就是對一段代碼邏輯的正確性進行校驗進行測試
單元測試的意義
從我自己的切身體會上來說,單元測試意義在於:
- 最基本的,保證代碼邏輯上的正確性
- 能夠進行迴歸,避免修改導致把以前的代碼改掛
- 迫使自己寫出好的程序
- 開發的時候能夠快速的把代碼跑起來,驗證效果(在程序體量特別大,構建一次成本特別高;或者程序整體運行條件比較苛刻的時候會更明顯)
- 單測即文檔
明確自己到底測什麼
明確好單元的定義後,寫單元測試前必須明確的一個點就是:到底需要測什麼?也就是這個單元的邊界在哪裏。
例如下面這段代碼:
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 對象。這個函數幹了這麼一件事:
- 根據 deployID 從數據庫中拿出對應的記錄
- 如果這個記錄的狀態是沒有被確認,就去確認一下否則之間返回。
我們要寫一個 TestDeployConfirm 函數:
- 如果我們僅僅是測這個函數的邏輯,那我們不應該將 Find 函數、ConfirmDeployInNeed 這個函數真正的執行一遍。否則到底是測 DeployConfirm 還是測 Find / ConfirmDeployInNeed?
- 如果是測 DeployConfirm 這個接口,那麼我們確實應該真正的將數據庫查詢/ConfirmDeployInNeed執行
可見,不同的單元劃分,寫出來的單測是不一樣的。通常,我們以:
- web服務的接口(常見業務代碼)
- 模塊的接口/模塊的接口(團隊合作開發)
- 一個函數(個人日常開發)
着三種粒度作爲一個單元。
明確自己要乾的事情之後,自然而然的,我們就從代碼開始着手。
有好代碼,才能寫出測試
一個有問題的例子
剛開始寫單元測試的時候,遇到的最大的問題應該就是,根本寫不出來。構造一個 case 的實在是太困難了。這背後的原因,是因爲代碼的耦合度過高。還是一上面那段代碼爲例。這段代碼問題很多,先只聚焦單測相關的內容。如果我們把 DeployConfirm 這個函數作爲一個單元,我們看看這個單測應該怎麼寫:
- d.db.Find 是一個肯定會訪問數據庫的動作。這意味着我們得事先準備好一個數據準備好了,可連接的數據庫。同時,得構造出 gorm 訪問數據庫所需要的上下文。
- 同時還得準備好 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% ,真的就意味着我們的測試是完備的嗎?
還是一上面的這段代碼爲例子,如果我們構造了兩個單測的用例:
- 這兩個用例 GetConfirm() 的結果都等於 DEPLOY_UNCONFIRMED
- 一個用例執行 ConfirmDeployInNeed 這個函數拋出異常;另一個用例執行ConfirmDeployInNeed 這個函數沒有拋出異常
那麼這兩個 case 一跑下來,確實所有的代碼都執行到了,代碼覆蓋率100%。但是我們的用例並沒有考慮當 GetConfirm() 不等於 DEPLOY_UNCONFIRMED 的情況。因此我認爲這樣的測試也是不完備的。
真正完備的測試,應該是要達到測試的用例數等於單元的圈複雜度),保證從邏輯的入口和出口之間的所有可能的代碼運行路徑都覆蓋到。
實踐
但是在日常的敏捷開發中,可能時間不允許/其實也沒必要維護這麼多的用例,來追求理論上完美的單元測試。這就要求我們做一定程度的取捨。
在大多數情況下,我們的程序都是:
- 有一條正常運行的通路,分枝用於拋出/處理異常
- 有的分叉口/邊界。比如降級,或者確實就是得根據不同的情況做不同的處理。
這個時候我們要做的就是:
- 一定要保證有一個用例覆蓋了正常的通路。這保證了我們主流程的正確性,同時日後迭代也能迴歸到,增強迭代的信心
- 覆蓋常規或者比較關鍵分叉和邊界。比如就是降級能不能正確處理。
- 每次線上出問題的地方,補充上單測。
- 極端情況下的邊界可以視重要程度不考慮。比如某一次數據庫訪問掛了。
明確好構造用例的思路,我們就真正着手於編寫單測帶麼了。
單測編寫工具
單元測試也是代碼,也需要人去編寫和維護 ,編寫的時候所要遵循的原則也是一樣的。工欲善其事,必先利其器。因此,在實際編寫時我傾向於使用一些現成的框架和庫來協助開發與維護。
常規工具 —— 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 插件的表達能力(比如你希望對測試分組分類),那麼可以考慮使用一些單元測試框架。比較值得推薦的單測框架就是 GoConvey 和 Testify
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 服務:
- 能監聽本地文件的變化,在編碼過程中,文件的變動都能自動觸發單元測試。
- 單元測試的報告能以一個相對美觀的 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。原因是:
- GoConvey 使用起來簡單,功能夠用,單測組織起來表達能力也強。
- GoMock 比 Testify 的 mock 好用,並且 mock 是我目前寫單測的主要依賴解決手段。
庫的使用
在具體寫代碼的時候,如果沒有管理好抽象和具體實現的的依賴關係,讓單測變得異常複雜。
舉個例子:如果一個包依賴了另一個包的具體實現,比如說示例代碼中的 confirmer。這個時候你想要去寫單測,很可能就使用了 Monkey 去給那個方法打樁。然後打樁還打不了,因爲我們沒辦法把打過樁的這個對象注入到我們測試的單元中。這時候很有可能就會把 cfm 這個變量寫成一個全局變量,然後想到用GoStub 去給這個全局變量打樁。代碼這樣去寫很顯然是不合理的。
因此,寫單測的時候無論如何都應該優先使用 Mock(畢竟 Google 官方也只提供了 gomock 這個工具,說明官方認爲這個就已經夠用了)。當要用到 GoStub 和 Monkey 時,先考慮清楚是不是代碼寫的不合理,然後確認使用場景之後,再合理選擇使用。