Go 有一個強大的內置測試庫. 如果你使用過 Go 語言, 那你應該已經知道了這一點. 在這篇文章中, 我們將討論一些有效的技巧來幫助你在 Go 語言中進行更好的測試, 這些技巧是我們從我們的大型 Go 代碼庫中獲得的經驗, 這些技巧可以節省維護代碼的時間和精力.

使用測試套件

如果你只能從這篇文章中學到一件事, 那麼就應該是: 使用測試套件. 對於那些不熟悉這種模式的人來說, 測試套件就是針對一個通用的介面開發一個測試過程, 這個測試過程可以用來對這個介面的多個實現進行測試. 下面的代碼演示瞭如果針對不同 Thinger 的實現使用相同的測試:

type Thinger interface {
DoThing(input string) (Result, error)
}

// Suite tests all the functionality that Thingers should implement
func Suite(t *testing.T, impl Thinger) {
res, _ := impl.DoThing("thing")
if res != expected {
t.Fail("unexpected result")
}
}

// TestOne tests the first implementation of Thinger
func TestOne(t *testing.T) {
one := one.NewOne()
Suite(t, one)
}

// TestOne tests another implementation of Thinger
func TestTwo(t *testing.T) {
two := two.NewTwo()
Suite(t, two)
}

有些讀者也許已經在代碼中使用了這種測試技術. 這種技巧在基於插件的系統中廣泛使用, 通常是針對介面編寫適用於該介面的所有實現的測試, 以確定實現是否滿足行為要求.

使用這種技巧可以節省數小時, 數天甚至更多的時間. 而且, 這可以在交換兩個底層系統時避免編寫(很多)額外的測試, 也可以確保不會破壞應用程序的正確性. 這隱含的要求你提供一個方式來指定需要測試的實現, 使用依賴注入你可以將需要測試的實現傳遞給測試套件.

這裡 提供了一個完整的例子. 雖然這個例子是故意設計的, 但是你可以想像其中的一個實現是遠程資料庫, 另一個實現是內存資料庫.

在標準庫中有一個很好的例子是 golang.org/x/net/nettest 包, 它提供了一個滿足 net.Conn 的介面來進行測試驗證.

空介面污染

在 Go 語言中沒有介面就沒有測試.

介面在測試環境中非常重要, 因為它們是我們測試庫中最強大的工具, 所以正確的使用介面是非常重要的. 包中經常導出介面提供給使用者使用, 這一般會出現兩種情況: 使用者提供他們自己的實現或者該包提供自己的實現.

The bigger the interface, the weaker the abstraction.

-- Rob Pike, Go Proverbs

在導出之前, 應該仔細的考慮介面的定義. 開發者經常導出介面讓使用者來實現他自己的行為. 相反的, 應該在文檔中描述你的結構滿足哪些介面, 這樣你就不會在使用者和你自己的包之間創建一個強的依賴關係. errors 包就是一個很好的例子.

當我們的程序中有一個我們不想導出的介面時, 可以使用一個內部包/子樹來隱藏它們. 通過這種方式, 可以避免其他使用者依賴於這些介面, 因此可以靈活的改變這些介面來適應新的需求. 我們通常圍繞外部依賴創建介面, 並使用依賴注入的方式來運行本地測試.

這允許使用者能夠實現自己的小介面, 並且提供給自己測試. 有關這些概念的更多細節, 可以參考 rakyll 的文章.

不要導出並發原語

Go 提供了易於使用的並發原語, 但是有時也會被過度使用. 我們主要關注 channel 和 sync 包. 有些時候從使用者中導出 channel 是看上去很美好的事情. 另外, 包含 sync.Mutex 而不把它作為私有成員是一個常見的錯誤. 當然這並不總是很糟糕的一件事, 但是在測試程序時確實會帶來一些挑戰.

如果你正在導出 channel 給你的使用者, 那你會為使用者帶來他們不應該關心的額外的複雜度. 只要從一個包中導出了 channel, 就會為使用者的測試編寫帶來麻煩, 如果要做好這個測試, 使用者需要知道:

  • 什麼時候需要在 channel 上發送數據發送?
  • 接收數據時是否有任何錯誤?
  • 在 channel 使用完成後如何進行清理?
  • 怎樣才能包裝出一些 API , 以避免直接調用 channel ?

考慮下面這個示例庫中的一個消費隊列的例子, 它讀取消息並暴露一個 channel 供使用者訂閱:

type Reader struct {...}
func (r *Reader) ReadChan() <-chan Msg {...}

一個使用者需要像下面這樣編寫他的測試:

func TestConsumer(t testing.T) {
cons := &Consumer{
r: libqueue.NewReader(),
}
for msg := range cons.r.ReadChan() {
// Test thing.
}
}

使用者可能會使用依賴注入並寫下如下的測試代碼:

func TestConsumer(t testing.T, q queueIface) {
cons := &Consumer{
r: q,
}
for msg := range cons.r.ReadChan() {
// Test thing.
}
}

如果考慮到錯誤呢?

func TestConsumer(t testing.T, q queueIface) {
cons := &Consumer{
r: q,
}
for {
select {
case msg := <-cons.r.ReadChan():
// Test thing.
case err := <-cons.r.ErrChan():
// What caused this again?
}
}
}

現在, 我們如何生成事件來複制我們正在使用的實際的庫的行為? 如果這個庫只是一個簡單的同步 API, 那麼我們可以在客戶端添加所有的並發處理, 然後使測試更為簡單:

func TestConsumer(t testing.T, q queueIface) {
cons := &Consumer{
r: q,
}
msg, err := cons.r.ReadMsg()
// handle err, test thing
}

如果有疑問, 請記住在包中添加並發的代碼總是容易的, 但是移除是非常苦難甚至是不可能的. 最後, 不要忘記在文檔中提示一個包或者結構體是否是並發安全的.

有時候, 導出一個 channel 也是可取的或者必要的. 為了在這種情況下緩解上述的問題, 可以通過訪問者模式替代直接暴露 channel, 並強制在訪問者中申明 channel 是隻讀或者只寫的.

使用 net/http/httptest 測試 http 代碼

httptest 運行你在不啟動一個伺服器或者綁定到一個埠的情況下運行你的 http.Handler 代碼. 這可以加快測試速度, 並允許它們盡量的並行.

以下是使用兩種方法實現的相同測試的示例代碼, 它看起來不多, 但是它為您節省了大量的代碼和資源:

func TestServe(t *testing.T) {
// The method to use if you want to practice typing
s := &http.Server{
Handler: http.HandlerFunc(ServeHTTP),
}
// Pick port automatically for parallel tests and to avoid conflicts
l, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
go s.Serve(l)

res, err := http.Get("http://" + l.Addr().String() + "/?sloths=arecool")
if err != nil {
log.Fatal(err)
}
greeting, err := ioutil.ReadAll(res.Body)
res.Body.Close()
if err != nil {
log.Fatal(err)
}
fmt.Println(string(greeting))
}

func TestServeMemory(t *testing.T) {
// Less verbose and more flexible way
req := httptest.NewRequest("GET", "Example Domain", nil)
w := httptest.NewRecorder()

ServeHTTP(w, req)
greeting, err := ioutil.ReadAll(w.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(greeting))
}

使用 httptest 最大的優勢就是可以只運行你想要測試的功能. 不會有你以前認為的是好的, 從伺服器或其他可憎的事物中帶來的路由, 中間件或者其他的副作用,

你可以從 Mark Berger 的這篇文章中看到更多的這樣的模式.

使用一個單獨的 _test包

在 Go 語言中大多數的測試都是在相同的包中創建一個 pkg\_test.go 文件並在該文件中編寫測試代碼. 一個單獨的測試包是指在你想要測試的包 foo 的目錄中新建一個 foo\_test 文件, 並且這個文件在包 foo\_test 中. 在這種情況下, 你可以從 github.com/example/foo 那裡導入你的額依賴. 這個方式提供了以下好處: 這是測試中存在循環依賴關係的推薦解決辦法, 可以防止脆弱的測試, 並允許開發人員感受使用自己的軟體包的感覺. 如果你的軟體包很難被使用, 那麼使用這種方法也可能很難進行測試.

這個技巧通過限制對私有變數的訪問來防止脆弱的測試. 特別是, 如果你的測試出錯了, 而且你使用的是一個單獨的測試包, 那麼使用這個包的客戶端也會出現錯誤.

最後, 這種方式可以避免循環依賴. 大多數包都會依賴於在其他包, 因此可能會遇到循環依賴的情況. 外部程序包位於包結構中的兩個包之上. 以 Go Programming Language (Chp. 11 Sec 2.4) 為例, net/url 實現了 net/http 包導入使用的 URL 解析器, 但是 net/url 想要通過導入 net/http 來進行測試, 於是 net/url_test 包出現了.

如果你正在使用單獨的測試包, 那麼你可能需要訪問在被測試包中沒有導出的實體. 大多數人都在測試基於時間的值時(例如 time.Now) 首先碰到這個問題. 在這種情況下, 我們可以使用額外的文件在測試期暴露它們, 因為 _test.go 文件在常規構建時是被排除在外的.

一些其他的點

需要注意的是沒有銀彈, 最好的解決方案始終是對情況進行批判性分析, 並確定適合問題的最佳解決方案.

如果想要了解更多的測試技術, 可以查看以下帖子:

  • Writing Table Driven Tests in Go by Dave Cheney
  • The Go Programming Language chapter on Testing.

或者這些視頻:

  • Hashimoto』s Advanced Testing With Go talk from Gophercon 2017
  • Andrew Gerrand&#x27;s Testing Techniques talk from 2014

資料

  • 原文 5 Advanced Testing Techniques in Go

推薦閱讀:

相關文章