首先須要明確的就是,單元是什麼?是一個函數?一個接口?仍是一個模塊?html
這個可能每一個人心中都用不一樣的定義。我比較贊同觀點是:單元是指一段邏輯。git
所以,單元測試就是對一段代碼邏輯的正確性進行校驗進行測試github
從我本身的切身體會上來講,單元測試意義在於:golang
明確好單元的定義後,寫單元測試前必須明確的一個點就是:到底須要測什麼?也就是這個單元的邊界在哪裏。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 對象。這個函數幹了這麼一件事:編程
咱們要寫一個 TestDeployConfirm 函數:數組
可見,不一樣的單元劃分,寫出來的單測是不同的。一般,咱們以:閉包
着三種粒度做爲一個單元。框架
明確本身要乾的事情以後,天然而然的,咱們就從代碼開始着手。
剛開始寫單元測試的時候,遇到的最大的問題應該就是,根本寫不出來。構造一個 case 的實在是太困難了。這背後的緣由,是由於代碼的耦合度太高。仍是一上面那段代碼爲例。這段代碼問題不少,先只聚焦單測相關的內容。若是咱們把 DeployConfirm 這個函數做爲一個單元,咱們看看這個單測應該怎麼寫:
是否是有點暈了?走到這一步的時候,可能就已經放棄了單元測試這個選項了。
最關鍵的,咱們本質上是想測試 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% ,真的就意味着咱們的測試是完備的嗎?
仍是一上面的這段代碼爲例子,若是咱們構造了兩個單測的用例:
那麼這兩個 case 一跑下來,確實全部的代碼都執行到了,代碼覆蓋率100%。可是咱們的用例並無考慮當 GetConfirm() 不等於 DEPLOY_UNCONFIRMED 的狀況。所以我認爲這樣的測試也是不完備的。
真正完備的測試,應該是要達到測試的用例數等於單元的圈複雜度),保證從邏輯的入口和出口之間的全部可能的代碼運行路徑都覆蓋到。
可是在平常的敏捷開發中,可能時間不容許/其實也不必維護這麼多的用例,來追求理論上完美的單元測試。這就要求咱們作必定程度的取捨。
在大多數狀況下,咱們的程序都是:
這個時候咱們要作的就是:
明確好構造用例的思路,咱們就真正着手於編寫單測帶麼了。
單元測試也是代碼,也須要人去編寫和維護 ,編寫的時候所要遵循的原則也是同樣的。工欲善其事,必先利其器。所以,在實際編寫時我傾向於使用一些現成的框架和庫來協助開發與維護。
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 提供了一種函數式編程風格的表達體驗。利用他官方提供的例程:
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 服務:
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 對象的庫,這一點咱們放在下一個話題。
單元測試的框架咱們有了,最關鍵的仍是構造測試用例。在咱們明確了要測試的單元的時候,構造用例的時候就涉及到要模擬的測試單元的外部依賴的返回值。這就兩個咱們可使用的工具選項:GoMock 和 Testify 提供 Mock 模塊。
這個是 Google 官方提供的惟一的單元測試的工具。這個工具好用的地方在於,他能自動根據你的 interface 的定義生成 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) }
這些代碼徹底就是有規律的,徹底可使用機器去編寫的。設想,若是一個接口有多個接口函數的簽名,同時一個裏面有無數個接口,這裏面的無用的工做量是很是恐怖的。
有的時候,咱們使用的外部邏輯是一個簡單的輔助函數/或者特定場景下使用了一個全局變量,這種時候 mock 就比較無力了。這個時候,咱們就能夠藉助 GoStub 給一個變量打樁。具體的使用實踐,能夠參考這篇文章,在此不作詳細的展開。
當你的一個方法依賴了當前結構體綁定的另一個方法的時候,就涉及到了方法的打樁。方法的打樁可使用 Monkey 這個工具,使用方法能夠參考這個實踐,在這裏就不展開了。
我更傾向於 GoConvey + GoMock。緣由是:
在具體寫代碼的時候,若是沒有管理好抽象和具體實現的的依賴關係,讓單測變得異常複雜。
舉個例子:若是一個包依賴了另外一個包的具體實現,好比說示例代碼中的 confirmer。這個時候你想要去寫單測,極可能就使用了 Monkey 去給那個方法打樁。而後打樁還打不了,由於咱們沒辦法把打過樁的這個對象注入到咱們測試的單元中。這時候頗有可能就會把 cfm 這個變量寫成一個全局變量,而後想到用GoStub 去給這個全局變量打樁。代碼這樣去寫很顯然是不合理的。
所以,寫單測的時候不管如何都應該優先使用 Mock(畢竟 Google 官方也只提供了 gomock 這個工具,說明官方認爲這個就已經夠用了)。當要用到 GoStub 和 Monkey 時,先考慮清楚是否是代碼寫的不合理,而後確認使用場景以後,再合理選擇使用。