[譯] SmartyStreets 的 Go 測試探索之路
最近常有人問我360%23issuecomment-368348056" rel="nofollow,noindex">這兩個有趣的問題 :
- 你為什麼將測試工具(從GoConvey)換成gunit ?
- 你建議大家都這麼做嗎?
這兩個問題很好,作為 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 { namestring 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) } } } 複製程式碼
不錯,這和之前的程式碼完全不一樣。
優點:
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' === RUNTestBowlingGameScoringFixture === PAUSE TestBowlingGameScoringFixture === CONTTestBowlingGameScoringFixture === RUNTestBowlingGameScoringFixture/TestPerfectGame === PAUSE TestBowlingGameScoringFixture/TestPerfectGame === CONTTestBowlingGameScoringFixture/TestPerfectGame --- PASS: TestBowlingGameScoringFixture (0.00s) --- PASS: TestBowlingGameScoringFixture/TestPerfectGame (0.00s) PASS okgithub.com/smartystreets/gunit/advanced_examples0.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 、前端 、後端 、區塊鏈 、產品 、設計 、人工智慧 等領域,想要檢視更多優質譯文請持續關注掘金翻譯計劃 、官方微博、知乎專欄 。