這是一個 API 設計的思想實驗,它從典型的 Go 單元測試慣用形式開始:git
func TestOpenFile(t *testing.T) { f, err := os.Open("notfound") if err != nil { t.Fatal(err) } // ... }
這段代碼有什麼問題?斷言 if err != nil { ... }
是重複的,而且須要檢查多個條件的狀況下,若是測試的做者使用 t.Error
而不是 t.Fatal
的話會容易出錯,例如:github
f, err := os.Open("notfound") if err != nil { t.Error(err) } f.Close() // boom!
有什麼解決方案?固然,經過將重複的斷言邏輯移到輔助函數中,來達到 DRY(Don't Repeat Yourself)。golang
func TestOpenFile(t *testing.T) { f, err := os.Open("notfound") check(t, err) // ... } func check(t *testing.T, err error) { if err != nil { t.Helper() t.Fatal(err) } }
使用 check
輔助函數使得這段代碼更簡潔一些,而且更加清晰地檢查錯誤,同時有望解決 t.Error
與 t.Fatal
的混淆使用。 將斷言抽象爲一個輔助函數的缺點是,如今你須要將一個 testing.T
傳遞到每個調用上。更糟糕的是,爲了以防萬一,你須要傳遞 *testing.T
到每個須要調用 check
的地方。安全
我猜,這並無關係。但我會觀察到只有在斷言失敗的時候纔會用到變量 t —— 即便在測試場景下,大多數時候,大部分的測試是經過的,所以在相對罕見的測試失敗的狀況下,會產生對這些變量 t 的固定讀寫開銷。函數
若是咱們這樣作怎麼樣?單元測試
func TestOpenFile(t *testing.T) { f, err := os.Open("notfound") check(err) // ... } func check(err error) { if err != nil { panic(err.Error()) } }
是的,能夠,可是有一些問題。測試
% go test --- FAIL: TestOpenFile (0.00s) panic: open notfound: no such file or directory [recovered] panic: open notfound: no such file or directory goroutine 22 [running]: testing.tRunner.func1(0xc0000b4400) /Users/dfc/go/src/testing/testing.go:874 +0x3a3 panic(0x111b040, 0xc0000866f0) /Users/dfc/go/src/runtime/panic.go:679 +0x1b2 github.com/pkg/expect_test.check(...) /Users/dfc/src/github.com/pkg/expect/expect_test.go:18 github.com/pkg/expect_test.TestOpenFile(0xc0000b4400) /Users/dfc/src/github.com/pkg/expect/expect_test.go:10 +0xa1 testing.tRunner(0xc0000b4400, 0x115ac90) /Users/dfc/go/src/testing/testing.go:909 +0xc9 created by testing.(*T).Run /Users/dfc/go/src/testing/testing.go:960 +0x350 exit status 2
先從好的方面提及,咱們不須要傳遞一個 testing.T
到每個調用 check
函數的地方,且測試會當即失敗。咱們還從 panic 中得到了一條不錯的信息 —— 儘管重複出現了兩次。可是,哪裏斷言失敗卻不容易看到。它發生在 expect_test.go:11
,你知道這一點是不能夠原諒的。ui
因此 panic 不是一個好的解決辦法,可是你能從堆棧跟蹤信息裏面看到什麼有用的信息嗎?這有一個提示:github.com/pkg/expect_test.TestOpenFile(0xc0000b4400)
。.net
TestOpenFile 有一個 t 的值,它由 tRunner 傳遞過來,因此 testing.T 在內存中位於地址 0xc0000b4400 上。若是咱們能夠在 check 函數內部獲取 t 會怎樣?那咱們能夠經過它來調用 t.Helper 來 t.Fatal。這可能嗎?設計
咱們想要的是可以訪問一個變量,而該變量的申明既不是在全局範圍,也不是在函數局部範圍,而是在調用堆棧的更高的位置上。這被稱之爲動態做用域。Go 並不支持動態做用域,但事實證實,某些狀況下,咱們能夠模擬它。回到正題:
// getT 返回由 testing.tRunner 傳遞過來的 testing.T 地址 // 而調用 getT 的函數由它(tRunner)所調用. 若是在堆棧中沒法找到 testing.tRunner // 說明 getT 在主測試 goroutine 沒有被調用, // 這時 getT 返回 nil. func getT() *testing.T { var buf [8192]byte n := runtime.Stack(buf[:], false) sc := bufio.NewScanner(bytes.NewReader(buf[:n])) for sc.Scan() { var p uintptr n, _ := fmt.Sscanf(sc.Text(), "testing.tRunner(%v", &p) if n != 1 { continue } return (*testing.T)(unsafe.Pointer(p)) } return nil }
咱們知道每一個測試(Test)由 testing 包在本身的 goroutine 上調用(看上面的堆棧信息)。testing 包經過一個名爲 tRunner 的函數來啓動測試,該函數須要一個testing.T 和一個 func(testing.T)來調用。所以咱們抓取當前 goroutine 的堆棧信息,從中掃描找到已 testing.tRunner 開頭的行——因爲 tRunner 是私有函數,只能是 testing 包——並解析第一個參數的地址,該地址是一個指向 testing.T 的指針。有點不安全,咱們將這個原始指針轉換爲一個 *testing.T 咱們就完成了。
若是搜索不到則多是 getT 並非被 Test 所調用。這其實是行的通的,由於咱們須要*testing.T 是爲了調用 t.Fatal,而 testing 包要求 t.Fatal 被主測試 goroutine所調用。
import "github.com/pkg/expect" func TestOpenFile(t *testing.T) { f, err := os.Open("notfound") expect.Nil(err) // ... }
綜上,在預期打開文件所產生的 err 爲 nil 後,咱們消除了斷言樣板,而且是測試看起來更加清晰易讀。
這時你應該會問,這樣好嗎?答案是,不,這很差。此時你應該會感到震驚,可是這些很差的感受可能值得反思。除了在 goroutine 的調用堆棧亂竄的固有不足之外,一樣存在一些嚴重的設計問題:
諷刺的是,這偏偏是我對context.Context的評價。我會將這個問題留給你本身判斷是否合理。
這是個壞主意,這點沒有異議。這不是你能夠在生產模式中使用的模式。可是,這也不是生產代碼。這是在測試,也許有着不一樣的規則適用於測試代碼。畢竟,咱們使用模擬(mocks)、樁(stubs)、猴子補丁(monkey patching)、類型斷言、反射、輔助函數、構建標誌以及全局變量,全部這些使得咱們更加有效率得測試代碼。全部這些,奇技淫巧是不會讓它們出如今生產代碼裏面的,因此這真的是世界末日嗎?
若是你讀完本文,你也許會贊成個人觀點,儘管不太符合常規,並沒有必要將*testing.T 傳遞到全部須要斷言的函數中去,從而使測試代碼更加清晰。
若是你感興趣,我已分享了一個應用這個模式的小的斷言庫。當心使用。
via: https://studygolang.com/subject/1?p=1
做者:Dave Cheney 譯者:dust347 校對:unknwon
本文由 GCTT 原創編譯,[Go語言中文網