如何在測試中更好地使用mock
注意:本文大部分內容為翻譯 Bob 大叔的文章,原文連結可以在文章底部的參考檔案處找到。
什麼是 mock
mock 作為名詞時表示 mock 物件,在維基百科的解釋中如下:
在面向物件程式設計中,模擬物件(英語:mock object,也譯作模仿物件)是以可控的方式模擬真實物件行為的假的物件。程式設計師通常創造模擬物件來測試其他物件的行為。
mock 作為動詞時表示編寫使用 mock 物件。
mock 多用於測試程式碼中,對於不容易構造或者不容易獲取的物件,使用一個虛擬的物件來方便測試。
mock 的分類
為了使用示例說明各個mock 種類的區別與聯絡,文章使用 go 語言作為示例,如下為示例的基礎程式碼:
type Authorizer interface {
authorize(username,password string) bool
}
type System struct {
authorizer Authorizer
}
func NewSystem(authorizer Authorizer) *System {
system = new(System)
system.authorizer = authorizer
return system
}
func (s *System) loginCount() int {
// skip
return 0
}
func (s *System) login(username,password string) error {
if s.authorizer.authorize(username,password) {
return nil
}
return errors.New("username or password is not right")
}
複製程式碼
dummy
當你不關心傳入的引數被如何使用時,你就應該使用 dummy 型別的 mock,一般用於作為其他物件的初始化引數。示例如下:
type DummyAuthorizer struct {}
func (d *DummyAuthorizer) authorize(username,password string) bool {
// return nil
return false
}
// Test
func TestSystem(t *testing.T) {
system := NewSystem(new(DummyAuthorizer))
got := system.loginCount()
want := 0
if got != want {
t.Errorf("got %d,want %d",got,want)
}
}
複製程式碼
在上面的測試示例程式碼中,DummyAuthorizer 的作為只是為了初始化 System 物件的需要,後續測試中並沒有使用該 DummyAuthorizer 物件。
注意:此處的 authorize 方法原文返回了 null ,由於 go 語言不允許為 bool 返回 nil ,因此此處返回了 false
stub
當你只關心方法的返回結果,並且需要特定返回值的時候,這時候你就可以使用 stub 型別的 mock 。比如我們需要測試系統中某些功能是否能正確處理使用者登入和不登入的情況,而登入功能我們已經在其他地方經過測試,而且使用真實的登入功能呼叫又比較的麻煩,我們就可以直接返回已登入或者未登入狀態來進行其他功能的驗證。
type AcceptingAuthorizerStub struct {}
func (aas *AcceptingAuthorizerStub) authorize(username,password string) bool {
return true
}
type RefusingAuthorizerStub struct {}
func (ras *RefusingAuthorizerStub) authorize(username,password string) bool {
return false
}
複製程式碼
spy
當你不只是只關心方法的返回結果,還需要檢查方法是否真正的被呼叫了,方法的呼叫次數等,或者需要記錄方法呼叫過程中的資訊。這個時候你就應該使用 spy 型別的 mock ,呼叫結束後你需要自己檢查方法是否被呼叫,檢查呼叫過程中記錄的其他資訊。但是請注意,這將會使你的測試程式碼和被測試方法相耦合,測試需要知道被測試方法的內部實現細節。使用時需要謹慎一些,不要過渡使用,過渡使用可能導致測試過於脆弱。
type AcceptingAuthorizerSpy struct {
authorizeWasCalled bool
}
func (aas *AcceptingAuthorizerSpy) authorize(username,password string) bool {
aas.authorizeWasCalled = true
return true
}
// Test
func TestSystem(t *testing.T) {
authorizer := new(AcceptingAuthorizerSpy)
system := NewSystem(authorizer)
got := system.login("will","will")
if got != nil {
t.Errorf("login failed with error %v",got)
}
if authorizer.authorizeWasCalled != true {
t.Errorf("authorize was not called")
}
}
複製程式碼
mock
mock 型別的 mock 可以算作是真正的 ”mock“ 。把 spy 型別的 mock 在測試程式碼中的斷言語句移動到 mock 物件中,這使它更關注於測試行為。這種型別的 mock 對方法的返回值並不是那麼的感興趣,它更關心的是哪個方法被使用了什麼引數在什麼時間被呼叫了,呼叫的頻率等。這種型別的 mock 使得編寫 mock 相關的工具更加的簡單,mock 工具可以幫助你在執行時建立 mock 物件。
type AcceptingAuthorizerVerificationMock struct {
authorizeWasCalled bool
}
func (aavm *AcceptingAuthorizerVerificationMock) authorize(username,password string) bool {
aavm.authorizeWasCalled = true
return true
}
func (aavm *AcceptingAuthorizerVerificationMock) verify() bool {
return aavm.authorizeWasCalled
}
複製程式碼
fake
fake 型別的 mock 與其他型別的 mock 最大的區別是它包含了真實的業務邏輯。當以不同的資料呼叫時,你會得到不同的結果。隨著業務邏輯的改變,它可能也會越來越複雜,最終你也需要為這種型別的 mock 編寫單元測試,甚至最後它可能成為了一個真實的業務系統。如果不是必須,請不要使用 fake 型別的 mock 。
type AcceptingAuthorizerFake struct {}
func (aas *AcceptingAuthorizerFake) authorize(username,password string) bool {
if username == "will" {
return true
}
return false
}
複製程式碼
總結
mock 是 spy 的一種型別,spy 又是 stub 的一種型別,而 stub 又是 dummy 的一種型別,但是 fake 與其他所有 mock 型別不同,fake 包含了真實的業務邏輯,而其他型別的 mock 都不包含真實的業務邏輯。
根據 Bob 大叔的實踐來看,他使用最多的是 spy 和 stub 型別的 mock ,並且他不會經常使用 mock 工具,很少使用 dummy 型別的 mock ,只有在使用 mock 工具時才會使用 mock 型別的 mock 。現在的程式設計 IDE 中,只需要你定義好介面,IDE 就可以幫你輕鬆的實現他們,你只需要簡單的修改就可以實現 spy 和 stub 型別的 mock ,因此 Bob 大叔很少使用 mock 工具。
mock 的使用時機
mock 物件是一個強大的工具,但是 mock 物件也有兩面性,如果使用不正確也可能會帶來強大的破壞力。
完全不使用 mock
如果我們完全不使用 mock ,直接使用真實的物件進行測試,這會帶來什麼問題呢?
- 測試將會執行緩慢。我們使用真實的資料庫,真實的上游服務,由於這些都需要通過網路來進行通訊,這會將比程式內部的函式呼叫慢上幾個數量級。當我們修改一行簡單的程式碼,進行測試時,可能需要等待數分鐘,數小時,甚至可能要幾天才能把測試執行結束。
- 程式碼的測試覆蓋率可能會降低很多。一些錯誤和異常在沒有使用 mock 的情況下可能根本無法進行測試,例如網路協議的異常。一些危險的測試用例,比如刪除檔案、刪除資料庫表很難進行安全的測試。
- 測試變得異常的脆弱。與測試無關的其他問題可能會導致測試失敗,例如由於機器負載導致的網路時延問題,資料庫表的結構不正確,配置檔案被錯誤修改等問題。
在完全不使用 mock 物件的情況下,我們的測試會變得緩慢、不完整、脆弱。
過度使用 mock
如果過度使用 mock 物件,所有的測試都使用 mock 物件,這會帶來什麼問題呢?
- 測試將會執行緩慢。一些 mock 工具強依賴反射機制,因此會使得測試變慢。
- mock 所有類之間的互動,會導致你必須建立返回其他 mock 類的 mock 類,你可能需要 mock 整個互動鏈路上所有的類,這將會導致你的測試異常的複雜,並且所有互動鏈路上的 mock 類可能都耦合在了一起,當其中一個修改時,可能會導致整個測試失敗。
- 暴露本不需要暴露的介面。由於需要 mock 每一個類之間的互動,就需要為每一個類之間的互動建立介面,這將會導致你需要創建出許多隻用於 mock 物件的介面,這是一種過度抽象和可怕的設計損壞。
過度使用 mock 物件,將會使用測試變得緩慢、脆弱、複雜,並且有可能損壞你的軟體設計。
mock 的使用建議
在架構的重要邊界使用 mock ,不要在邊界內部使用 mock
例如可以在資料庫、web伺服器等所有第三方服務的邊界處使用 mock 。可以參考如下的整潔架構圖:
可以在最外環的邊界處使用 mock 隔離外部依賴,方便測試,這樣做可以得到如下的好處:
- 測試執行速度快。
- 測試不會因為外部依賴的錯誤而失敗。
- 更容易的模擬測試外部依賴的所有異常情況。
- 橫跨邊界的有限狀態機的每條路徑都可以被測試。
- mock 不在需要相互耦合依賴,程式碼會更整潔。
另一個比較大的好處是它強迫你思考找出軟體的重要邊界,並且為它們定義介面,這使得你的軟體不會強耦合依賴於邊界外的元件。因此你可以獨立開發部署邊界兩邊的元件。像這樣去分離架構關注點是一個很好的軟體設計原則。
使用你自己的 mock
mock 工具有它們自己的領域語言,在使用它們之前你必須先學習它。通過前面的 mock 型別介紹,我們已經知道用的最多的 mock 是 stub 和 spy 型別,而由於現在的 IDE 可以很方便的生成這些 mock 程式碼,我們只需要稍作修改就可以直接使用,所以綜合來看,我們一般情況下是不需要使用 mock 工具的。
由於你自己寫 mock 時不會使用反射,這將會讓你的測試程式碼執行速度更快。如果你決定使用 mock 工具,請儘量少的使用它。
總結
mock 物件既不能完全不使用,也不能過度使用。我們應該在軟體的重要邊界處使用 mock ,要儘量少的使用 mock 工具,使用 mock 工具時不要過度依賴它,我們應該儘量使用輕量級的 stub 和 spy 的 mock 型別,並且我們應該自己手寫這些簡單的 mock 型別。如果你這樣做了,你會發現你的測試執行速度更快,更穩定,並且還會有更高的測試覆蓋率,你的軟體架構設計也會越來越好。