這是一個 API 設計的思想實驗,它從典型的 Go 單元測試慣用形式開始:
func TestOpenFile(t *testing.T) {
f, err := os.Open("notfound")
if err != nil {
t.Fatal(err)
}
// ...
}
這段程式碼有什麼問題?斷言 if err != nil { ... }
是重複的,並且需要檢查多個條件的情況下,如果測試的作者使用 t.Error
而不是 t.Fatal
的話會容易出錯,例如:
f, err := os.Open("notfound")
if err != nil {
t.Error(err)
}
f.Close() // boom!
有什麼解決方案?當然,通過將重複的斷言邏輯移到輔助函式中,來達到 DRY(Don't Repeat Yourself)。
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
,你知道這一點是不可以原諒的。
所以 panic 不是一個好的解決辦法,但是你能從堆疊跟蹤資訊裡面看到什麼有用的資訊嗎?這有一個提示:github.com/pkg/expect_test.TestOpenFile(0xc0000b4400)
。
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 的呼叫堆疊亂竄的固有不足以外,同樣存在一些嚴重的設計問題:
- expect.Nil 的行為依賴於誰呼叫它。同樣的引數,由於呼叫堆疊位置的原因可能導致行為的不同——這是不可預期的。
- 採取極端的動態作用域,將傳遞給單個函式之前的所有函式的所有變數納入單個函式的作用域中。這是一個在函式申明沒有明確記錄的情況下將資料傳入和傳出的輔助手段。
諷刺的是,這恰恰是我對context.Context的評價。我會將這個問題留給你自己判斷是否合理。
最後的話
這是個壞主意,這點沒有異議。這不是你可以在生產模式中使用的模式。但是,這也不是生產程式碼。這是在測試,也許有著不同的規則適用於測試程式碼。畢竟,我們使用模擬(mocks)、樁(stubs)、猴子補丁(monkey patching)、型別斷言、反射、輔助函式、構建標誌以及全域性變數,所有這些使得我們更加有效率得測試程式碼。所有這些,奇技淫巧是不會讓它們出現在生產程式碼裡面的,所以這真的是世界末日嗎?
如果你讀完本文,你也許會同意我的觀點,儘管不太符合常規,並無必要將*testing.T 傳遞到所有需要斷言的函式中去,從而使測試程式碼更加清晰。
如果你感興趣,我已分享了一個應用這個模式的小的斷言庫。小心使用。
via: https://studygolang.com/subject/1?p=1
作者:Dave Cheney 譯者:dust347 校對:unknwon
本文由 GCTT 原創編譯,[Go語言中文網