[譯] SmartyStreets 的 Go 測試探索之路

kasheemlew發表於2018-09-24

最近常有人問我這兩個有趣的問題

  1. 你為什麼將測試工具(從 GoConvey)換成 gunit
  2. 你建議大家都這麼做嗎?

這兩個問題很好,作為 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 的人,下面兩點會讓你很難受:

  1. 由於沒有統一的 Setup 函式/方法可以使用,所有遊戲中需要不斷重複建立 game 結構。
  2. 所有的斷言錯誤資訊都得自己寫,並且混雜在一個 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)
		}
	}
}
複製程式碼

不錯,這和之前的程式碼完全不一樣。

優點:

  1. 新的程式碼短多了!整套測試現在只有一個測試函式了。
  2. 使用迴圈語句解決了 setup 重複的問題。
  3. 相似的,使用者只會從一條斷言語句中獲取錯誤碼。
  4. 在 debug 的過程中,可以很容易地在 struct 的定義中加一個 skip bool 來跳過一些測試

缺點:

  1. 匿名 struct 的定義和迴圈的宣告混在一起,看起來很奇怪。
  2. 表格驅動測試只在一些比較簡單的,只涉及資料讀入/讀出的情況下才比較有效。當情況逐漸複雜起來的時候,它會變得很笨重,也不容易(或者說不可能)用單一的 struct 對整個測試進行擴充套件。
  3. 使用 slice 表示 throws/rolls 很“煩人”。雖然動動腦筋我們還是可以簡化一下的,但是這會讓我們的模板程式碼的邏輯變複雜
  4. 儘管只用寫一條斷言語句,但是這種間接/否定式的測試還是讓我很憤怒。

GoConvey

現在,我們不能僅僅滿足於開箱即用的 go test,於是我們開始使用 Go 提供的工具和庫來實現我們自己的測試方法。如果你仔細看過 SmartyStreets GitHub page,你會注意到一個比較有名的倉庫 — GoConvey。它是我們對 Go OSS社群貢獻的最早的專案之一。

GoConvey 可以說是一個雙管齊下的測試工具。首先,有一個測試執行器監控你的程式碼,在有變化的時候執行 go test,並將結果渲染成炫酷的網頁,然後用瀏覽器展示出來。其次,它提供了一個庫讓你可以在標準的 go test 函式中寫行為驅動開發風格的測試。還有一個好訊息:你可以自由選擇不使用、部分使用或者全部使用 GoConvey 中的這些功能。

有兩個原因促使我們開發了 GoConvey:重新開發一個我們本來打算在 JetBrains IDEs 中完成的測試執行器(我們當時用的是 ReSharper)以及創造一套我們很喜歡的像 nUnitMachine.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 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章