动态

详情 返回 返回

Go 源碼是如何解決測試代碼循環依賴問題的? - 动态 详情

公眾號首發地址:https://mp.weixin.qq.com/s/j5vKNxl2keMF7oPT5M0XnA

最近我寫了一篇講解 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 識別。

現在,contexttestingcontext_test 三個包的依賴情況如下:

image.png

context_test 包導入了 contexttesting 兩個包,而 contexttesting 兩個包並沒有互相導入,這也就通過抽象出一個更高的層級依賴兩個下層包的方式,解決了循環導入。這也是我們平時開發時,避免循環導入的小技巧。

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程序設計語言》非常值得一讀,推薦給大家。

希望此文能對你有所啓發。

聯繫我

  • 公眾號:Go編程世界
  • 微信:jianghushinian
  • 郵箱:jianghushinian007@outlook.com
  • 博客:https://jianghushinian.cn
  • GitHub:https://github.com/jianghushinian
user avatar fuzhengwei 头像
点赞 1 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.