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

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

  1. 你爲何將測試工具(從 GoConvey)換成 gunit
  2. 你建議你們都這麼作嗎?

這兩個問題很好,做爲 GoConvey 的聯合創始人兼 gunit 的主要做者,我也有責任將這兩個問題解釋清楚。直接回答,太長不讀系列:前端

問題 1:爲何換用 gunit?android

在使用 GoConvey 的過程當中,有一些問題一直困擾着咱們,因此咱們想了一個更能體現測試庫中重點的替代方案,以解決這些問題。在當時的狀況中,咱們已經沒法對 GoConvey 作過渡升級方案了。下面我會仔細介紹一下,並提煉到簡明的宣明式結論ios

問題 2:你是否建議你們都這麼作(從 GoConvey 換成 gunit)?git

不。我只建議大家使用能幫助大家達成目標的工具和庫。你得先明確本身對測試工具的需求,而後再儘快去找或者造適合本身的工具。測試工具是大家構建項目的基礎。若是你對後面的內容產生了共鳴,那麼 gunit 會成爲你選型中一個極具吸引力的選項。你得好好研究,而後慎重選擇。GoConvey 的社區還在不斷成長,而且擁有不少活躍的維護者。若是你很想支持一下這個項目,隨時歡迎加入咱們。github


好久之前在一個遙遠的星系...

Go 測試

咱們初次使用 Go 大概是在 Go 1.1 發佈的時候(也就是 2013 年年中),在剛開始寫代碼的時候,咱們很天然地接觸到了 go test"testing"。我很高興看到 testing 包被收進了標準庫甚至是工具集中,可是對於它慣用的方法並無什麼感受。後文中,咱們將使用著名的「保齡球遊戲」練習對比展現咱們使用不一樣測試工具後獲得的效果。(你能夠花點時間熟悉一下生產代碼,以便更好地瞭解後面的測試部分。)golang

下面是用標準庫中的 "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 社區放棄了「咱們最愛的測試幫手」「斷言方法」的觀點,轉而使用「表格驅動」測試來減小模板代碼。用表格驅動測試從新寫一遍上面的例子:bash

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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索