- 原文地址:A History of Testing in Go at SmartyStreets
- 原文作者:Michael Whatcott
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:kasheemlew
- 校對者:StellaBauhinia
最近常有人問我這兩個有趣的問題:
這兩個問題很好,作為 GoConvey 的聯合創始人兼 gunit 的主要作者,我也有責任將這兩個問題解釋清楚。直接回答,太長不讀系列:
問題 1:為什麼換用 gunit?
在使用 GoConvey 的過程中,有一些問題一直困擾著我們,所以我們想了一個更能體現測試庫中重點的替代方案,以解決這些問題。在當時的情況中,我們已經無法對 GoConvey 做過渡升級方案了。下面我會更仔細介紹一下,並提煉到簡明的宣明式結論。
問題 2:你是否建議大家都這麼做(從 GoConvey 換成 gunit)?
不。我只建議你們使用能幫助你們達成目標的工具和庫。你得先明確自己對測試工具的需求,然後再儘快去找或者造適合自己的工具。測試工具是你們構建專案的基礎。如果你對後面的內容產生了共鳴,那麼 gunit 會成為你選型中一個極具吸引力的選項。你得好好研究,然後慎重選擇。GoConvey 的社群還在不斷成長,並且擁有很多活躍的維護者。如果你很想支援一下這個專案,隨時歡迎加入我們。
很久以前在一個遙遠的星系...
Go 測試
我們初次使用 Go 大概是在 Go 1.1 釋出的時候(也就是 2013 年年中),在剛開始寫程式碼的時候,我們很自然地接觸到了 go test
和 "testing"
包。我很高興看到 testing 包被收進了標準庫甚至是工具集中,但是對於它慣用的方法並沒有什麼感覺。後文中,我們將使用著名的“保齡球遊戲”練習對比展示我們使用不同測試工具後得到的效果。(你可以花點時間熟悉一下生產程式碼,以便更好地瞭解後面的測試部分。)
下面是用標準庫中的 "testing"
包編寫保齡球遊戲測試的一些方法:
import "testing"
// Helpers:
func (this *Game) rollMany(times, pins int) {
for x := 0; x < times; x++ {
this.Roll(pins)
}
}
func (this *Game) rollSpare() {
this.rollMany(2, 5)
}
func (this *Game) rollStrike() {
this.Roll(10)
}
// Tests:
func TestGutterBalls(t *testing.T) {
t.Log("Rolling all gutter balls... (expected score: 0)")
game := NewGame()
game.rollMany(20, 0)
if score := game.Score(); score != 0 {
t.Errorf("Expected score of 0, but it was %d instead.", score)
}
}
func TestOnePinOnEveryThrow(t *testing.T) {
t.Log("Each throw knocks down one pin... (expected score: 20)")
game := NewGame()
game.rollMany(20, 1)
if score := game.Score(); score != 20 {
t.Errorf("Expected score of 20, but it was %d instead.", score)
}
}
func TestSingleSpare(t *testing.T) {
t.Log("Rolling a spare, then a 3, then all gutters... (expected score: 16)")
game := NewGame()
game.rollSpare()
game.Roll(3)
game.rollMany(17, 0)
if score := game.Score(); score != 16 {
t.Errorf("Expected score of 16, but it was %d instead.", score)
}
}
func TestSingleStrike(t *testing.T) {
t.Log("Rolling a strike, then 3, then 7, then all gutters... (expected score: 24)")
game := NewGame()
game.rollStrike()
game.Roll(3)
game.Roll(4)
game.rollMany(16, 0)
if score := game.Score(); score != 24 {
t.Errorf("Expected score of 24, but it was %d instead.", score)
}
}
func TestPerfectGame(t *testing.T) {
t.Log("Rolling all strikes... (expected score: 300)")
game := NewGame()
game.rollMany(21, 10)
if score := game.Score(); score != 300 {
t.Errorf("Expected score of 300, but it was %d instead.", score)
}
}
複製程式碼
對於之前使用過 xUnit 的人,下面兩點會讓你很難受:
- 由於沒有統一的
Setup
函式/方法可以使用,所有遊戲中需要不斷重複建立 game 結構。 - 所有的斷言錯誤資訊都得自己寫,並且混雜在一個 if 表示式中,由它來以反義檢驗你所編寫的正向斷言語句。在使用比較運算子(
<
、>
、<=
和>=
)的時候,這些否定斷言會更加惱人。
所以,我們調研如何測試,深入瞭解為什麼 Go 社群放棄了“我們最愛的測試幫手”和“斷言方法”的觀點,轉而使用“表格驅動”測試來減少模板程式碼。用表格驅動測試重新寫一遍上面的例子:
import "testing"
func TestTableDrivenBowlingGame(t *testing.T) {
for _, test := range []struct {
name string
score int
rolls []int
}{
{"Gutter Balls", 0, []int{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
{"All Ones", 20, []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}},
{"A Single Spare", 16, []int{5, 5, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
{"A Single Strike", 24, []int{10, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
{"The Perfect Game", 300, []int{10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10}},
} {
game := NewGame()
for _, roll := range test.rolls {
game.Roll(roll)
}
if score := game.Score(); score != test.score {
t.Errorf("FAIL: '%s' Got: [%d] Want: [%d]", test.name, score, test.score)
}
}
}
複製程式碼
不錯,這和之前的程式碼完全不一樣。
優點:
- 新的程式碼短多了!整套測試現在只有一個測試函式了。
- 使用迴圈語句解決了 setup 重複的問題。
- 相似的,使用者只會從一條斷言語句中獲取錯誤碼。
- 在 debug 的過程中,可以很容易地在 struct 的定義中加一個
skip bool
來跳過一些測試
缺點:
- 匿名 struct 的定義和迴圈的宣告混在一起,看起來很奇怪。
- 表格驅動測試只在一些比較簡單的,只涉及資料讀入/讀出的情況下才比較有效。當情況逐漸複雜起來的時候,它會變得很笨重,也不容易(或者說不可能)用單一的 struct 對整個測試進行擴充套件。
- 使用 slice 表示 throws/rolls 很“煩人”。雖然動動腦筋我們還是可以簡化一下的,但是這會讓我們的模板程式碼的邏輯變複雜。
- 儘管只用寫一條斷言語句,但是這種間接/否定式的測試還是讓我很憤怒。
GoConvey
現在,我們不能僅僅滿足於開箱即用的 go test
,於是我們開始使用 Go 提供的工具和庫來實現我們自己的測試方法。如果你仔細看過 SmartyStreets GitHub page,你會注意到一個比較有名的倉庫 — GoConvey。它是我們對 Go OSS社群貢獻的最早的專案之一。
GoConvey 可以說是一個雙管齊下的測試工具。首先,有一個測試執行器監控你的程式碼,在有變化的時候執行 go test
,並將結果渲染成炫酷的網頁,然後用瀏覽器展示出來。其次,它提供了一個庫讓你可以在標準的 go test
函式中寫行為驅動開發風格的測試。還有一個好訊息:你可以自由選擇不使用、部分使用或者全部使用 GoConvey 中的這些功能。
有兩個原因促使我們開發了 GoConvey:重新開發一個我們本來打算在 JetBrains IDEs 中完成的測試執行器(我們當時用的是 ReSharper)以及創造一套我們很喜歡的像 nUnit 和 Machine.Specifications(在開始使用 Go 之前我們是 .Net 商店)那樣的測試組合和斷言。
下面是用 GoConvey 重寫上面測試的效果:
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestBowlingGameScoring(t *testing.T) {
Convey("Given a fresh score card", t, func() {
game := NewGame()
Convey("When all gutter balls are thrown", func() {
game.rollMany(20, 0)
Convey("The score should be zero", func() {
So(game.Score(), ShouldEqual, 0)
})
})
Convey("When all throws knock down only one pin", func() {
game.rollMany(20, 1)
Convey("The score should be 20", func() {
So(game.Score(), ShouldEqual, 20)
})
})
Convey("When a spare is thrown", func() {
game.rollSpare()
game.Roll(3)
game.rollMany(17, 0)
Convey("The score should include a spare bonus.", func() {
So(game.Score(), ShouldEqual, 16)
})
})
Convey("When a strike is thrown", func() {
game.rollStrike()
game.Roll(3)
game.Roll(4)
game.rollMany(16, 0)
Convey("The score should include a strike bonus.", func() {
So(game.Score(), ShouldEqual, 24)
})
})
Convey("When all strikes are thrown", func() {
game.rollMany(21, 10)
Convey("The score should be 300.", func() {
So(game.Score(), ShouldEqual, 300)
})
})
})
}
複製程式碼
和表格驅動的方法一樣,整個測試都包含在一個函式中。又像在原來的例子中一樣,我們通過一個輔助函式進行重複的 rolls/throw。不同於其他的例子,我們現在已經擁有了一個巧妙的、不繁瑣的、基於作用域的執行模型。所有的測試共享了 game
變數,但 GoConvey 的奇妙之處在於每個外層作用域都針對每個內層作用域執行。所以,每一個測試之間又相對隔離。顯然,如果不注意初始化和作用域的話,你很容易就會陷入麻煩。
另外,當你將對 Convey 的呼叫加入到迴圈中時(例如嘗試將 GoConvey 和表格驅動測試組合起來使用),可能會發生一些詭異的事情。*testing.T
完全由頂層的 Convey
呼叫管理(你注意到它和其他的 Convey
稍有不同了嗎?),因此你也不必在所有需要斷言的地方都傳遞這個引數。但是如果用 GoConvey 寫過任何稍微複雜點的測試的話,你就會發現取出輔助函式的過程相當複雜。在我決定繞過這個問題之前,我建了一個 固定結構
來存放所有測試的狀態,然後在這個結構裡建立 Convey
的回撥會用到的函式。所以一會是 Convey 的塊和作用域,一會又是固定結構和它的方法,這看起來就很奇怪了。
gunit
所以,儘管我們花了點時間,但最終還是意識到我們只是想要一個 Go 版本的 xUint,它需要摒棄奇怪的點匯入和下劃線包等級註冊變數(看看你的 GoCheck)。我們還是很喜歡 GoConvey 中的斷言,於是從原來的專案中分裂出了一個獨立的倉庫,gunit 就這樣誕生了:
import (
"testing"
"github.com/smartystreets/assertions/should"
"github.com/smartystreets/gunit"
)
func TestBowlingGameScoringFixture(t *testing.T) {
gunit.Run(new(BowlingGameScoringFixture), t)
}
type BowlingGameScoringFixture struct {
*gunit.Fixture
game *Game
}
func (this *BowlingGameScoringFixture) Setup() {
this.game = NewGame()
}
func (this *BowlingGameScoringFixture) TestAfterAllGutterBallsTheScoreShouldBeZero() {
this.rollMany(20, 0)
this.So(this.game.Score(), should.Equal, 0)
}
func (this *BowlingGameScoringFixture) TestAfterAllOnesTheScoreShouldBeTwenty() {
this.rollMany(20, 1)
this.So(this.game.Score(), should.Equal, 20)
}
func (this *BowlingGameScoringFixture) TestSpareReceivesSingleRollBonus() {
this.rollSpare()
this.game.Roll(4)
this.game.Roll(3)
this.rollMany(16, 0)
this.So(this.game.Score(), should.Equal, 21)
}
func (this *BowlingGameScoringFixture) TestStrikeReceivesDoubleRollBonus() {
this.rollStrike()
this.game.Roll(4)
this.game.Roll(3)
this.rollMany(16, 0)
this.So(this.game.Score(), should.Equal, 24)
}
func (this *BowlingGameScoringFixture) TestPerfectGame() {
this.rollMany(12, 10)
this.So(this.game.Score(), should.Equal, 300)
}
func (this *BowlingGameScoringFixture) rollMany(times, pins int) {
for x := 0; x < times; x++ {
this.game.Roll(pins)
}
}
func (this *BowlingGameScoringFixture) rollSpare() {
this.game.Roll(5)
this.game.Roll(5)
}
func (this *BowlingGameScoringFixture) rollStrike() {
this.game.Roll(10)
}
複製程式碼
可以看到,去除輔助方法的過程很繁瑣,這是因為我們是在操作結構級的狀態,而不是函式的區域性變數的狀態。此外,xUnit 中配置/測試/清除的執行模型比 GoConvey 中的作用域執行模型好懂多了。這裡,*testing.T
現在由嵌入的 *gunit.Fixture
管理。這種方式對於簡單的和基於互動的複雜測試來說同樣直觀好懂。
gunit 和 GoConvey 的另一個巨大區別是,按照 xUnit 的測試模式,GoConvey 使用共享的固定結構而 gunit 使用全新的固定結構。這兩種方法都有道理,主要還是看你的應用場景。全新的固定結構通常在單元測試中更能讓人滿意,而共享的固定結構在一些配置消耗比較大的情況下更有利,例如整合測試或系統測試。
全新的固定結構更能保證分開的測試項之間是相互獨立的,因此 gunit 預設使用 t.Parallel()
。同樣的,因為我們只用反射呼叫子測試,所以也可以使用 -run
引數挑選特定的測試項執行:
$ go test -v -run 'BowlingGameScoringFixture/TestPerfectGame'
=== RUN TestBowlingGameScoringFixture
=== PAUSE TestBowlingGameScoringFixture
=== CONT TestBowlingGameScoringFixture
=== RUN TestBowlingGameScoringFixture/TestPerfectGame
=== PAUSE TestBowlingGameScoringFixture/TestPerfectGame
=== CONT TestBowlingGameScoringFixture/TestPerfectGame
--- PASS: TestBowlingGameScoringFixture (0.00s)
--- PASS: TestBowlingGameScoringFixture/TestPerfectGame (0.00s)
PASS
ok github.com/smartystreets/gunit/advanced_examples 0.007s
複製程式碼
但不可否認,一些之前的樣本程式碼仍然存在(比如檔案頭部的一些程式碼)。我們在 GoLand 中安裝了下面的實時模板,這些會自動生成前面大部分的內容。下面是在 GoLand 中安裝實時模板的命令:
- 在 GoLand 中開啟偏好設定。
- 在
編輯器/實時模板
中選中Go
列表,然後點選+
號並選擇“實時模板” - 給他取個縮寫名(我們用的是
fixture
) - 將下面的程式碼貼上到
模板文字
區域:
func Test$NAME$(t *testing.T) {
gunit.Run(new($NAME$), t)
}
type $NAME$ struct {
*gunit.Fixture
}
func (this *$NAME$) Setup() {
}
func (this *$NAME$) Test$END$() {
}
複製程式碼
- 在那之後,點選“未指定應用上下文”警告旁邊的
定義
。 - 在
Go
前面打個勾然後點OK
。
現在我們只用開啟一個測試檔案,輸入 fixture
然後用 tab 自動補全測試模板就行了。
結論
讓我效仿敏捷軟體開發宣言的風格來做個總結:
我們不斷實踐、幫助他人,最終發現了更好的方法來進行軟體測試。這讓我們實現了很多有價值的東西:
- 在共享的固定結構的基礎上實現了全新的固定結構
- 用巧妙的作用域語義實現了簡單的執行模型
- 用區域性函式(或者說包級的)變數作用域實現了結構級作用域
- 通過倒置的檢查和手動建立的錯誤資訊實現了直接的斷言函式
也就是說,雖然其他的測試庫也很不錯(這是一方面),我們更喜歡 gunit(這是另一方面)。
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。