編寫可測試 Go 代碼的一種模式

UT(單元測試)是個好東西,我們每個人都愛它。當寫完一段功能複雜的邏輯時,各種變態的測試樣例能增強我們對這段邏輯的信心;當更改別人的代碼時,好的 UT coverage 能幫我們確保這次的更改不會影響到其他的代碼;當閱讀別人代碼時,相應的 UT 也是一份文檔,能告訴我們這段代碼所實現的功能。因此我們總是希望別人的代碼能有 UT,但自己卻很少寫 UT,這是爲什麼呢?🤔

以我有限的經驗來看,原因大概可以分成這兩類:

可以看到,除了不可控的外因外,單元測試的難點就是替換依賴(mock),如果依賴能夠簡單的替換掉,那代碼就變得很容易測試了。下面我們就來看看兩種常見的替換方法:

Monkey Patch

在動態語言(js/python)的世界裏,函數和方法是可以被隨意修改的,因而在它們的單元測試中,用monkey patch來 mock 依賴是再常見不過的事了。但 Go 是個強類型的語言,monkey patch既違反了語言的特性,也遠沒有像動態語言裏面那麼靈活,即使費盡力氣使用上了,那段代碼也是充滿了黑科技,很容易讓其他人掉進坑裏。所以,如果不是萬不得已,一般情況下還是不建議使用這種傷敵一千自損八百的大殺器的。

理想狀況下,一段測試代碼應當是簡單、可維護的,它的複雜度不應當超過被測試的業務代碼,下面介紹的一種方法就很容易達到這個目的。

Interface + Dependency Injection

在 Go 語言中,接口(interface)是對一個對象的抽象性描述,表明該對象能提供什麼樣的服務。它最主要的作用就是解耦調用者和實現者,這成爲了可測試代碼的關鍵。甚至有人說:

如果一個略有規模的項目中沒有出現任何 interface 的定義,那麼我們可以推測出這在很大的概率上是一個代碼質量堪憂並且沒有多少單元測試覆蓋的項目

如果我們的代碼都是面向接口編程,那依賴注入(dependency injection)就很容易實現。如果依賴注入被大量使用,那替換掉依賴將會變成一件輕而易舉的事情。把這兩者結合,就得到了一種編寫可測試代碼的模式:

  1. 將代碼的依賴抽象出來,抽象成一個接口,並且這個接口的實例不是自己創建出來,而是由上層調用方注入進來。

  2. 將第三方依賴封裝成上面接口的一種實現,調用方負責創建具體的實例,並注入進業務代碼。

有了這一層松耦合的依賴關係,在測試代碼裏,我們就可以 mock 出另一種接口的實現,從而很容易的替換掉第三方的依賴。

理論就這麼簡單,下面通過一個具體的例子實戰一下,看看怎樣用這個模式來重構一段代碼,提升它的可測試性。

Code in Action

比方說我們有一個電商系統中的交易類transaction,用來記錄每筆訂單的交易情況。其中的Execute()函數負責執行轉賬操作,將錢從買家的賬戶轉移到賣家的賬戶中,而真正的轉賬操作則是通過調用銀行(支付寶、微信)的 SDK 完成的:

type transaction struct {
    ID       string
    BuyerID  int
    SellerID int
    Amount   float64
    createdAt time.Time
    Status TransactionStatus
}

func (t *transaction) Execute() bool {
    if t.Status == Executed {
        return true
    }
    if time.Now() - t.createdAt > 24.hours { // 交易有有效期
        t.Status = Expired
        return false
    }
    client := BankClient.New(config.token) // 調用銀行的 SDK 執行轉賬
    if err := client.TransferMoney(id, t.BuyerID, t.SellerID, t.Amount); err != nil {
        t.Status = Failed
        return false
    }
    t.Status = Executed
    return true
}

這個類最重要的功能集中在Execute()函數中,但它卻不好測試,因爲它有兩個外部依賴:

  1. 行爲不確定的time.Now函數,它的每一次調用都會產生不同的結果。

  2. 銀行提供的轉賬 SDK,我們不可能每次測試都去真的調用一下,那測試成本也忒高了。

解決方法就是把這兩個依賴 mock 掉,即用一個 “假的” 服務來替換真的服務,這裏我們先拿測試成本較高的銀行 SDK 試水。

Mock SDK Dependency

按照上面的理論,先將代碼裏使用到的方法抽象成一個接口(目前這個接口只包含一個方法,當然實際的場景下抽象出來的接口肯定比這個複雜):

type Transferer interface {
    TransferMoney(id int, buyerID int, sellerID int, amount float64) error
}

然後將創建BankClient的行爲上移到調用者那邊去,相當於調用者創建了一個滿足Transferer接口的實例,再注入進我們的代碼。所以transaction這邊就需要有個地方來接受這個實例,一個方法是通過Execute()函數的參數,但如果依賴過多的話,會造成函數參數爆炸,另一個則是放到transaction的成員屬性中。這裏我們採用更常見的第二個方法,因此重構後的transaction類及其構造函數就變成了這樣:

type transaction struct {
    ID       string
    BuyerID  int
    SellerID int
    Amount   float64
    createdAt time.Time
    Status TransactionStatus
    // 增加了一個存放接口的屬性
    transferer Transferer
}

func New(buyerID, sellerID int, amount float64, transferer Transferer) *transaction {
    return &transaction{
        ID:         IdGenerator.generate(),
        BuyerID:    buyerID,
        SellerID:   sellerID,
        Amount:     amount,
        createdAt:  time.Now(),
        Status:     TO_BE_EXECUTD,
        transferer: transferer, // 注入進 transaction 類中
    }
}

func (t *transaction) Execute() bool {
    //...
    //不直接創建,而是使用別人注入的接口實例
    t.transferer.TransferMoney(id, t.BuyerID, t.SellerID, t.Amount)
    //...
}

現在,我們在單元測試中就能夠很方便的替換掉那個成本高昂的支付接口的調用了。

// 定義一個滿足 Transferer 接口的 mock 類
type MockedClient struct {
    responseError error // 實例化的時候可以將期望的返回值保存進來
}

func (m *MockedClient) TransferMoney(id int, buyerID int, sellerID int, amount float64) error {
    return m.responseError
}

func Test_transaction_Execute(t *testing.T) {
    // 實例化一個可以自由控制結果的 client
    transferer := &MockedClient{
        responseError: errors.New("insufficient balance"),
    }
    tnx := New(buyerID, sellerID, amount, transferer)
    if succeeded := tnx.Execute(); succeeded != false {
        t.Errorf("Execute() = %v, want %v", succeeded, false)
    }
}

第三方 SDK 的替換問題解決了,我們再來看看對交易過期這種情況的測試。

最直觀的方法就是將createdAt屬性設爲 24 小時之前,這樣就可以模擬出過期這個場景了。但這不是一個好的解決方案,因爲在我們的實現中,createdAt是個私有屬性,它是在交易生成時(即構造函數中)自動獲取的系統時間,外界不應該去幹預它,否則就破壞了類的封裝性。所以我們應該想辦法去替換掉time.Now的行爲。

Mock time.Now

事實上,怎樣 mock 當前時間是一個很常見的問題,類似的函數還有rand.Intn,他們的共同點就是輸出是不確定的,這就讓我們的測試無法覆蓋所有的情況。面對這些函數,我們當然可以像上面一樣用一個接口封裝一下,但對於這麼一個無毒無副作用的 util 函數,用 OOP 的那一套封裝一下不免有點小題大做。這方面更常見的一種做法是利用函數在 Go 裏面是一等公民,引入一箇中間變量解耦一下。

即業務代碼不直接通過調用time.Now獲得當前時間,而是通過一箇中間人獲得,而這個中間人被外界賦值爲了time.Now。跟上面一樣,這個中間人可以通過成員屬性和函數參數的方式注入進來,或者偷懶直接定義爲一個全局變量。下面來看看這種偷懶的做法(如果單元測試是並行執行的t.Parallel(),最好不要這麼做):

var nowFn = time.Now //一個全局變量,用來解耦time.Now的生產者和消費者

func (t *transaction) Execute() bool {
    if t.Status == Executed {
        return true
    }
    if nowFn() - t.createdAt > 24.hours { // 不直接調用time.Now()
        t.Status = Expired
        return false
    }
    //...
}

這樣,我們的單元測試就能隨心所欲的改變 “當前時間” 了

func Test_expired_transaction_Execute(t *testing.T) {
    // 用同樣的函數簽名改寫業務中需要的時間函數
    // 這裏能改變私有的全局變量是因爲測試代碼跟業務代碼處於同一個包中
    nowFn = func() time.Time {
        return time.Now().Add(-24 * time.Hour)
    }
    // 依舊需要實例化一個假的的 client
    transferer := &MockedClient{
        responseError: nil,
    }
    tnx := New(buyerID, sellerID, amount, transferer)
    //...
}

這一次的 mock 雖然沒有像上個那樣顯式的定義一個接口出來,但我們隱式的複用了time.Now函數簽名,將它當做一種 “接口類型” 來使用。可以看到,其實這兩個 mock 用到的解耦思想都是一樣的。

Best Practices

在使用接口 + 依賴注入實現第三方服務替換的這條路上,這裏還有些值得分享的經驗,讓單元測試的編寫更輕鬆:

調用方最清楚自己使用了第三方服務的哪幾個方法。本着最小依賴的原則,注入的接口最好是由自己定義的,而不要使用第三方服務提供的大而全的接口,這樣在 mock 的時候就能減輕不少工作量。這也是 Dave 的觀點:

#golang top tip: the consumer should define the interface. If you’re defining an interface and an implementation in the same package, you may be doing it wrong.

調用者應該負責定義接口,如果在一個包中同時定義了接口和實現,那麼你可能就做錯了。

有的依賴調用入參和出參比較複雜,如果原封不動的抽象成一個接口,那測試代碼裏就要花很大篇幅去構造那些參數。這個時候我們可以重新封裝一下,將原接口的抽象範圍擴大,使得整個接口的輸入和輸出變得更簡單、更有業務含義。

這是 Goland 的一個小功能,它能自動生成一堆 table driven tests 模板代碼,我們只要往裏面填測試數據就行了,這極大的加快了 UT 的編寫。具體使用方法是在函數的任意位置右擊,從彈出的菜單欄裏選擇Generate...,然後就會出現Test for function這個功能了。

https://github.com/golang/go/wiki/TableDrivenTests

Conclusion

理論上來說,單元測試的難點應當在于思考的縝密性,因爲要考慮到各種臨界情況。如果你寫單元測試的時候主要精力不是花在這裏,而是想着怎樣用黑魔法改變某個函數的底層行爲,那很可能你的方向就走錯了。如果我們的代碼都用接口 + 依賴注入的方式解耦掉了,依賴都做成可插拔的,那單元測試裏面隔離依賴就是一件水到渠成的事情。

當然,這只是提高代碼可測試性的一種途徑,如果你還有其他方法,歡迎與我交流。

轉自:

blog.betacat.io/post/2020/03/a-pattern-for-writing-testable-go-code

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Vgr_UqW_PpfzmtOMoUTDPA