Go 源碼是如何解決測試代碼循環依賴問題的?
最近我寫了一篇講解 context 包源碼的文章《Go 併發控制:context 源碼解讀》,在閱讀源碼的過程中,我在 context 包測試代碼中發現了一個解決循環依賴的小技巧,在此分享給大家。
x_test.go 解決循環依賴
context 包源碼目錄結構如下:
https://github.com/golang/go/tree/go1.23.0/src/context
$ tree context
context
├── afterfunc_test.go
├── benchmark_test.go
├── context.go
├── context_test.go
├── example_test.go
├── net_test.go
└── x_test.go
1 directory, 7 files
context.go
文件是 context 包源碼實現,其他都是測試文件。其中只有context_test.go
的包名爲context
,其他幾個測試文件的包名則爲context_test
。那麼也就是說context_test.go
是白盒測試,其他測試文件爲黑盒測試。
不過,context_test.go
文件中並沒有以Test
開頭的測試函數,而是定義了幾個名稱格式爲XTestXxx
的測試函數。以XTestCancelRemoves
爲例,其代碼如下:
https://github.com/golang/go/blob/go1.23.0/src/context/context_test.go#L193
package context
// Tests in package context cannot depend directly on package testing due to an import cycle.
// If your test does requires access to unexported members of the context package,
// add your test below as `func XTestFoo(t testingT)` and add a `TestFoo` to x_test.go
// that calls it. Otherwise, write a regular test in a test.go file in package context_test.
import (
"time"
)
type testingT interface {
Deadline() (time.Time, bool)
Error(args ...any)
Errorf(format string, args ...any)
Fail()
FailNow()
Failed() bool
Fatal(args ...any)
Fatalf(format string, args ...any)
Helper()
Log(args ...any)
Logf(format string, args ...any)
Name() string
Parallel()
Skip(args ...any)
SkipNow()
Skipf(format string, args ...any)
Skipped() bool
}
...
func XTestCancelRemoves(t testingT) {
checkChildren := func(when string, ctx Context, want int) {
if got := len(ctx.(*cancelCtx).children); got != want {
t.Errorf("%s: context has %d children, want %d", when, got, want)
}
}
ctx, _ := WithCancel(Background())
checkChildren("after creation", ctx, 0)
_, cancel := WithCancel(ctx)
checkChildren("with WithCancel child ", ctx, 1)
cancel()
checkChildren("after canceling WithCancel child", ctx, 0)
ctx, _ = WithCancel(Background())
checkChildren("after creation", ctx, 0)
_, cancel = WithTimeout(ctx, 60*time.Minute)
checkChildren("with WithTimeout child ", ctx, 1)
cancel()
checkChildren("after canceling WithTimeout child", ctx, 0)
ctx, _ = WithCancel(Background())
checkChildren("after creation", ctx, 0)
stop := AfterFunc(ctx, func() {})
checkChildren("with AfterFunc child ", ctx, 1)
stop()
checkChildren("after stopping AfterFunc child ", ctx, 0)
}
首先,go test
是不認識以XTest
開頭的函數的,其次,函數參數testingT
是一個接口,並不是*testing.T
結構體,所以XTestCancelRemoves
不會被當作測試函數。
並且在文件開頭的註釋部分也說明了:
context
包中的測試不能直接依賴testing
包,因爲會導致循環導入。如果你的測試需要訪問context
包中未導出的(unexported)成員,請將測試添加到下面,形式爲func XTestFoo(t testingT)
,並在x_test.go
文件中添加一個調用它的TestFoo
方法。否則,請在context_test
包中的test.go
文件中編寫常規測試。
所以,這種寫法是爲了解決循環導入的。
我在 testing 包源碼中搜索了下,有兩處直接導入 context 包,分別是deps.go
文件和slogtest.go
文件。
源碼位置:
https://github.com/golang/go/blob/go1.23.0/src/testing/internal/testdeps/deps.go#L15
https://github.com/golang/go/blob/go1.23.0/src/testing/slogtest/slogtest.go#L9
不過,實測下來這兩處並不是導致循環導入的根本原因,因爲它們都是 testing 的子包。如果沒有用到,是不會被導入到 context 包的。
其實 testing 包源碼中還有一處間接引用 context 包的地方,在testing.go
中導入了runtime/trace
包,而runtime/trace
包內部則引入了 context 包。
源碼位置:
https://github.com/golang/go/blob/go1.23.0/src/testing/testing.go#L385
這個纔是造成 context 包與 testing 包形成循環導入的根因。
那麼爲了解決這個問題,所以才抽象出testingT
接口,這個接口就是照着*testing.T
結構體實現的方法設計的,也就是說*testing.T
結構體實現了這個接口。
但是因爲go test
是不認testingT
接口的,所以如果將XTestCancelRemoves
定義成以Test
開頭的單元測試函數TestCancelRemoves
,就會編譯報錯。爲了解決這個問題,前面加一個X
,就得到了XTestCancelRemoves
。而XTestCancelRemoves
不過是一個普通函數,並不是單元測試函數。所以使用go test
命令執行測試代碼的時候,不會執行XTestCancelRemoves
函數。
那麼現在這個問題就好解決了。在x_test.go
中定義TestCancelRemoves
單元測試函數,並且其內部調用了XTestCancelRemoves
,實現代碼如下:
https://github.com/golang/go/blob/go1.23.0/src/context/x_test.go#L26
package context_test
import (
. "context"
"errors"
"fmt"
"math/rand"
"runtime"
"strings"
"sync"
"testing"
"time"
)
// Each XTestFoo in context_test.go must be called from a TestFoo here to run.
...
func TestCancelRemoves(t *testing.T) {
XTestCancelRemoves(t) // uses unexported context types
}
注意這裏使用import . "context"
的方式導入了context
包,因爲這兩個文件不在同一個包,當前黑盒測試代碼包名爲context_test
,並且這裏就可以導入testing
包了,這樣就解決了循環依賴問題。
TestCancelRemoves
函數以Test
開頭,並且參數爲*testing.T
,這是一個標誌的單元測試代碼,能夠被go test
識別。
現在,context
、testing
、context_test
三個包的依賴情況如下:
context_test
包導入了context
、testing
兩個包,而context
、testing
兩個包並沒有互相導入,這也就通過抽象出一個更高的層級依賴兩個下層包的方式,解決了循環導入。這也是我們平時開發時,避免循環導入的小技巧。
export_test.go 測試後門
在分析x_test.go
機制時,讓我想起了 Go 語言 “聖經”《Go 程序設計語言》一書中講到的測試 “後門”。既然都講到這裏,那麼我再順便分享一下使用export_test.go
作爲測試 “後門” 的小技巧。
NOTE:
身爲一名 Gopher,如果你還沒讀過這本 Go 語言 “聖經”,那麼強烈建議你讀一下。
在《Go 程序設計語言》一書11.2.4 外部測試包
這一小節中也有提到使用黑盒測試解決循環引用問題。不過,如果有些包變量是 unexported 的,則可以通過編寫測試 “後門” 來解決。
比如fmt
包中有一個 unexported 的函數isSpace
,在黑盒測試中需要被使用。解決方案非常簡單,在包名爲fmt
的白盒測試文件中,聲明一個 exported 的新變量IsSpace
,並將isSpace
賦值給它:
https://github.com/golang/go/blob/go1.23.0/src/fmt/export_test.go
package fmt
var IsSpace = isSpace
var Parsenum = parsenum
然後就可以在黑盒測試中使用 exported 變量IsSpace
了:
https://github.com/golang/go/blob/go1.23.0/src/fmt/fmt_test.go#L1789
package fmt_test
import (
"bytes"
. "fmt"
"internal/race"
"io"
"math"
"reflect"
"runtime"
"strings"
"testing"
"time"
"unicode"
)
...
func TestIsSpace(t *testing.T) {
// This tests the internal isSpace function.
// IsSpace = isSpace is defined in export_test.go.
for i := rune(0); i <= unicode.MaxRune; i++ {
if IsSpace(i) != unicode.IsSpace(i) {
t.Errorf("isSpace(%U) = %v, want %v", i, IsSpace(i), unicode.IsSpace(i))
}
}
}
測試函數TestIsSpace
的代碼註釋中也說明了IsSpace = isSpace
是在export_test.go
中定義的。
爲測試編寫 “後門” 原來如此簡單。並且,由於以_test.go
結尾的文件只在編譯測試的時候纔會被使用,那麼正常編譯 exported 變量IsSpace
是不會被使用的,所以無需擔心isSpace
被亂用的問題。
總結
本文介紹了兩個單元測試的小技巧,我們可以使用XTest
來解決循環依賴問題,使用測試 “後門” 來解決黑盒測試引用白盒測試 unexported 變量問題。
另外,《Go 程序設計語言》非常值得一讀,推薦給大家。
希望此文能對你有所啓發。
延伸閱讀
-
go1.23.0/src/context:https://github.com/golang/go/tree/go1.23.0/src/context
-
go1.23.0/src/fmt:https://github.com/golang/go/blob/go1.23.0/src/fmt/
-
《Go 程序設計語言》:https://book.douban.com/subject/27044219/
-
Go 併發控制:context 源碼解讀:https://jianghushinian.cn/2024/12/09/context/
-
本文 GitHub 示例代碼:https://github.com/jianghushinian/blog-go-example/tree/main/context
聯繫我
-
公衆號:Go 編程世界
-
微信:jianghushinian
-
郵箱:jianghushinian007@outlook.com
-
博客:https://jianghushinian.cn
-
GitHub:https://github.com/jianghushinian
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/j5vKNxl2keMF7oPT5M0XnA