Go 1.27 已于 2026 年 8 月 19 日发布。这个版本中,实现了多个与测试相关的改动:既有让编写测试更省心的新 API,也有默认开启、可能导致升级后 go test 直接失败的 vet 检查。本文就来系统梳理这些变化,并给出升级时的注意事项。

本文涉及的全部示例代码,我都放在了 blog-go-example/test/go127 目录下,各子目录为独立模块,可单独运行。

测试相关变化一览

为了方便阅读,这里先列出本文要讲的几处变化:

变化 类型 说明
httptest.NewTestServer 新增 基于内存网络的测试服务器,自动清理、panic 计入测试结果
testing/synctest.Sleep 新增 time.Sleep 与 synctest.Wait 的组合
go test -json 的 OutputType 新增 区分普通输出、测试框架输出、错误、错误续行
stdversion vet 检查 默认启用 检查是否使用了高于 go 指令声明的标准库符号
printf vet 的 %w 指针规则 新增 报告会让 errors.Is、errors.As 失效的 %w 包装

其中 httptest.NewTestServer 与 testing/synctest 关系密切,本文会有示例放在一起讲解。

测试服务 httptest.NewTestServer

httptest.NewServer 存在的问题

在 Go 1.27 之前版本,Go 提供了 httptest.NewServer 用来创建测试服务,它会在 loopback(即本机回环地址 127.0.0.1)上启动一个真实的 HTTP 服务器,并返回服务器的 URL。它简单直接,但也存在几个长期存在的问题:

  • 一般需要通过手动调用 defer server.Close() 释放资源,容易遗漏。
  • 它会监听一个真实的 TCP 端口,测试数量多时可能耗尽端口。
  • handler 中业务代码如果发生 panic,net/http 会将其 recover 并记录到 stderr,测试本身不会失败。
  • 使用真实的网络 I/O,无法完美配合 testing/synctest 使用(网络阻塞不属于持久阻塞)。

NOTE:

如果你对 httptest.NewServer 还不太熟悉,可以参考我之前的文章《在 Go 语言单元测试中如何解决 HTTP 网络依赖问题》。

NewTestServer 的签名与改进

Go 1.27 新增了 httptest.NewTestServer,其签名如下:

1
func NewTestServer(t testing.TB, handler http.Handler) *Server

它返回的类型依然是 *httptest.Server,与 NewServer 相同,区别在于:

  • 接收 testing.TB,并通过 t.Cleanup 在测试结束时自动关闭,无需再写 defer server.Close()。
  • 默认使用内存网络(in-memory network),server.Listener == nil,不占用真实端口。
  • handler 中的 panic 会被报告为测试失败(t.Errorf)。
  • 内存网络将 HTTP 通信转化为内存中的连接,可以配合 testing/synctest 使用。

NOTE:

testing/synctest 在 Go 1.25 正式转正,我在之前的文章《使用 testing/synctest 测试并发代码》中做过介绍,此处不再展开,只讲它在 1.27 中的新变化。

示例:同一组测试用例,两种写法

下面用同一个被测客户端 UserClient 来对比两种写法。客户端代码如下:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/http/client/client.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// UserClient 用户客户端
type UserClient struct {
BaseURL string
Client *http.Client
}

// NewUserClient 创建用户客户端实例
func NewUserClient(baseURL string, client *http.Client) *UserClient {
uc := &UserClient{BaseURL: baseURL, Client: client}
if client == nil {
uc.Client = http.DefaultClient
}
return uc
}

// GetUser 从 HTTP 服务获取用户信息
func (c *UserClient) GetUser(userID int) (*User, error) {
url := fmt.Sprintf("%s/users/%d", c.BaseURL, userID)
resp, err := c.Client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API status code: %d", resp.StatusCode)
}

var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}

注意 UserClient 通过构造函数注入了一个 *http.Client,这是使用 NewTestServer 的前提,原因稍后会说明。

使用老写法 NewServer 的测试用例 TestUserClientA 如下:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/http/client/client_test.go

1
2
3
4
5
6
7
8
9
10
11
12
ts := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.status)
if tt.response != "" {
w.Write([]byte(tt.response))
}
}),
)
defer ts.Close()

client := NewUserClient(ts.URL, nil)
user, err := client.GetUser(tt.user.ID)

使用新写法 NewTestServer 的测试用例 TestUserClientB 如下:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/http/client/client_test.go

1
2
3
4
5
6
7
8
9
10
11
12
13
ts := httptest.NewTestServer(
t,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.status)
if tt.response != "" {
w.Write([]byte(tt.response))
}
}),
)

client := ts.Client()
uc := NewUserClient(ts.URL, client)
user, err := uc.GetUser(tt.user.ID)

可以看到,新写法去掉了 defer ts.Close(),并且必须通过 ts.Client() 获取客户端。这两点背后分别是自动清理和内存网络路由机制,下面分别说明。

注意点一:内存网络的请求路由

内存网络下,请求的”任意地址都路由到测试服务器”这一行为,实现在 server.Client() 返回的 *http.Client 的 Transport 中:它的 DialContext 直接返回内存 listener 的连接,从而忽略请求地址。

因此在内存网络模式下,必须使用 server.Client(),普通的 http.DefaultClient 无法访问内存服务器。此外,server.URL 在首次调用 Client()、Start() 或 StartTLS() 之前是空字符串,调用之后会被设置为 http://example.com。

核心逻辑用下面这段精简的测试代码说明即可:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/http/client/routing_demo_test.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
ts := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "served by test server, host=%s path=%s", r.Host, r.URL.Path)
}))

// server.Client():无视请求地址,一律路由到内存服务器
resp, err := ts.Client().Get("http://totally.other.host/anything")
if err != nil {
t.Fatalf("server.Client() should reach the in-memory server: %v", err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()

// 普通 client:没有这层路由,会真的去拨 server.URL 里的地址(内存模式下是 example.com)。
// 这里用 DialContext 把连接拦下来,只是为了证明它确实在拨那个地址,而不是真的访问公网。
var dialed string
plain := &http.Client{Transport: &http.Transport{
DialContext: func(_ context.Context, _ string, addr string) (net.Conn, error) {
dialed = addr
return nil, errors.New("blocked by demo")
},
}}
if _, err := plain.Get(ts.URL); err == nil {
t.Fatal("plain client should not reach the in-memory server")
}
t.Logf("plain client dialed %q, not the in-memory listener", dialed)

运行结果如下:

1
2
3
4
5
6
7
$ go test -tags inmemory_demo -run TestInMemoryRoutingGotcha -v .
=== RUN TestInMemoryRoutingGotcha
routing_demo_test.go:27: before Client(): url="" listener=<nil>
routing_demo_test.go:29: after Client(): url="http://example.com" listener=<nil>
routing_demo_test.go:38: server.Client() -> "served by test server, host=totally.other.host path=/anything"
routing_demo_test.go:52: plain client dialed "example.com:80" (real network), not the in-memory listener
--- PASS: TestInMemoryRoutingGotcha (0.00s)

可以看到,server.Client() 无视请求地址,将 http://totally.other.host/anything 路由到了内存服务器;而普通的 client 会去访问真实的 example.com:80,请求直接打到公网。

这一点也解释了一个容易被忽略的问题:如果被测客户端内部使用的是 http.Get 这样的包级函数,而不是注入的 http.Client,那么用旧的 NewServer(loopback 网络)测试时能够通过——因为 URL 确实可以访问;但换成内存网络后,请求会被发往公网,测试行为与线上不一致。换句话说,NewTestServer 会让这类隐藏问题暴露出来,这也是为什么推荐通过依赖注入的方式使用 *http.Client。

注意点二:Start 与 Client 的调用顺序

NewTestServer 默认使用内存网络。如果确实需要真实端口(例如需要用其它工具访问该服务器),可以调用 Start() 或 StartTLS(),此时服务器会切换回 loopback 真实监听。

需要注意的是调用顺序:应当先调用 Start(),再调用 Client()。如果反过来先调用 Client(),再调用 Start(),会 panic:Server already started。

示例 startorder_demo_test.go 的验证结果如下:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/http/client/startorder_demo_test.go

1
2
3
4
5
$ go test -tags startorder_demo -v .
startorder_demo_test.go:21: after Start: url="http://127.0.0.1:56966" listener=true
--- PASS: TestStartBeforeClient (0.00s)
startorder_demo_test.go:37: recovered panic: Server already started
--- PASS: TestClientThenStartPanics (0.00s)

可以看到,先 Start() 时 server.URL 变为 http://127.0.0.1:56966,Listener 也不为空;而先 Client() 再 Start() 则会 panic。

并发测试 testing/synctest

1.27 的新变化:Sleep

testing/synctest 在 Go 1.24 引入实验版本(Run、Wait),Go 1.25 转正(Run 被 Test 取代),Go 1.27 又新增了 Sleep。截至 Go 1.27,该包对外只提供 3 个函数:

1
2
3
func Test(t *testing.T, f func(*testing.T)) // Go 1.25
func Wait() // Go 1.25
func Sleep(d time.Duration) // Go 1.27 新增

其中 Sleep 等价于 time.Sleep(d) 后跟一次 Wait():

1
2
3
4
func Sleep(d time.Duration) {
time.Sleep(d)
Wait()
}

它的用途是:在气泡内推进虚拟时间,同时等待其它 goroutine 稳定下来,从而使“睡一段时间后断言”的写法既快速又可靠。

为了理解 Sleep 到底等的是什么,需要先回顾两个核心概念:虚拟时钟和持久阻塞。

虚拟时钟与持久阻塞

Test 会在一个隔离的“气泡”中运行测试函数。气泡内的时间是一个虚拟时钟,起点固定为 UTC 2000-01-01 00:00。气泡内的所有 time.Now()、time.Sleep() 和定时器都基于这个虚拟时钟。

当气泡内的所有 goroutine 都处于持久阻塞(durably blocked)状态时,虚拟时钟才会向前推进。所谓持久阻塞,是指某个 goroutine 阻塞,且只能被气泡内的其它 goroutine 唤醒。以下操作会被视为持久阻塞:

  • 在 nil channel 上发送或接收;
  • 在同一气泡内创建的 channel 上阻塞发送或接收;
  • 每个 case 都持久阻塞的 select 语句;
  • time.Sleep;
  • sync.Cond.Wait;
  • sync.WaitGroup.Wait。

以下操作不属于持久阻塞(因为它们可能被气泡外的事件唤醒):

  • sync.Mutex、sync.RWMutex 等锁竞争;
  • 网络 I/O;
  • 文件 I/O、系统调用。

当气泡被持久阻塞时,按以下顺序处理:

  • 如果有未完成的 Wait 调用,则返回;
  • 否则,虚拟时钟推进到下一个可以唤醒某个 goroutine 的时刻;
  • 否则,气泡陷入死锁,Test 会 panic。

Wait 等的是什么

一个常见的误解是:Wait 在等“其它 goroutine 把活干完”。实际上,Wait 等待的是一条纯粹的状态谓词:除调用者外(调用 Wait 的那个 goroutine,通常是测试函数自己的 goroutine),气泡内其它每个 goroutine 都处于持久阻塞状态(或已退出)。

之所以要把调用者排除在外,是因为调用者一调用 Wait,自己就会先被阻塞住,直到条件满足才返回——把自己的状态算进谓词里没有意义。所以可以把 Wait 理解为“等其它 goroutine 都稳定下来(要么持久阻塞,要么退出),我再继续”。它并不关心“任务”是否真正完成。

来看下面这个例子:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/synctest/wait_test.go

1
2
3
4
5
6
7
8
9
10
11
12
synctest.Test(t, func(t *testing.T) {
var done atomic.Bool
go func() {
time.Sleep(1 * time.Second)
done.Store(true)
}()

time.Sleep(1 * time.Second)
t.Log(done.Load()) // 此处 done 可能仍然是 false
synctest.Wait()
t.Log(done.Load()) // 此处 done 必然是 true
})

这段代码的关键在于虚拟时钟是怎么“跳”到 1s 的:测试 goroutine 和被测 goroutine 都调用了 time.Sleep(1 * time.Second),同时处于持久阻塞。按照上文的规则,当气泡内所有 goroutine 都持久阻塞时,虚拟时钟不会一秒一秒地走,而是直接推进到“最早能唤醒某个 goroutine”的时刻——也就是 1s。此时两个定时器同时到期,两个 goroutine 都恢复为可运行,但谁先执行是不确定的:

  • 如果测试 goroutine 先执行:第一个 t.Log(done) 读到的是 false,因为子 goroutine 还没运行到 done.Store(true)。随后调用 synctest.Wait(),此时子 goroutine 处于可运行状态(既未持久阻塞也未退出),Wait 不会返回;子 goroutine 会被继续执行,Store(true) 后退出,Wait 这才返回,所以第二个 t.Log(done) 必然是 true。
  • 如果子 goroutine 先执行:done.Store(true) 完成、子 goroutine 退出,测试 goroutine 再执行时第一个 t.Log(done) 读到的就是 true;此时调用 Wait 只需确认“其它 goroutine 已退出”立刻就能返回,第二个仍然是 true。

无论哪种顺序,Wait 之后都能确定 done 已经是 true,这正是用 Wait(或 Sleep)来做断言的意义所在。

这也是 Sleep = time.Sleep + Wait 的意义:仅仅“睡够时间”只保证自己醒来了,Wait 才保证其它 goroutine 都已稳定下来,此时做断言才有确定性。

示例

下面这个例子分别用真实时间和 synctest 来测试同一段逻辑,可以直观地看到差异:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/synctest/synctest_test.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func TestTime(t *testing.T) {
start := time.Now() // 起始时间为当前时间
go func() {
time.Sleep(1 * time.Second)
t.Log(time.Since(start)) // 每次输出都是 "1s"
}()
time.Sleep(2 * time.Second)
t.Log(time.Since(start)) // 每次输出都是 "2s"
}

func TestTimeWithSynctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now() // 起始时间为 2000-01-01 UTC
go func() {
time.Sleep(1 * time.Second)
t.Log(time.Since(start)) // 每次输出都是 "1s"
}()
time.Sleep(2 * time.Second)
t.Log(time.Since(start)) // 每次输出都是 "2s"
})
}

TestTime 会让测试真的等待 2 秒,而 TestTimeWithSynctest 在虚拟时间上前进 2 秒,真实世界瞬间完成。

使用 Sleep 的写法则更加简洁,示例 sleep_test.go 如下:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/synctest/sleep_test.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func TestSleep(t *testing.T) {
realStart := time.Now()

synctest.Test(t, func(t *testing.T) {
start := time.Now() // 气泡内的虚拟时间

woke := make(chan time.Duration, 1)
go func() {
time.Sleep(time.Second)
woke <- time.Since(start)
}()

synctest.Sleep(2 * time.Second)

select {
case d := <-woke:
if d != time.Second {
t.Fatalf("worker virtual sleep = %v, want 1s", d)
}
default:
t.Fatal("worker goroutine not settled after synctest.Sleep")
}
})

if real := time.Since(realStart); real >= 100*time.Millisecond {
t.Fatalf("2s of virtual time should cost ~no real time, took %v", real)
}
}

这个示例想要验证的是 synctest.Sleep 的两个特性,整个 select 断言都建立在第二个特性上:

  • 虚拟时间真的在推进:worker 睡满 1 秒后,用 time.Since(start) 量到的就是恰好 1s,断言 d != time.Second 不会触发。
  • 返回时其它 goroutine 必然已稳定:Sleep(2 * time.Second) 返回时,worker 早已睡满 1 秒并把结果写入了 channel(main 的 2 秒定时器晚于 worker 的 1 秒定时器,且 Sleep 内部还会再 Wait 一次),所以 select 的 default 分支永远走不到。
  • 虚拟时间几乎不花真实时间:结尾断言整个虚拟 2 秒只消耗不到 100ms 的真实时间。

也就是说,synctest.Sleep 之后可以像等待普通 goroutine 结果一样直接做断言,不再需要手写 time.Sleep + synctest.Wait 两步。

限制

testing/synctest 也有若干限制,使用时需要注意:

  • 气泡内不能调用 t.Run(子测试)、t.Parallel、t.Deadline。
  • 网络 I/O、文件 I/O 等不属于持久阻塞,在气泡内使用会阻止时钟推进。也正因如此,NewTestServer 才提供了内存网络。
  • 在气泡外操作气泡内创建的 channel 会导致气泡 panic。

NewTestServer 与 synctest 的结合

前面提到,内存网络将 HTTP 通信转化为内存中的连接,这正是为了配合 testing/synctest:真实网络 I/O 不属于持久阻塞,若在气泡内使用真实端口,时钟将无法推进。因此,NewTestServer 与 synctest 配合使用非常自然。

示例 newtestserver_test.go 演示了这一点:10 个并发请求,handler 各耗时 100ms。

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/synctest/newtestserver_test.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func TestNewTestServerWithSynctest(t *testing.T) {
realStart := time.Now()

synctest.Test(t, func(t *testing.T) {
start := time.Now() // 气泡内虚拟时间

ts := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond)
fmt.Fprint(w, "ok")
}))
client := ts.Client()

var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
resp, err := client.Get("http://example.com/")
if err != nil {
t.Errorf("request failed: %v", err)
return
}
resp.Body.Close()
}()
}
wg.Wait()

if elapsed := time.Since(start); elapsed != 100*time.Millisecond {
t.Fatalf("virtual elapsed = %v, want 100ms", elapsed)
}
})

if real := time.Since(realStart); real >= 100*time.Millisecond {
t.Fatalf("10 concurrent 100ms requests should cost ~no real time, took %v", real)
}
}

由于 10 个请求并发处理,虚拟时间只前进 100ms,真实世界几乎不花时间。运行结果如下:

1
2
3
$ go test -run TestNewTestServerWithSynctest -v .
=== RUN TestNewTestServerWithSynctest
--- PASS: TestNewTestServerWithSynctest (0.00s)

需要注意的问题:并发调用 synctest.Sleep

synctest.Sleep 内部会调用 Wait,而在同一时刻只允许有一个 Wait 调用者。如果在多个 handler goroutine 中并发调用 synctest.Sleep,就会 panic:wait already in progress,并且会被 NewTestServer 捕获后报告为 t.Errorf,导致测试失败。

示例 concurrent_sleep_demo_test.go 演示了这一错误:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/synctest/concurrent_sleep_demo_test.go

1
2
3
4
ts := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
synctest.Sleep(100 * time.Millisecond) // 错误示范:并发调用 Wait
w.WriteHeader(http.StatusOK)
}))

运行结果如下:

1
2
3
$ go test -tags concurrentsleep_demo -v .
server.go:202: httptest: panic in server handler: wait already in progress
--- FAIL: TestConcurrentSynctestSleep (0.00s)

正确的做法是:在气泡内等待时间只放在测试主流程中(使用 synctest.Wait/synctest.Sleep),handler 中则使用普通的 time.Sleep。上文的 newtestserver_test.go 正是这样写的。

go test 命令行工具的变化

stdversion 检查默认启用

stdversion 是一个 vet 分析器,检查的是:代码里用到的标准库 API(函数、类型、方法)是不是比 go.mod 声明的 go 指令版本还新。比如 go.mod 写的是 go 1.24,代码却用了 Go 1.25 才加入的 sync.WaitGroup.Go,它就会报错。

为什么需要这种检查?这里要区分语法和 API 两类问题。语法是新旧 Go 版本差异中被编译器严格校验的部分——例如 range over func 需要 Go 1.23,如果 go.mod 的 go 指令低于该版本,编译时就会报错:

1
2
cannot range over func(yield func(int) bool) {…} (value of type func(yield func(int) bool)):
requires go1.23 or later (-lang was set to go1.21; check go.mod)

而标准库 API 则长期缺乏类似的检查:只要实际用于构建的工具链足够新,即使 go.mod 的 go 指令版本较老,使用新 API 也能编译通过、测试通过。需要说明的是,这里的「工具链」指实际用来编译、运行的 Go 版本,它由本机安装的版本、GOTOOLCHAIN 环境变量以及 go.mod 中的 toolchain 指令共同决定,与 go 指令声明的语言版本是两回事。问题会在别人用较老版本工具链构建(CI 矩阵、被更老版本的模块依赖)时才暴露,而本地往往无法发现。

stdversion 正是为填补这一空缺而设计的。它并不是 Go 1.27 才出现的分析器:早在 Go 1.23,go vet 就已包含它,同时也支持通过 go test -vet=stdversion 手动启用。只是此前 go test 的默认 vet 列表并不包含它。Go 1.27 起,go test 也默认启用该检查,这也是升级到 1.27 后老项目测试可能直接失败的原因之一。

示例模块 stdversion 的 go.mod 声明为 go 1.24,但使用 Go 1.25 才加入的 sync.WaitGroup.Go。先看 go.mod:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/stdversion/go.mod

1
2
3
4
5
module github.com/jianghushinian/blog-go-example/test/go127/stdversion

go 1.24

toolchain go1.27.0

再看测试代码:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/stdversion/waitgroupgo_demo_test.go

1
2
3
4
5
6
7
//go:build stdversion_demo

func TestWaitGroupGoTooNew(t *testing.T) {
var wg sync.WaitGroup
wg.Go(func() {})
wg.Wait()
}

运行结果如下:

1
2
3
4
$ go test -tags stdversion_demo .
# github.com/jianghushinian/blog-go-example/test/go127/stdversion
./waitgroupgo_demo_test.go:19:5: sync.(*WaitGroup).Go requires go1.25 or later (module is go1.24)
FAIL github.com/jianghushinian/blog-go-example/test/go127/stdversion [build failed]

解决方式有两种:

  • 将 go.mod 的 go 指令提升到实际使用的 API 版本;
  • 或通过 go test -vet=off 关闭 vet(也可以在 -vet 列表中移除 stdversion)。

NOTE:

该示例模块的 go.mod 中除 go 1.24 外还声明了 toolchain go1.27.0,用于确保使用 Go 1.27 工具链运行。关于 go 指令与 toolchain 指令的区别,可以参考 Go 官方文档 https://go.dev/doc/toolchain 。

go test -json 新增 OutputType 字段

go test -json 会以 JSON 形式输出测试事件。Go 1.27 为其中 Action 为 output 的事件新增了 OutputType 字段,用于区分输出来源,取值有四种:

  • 空字符串:普通输出,例如 t.Log、fmt.Println 的内容。
  • frame:测试框架输出的内容,例如 === RUN、--- FAIL。
  • error:错误信息,例如 t.Error、t.Fatal 的内容。
  • error-continue:多行错误的续行。

一条 OutputType 为 error 的事件示例如下:

1
{"Time":"2026-10-11T16:30:47.302846+08:00","Action":"output","Package":"github.com/jianghushinian/blog-go-example/test/go127/outputtype","Test":"TestOutputTypeDemo/fatal","Output":"    outputtype_test.go:31: fatal error stops the subtest\n","OutputType":"error"}

示例 outputtype 通过拉起 go test -json 子进程并解析事件,验证了四种类型的存在:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/outputtype/outputtype_test.go

1
2
3
4
5
6
$ go test -v -run TestOutputTypeVerifier .
outputtype_test.go:92: OutputType=(blank) count=2 sample=" outputtype_test.go:23: regular log line\n"
outputtype_test.go:92: OutputType=frame count=6 sample="=== RUN TestOutputTypeDemo\n"
outputtype_test.go:92: OutputType=error count=3 sample=" outputtype_test.go:31: fatal error stops the subtest\n"
outputtype_test.go:92: OutputType=error-continue count=1 sample=" line error\n"
--- PASS: TestOutputTypeVerifier (0.54s)

这里有一个需要注意的问题:go test -json 会隐式注入 -test.v=test2json,如果同时显式传入 -v,-v 会被解析为 -test.v=true,从而覆盖 test2json,导致 error 和 error-continue 丢失。也就是说,go test -json -v 反而得不到错误类型,这是一个已知问题(golang/go#70384)。

下面的两组输出可以印证这一点。为了让 demo 产生错误输出,需要设置 OUTPUTTYPE_DEMO=1 环境变量(demo 默认不产生错误,这样日常 go test ./... 才能保持全部通过):

1
2
3
4
5
6
7
$ OUTPUTTYPE_DEMO=1 go test -json -count=1 -run '^TestOutputTypeDemo$' . | grep -o '"OutputType":"[^"]*"' | sort | uniq -c
3 "OutputType":"error"
1 "OutputType":"error-continue"
6 "OutputType":"frame"

$ OUTPUTTYPE_DEMO=1 go test -json -v -count=1 -run '^TestOutputTypeDemo$' . | grep -o '"OutputType":"[^"]*"' | sort | uniq -c
6 "OutputType":"frame"

可以看到,加上 -v 之后,所有 error、error-continue 事件都消失了。如果你在编写自定义的测试报告器,需要留意这一点。

vet printf 检查 %w 的指针

printf 和前面讲过的 stdversion 一样,都是 vet 的检查项(vet 中每个检查项由一个分析器实现),而且 printf 早已是 go test 默认运行的检查之一。Go 1.27 为它新增了一条规则:fmt.Errorf 的 %w 操作数不应是“实现了 error 的类型的指针”(即 *E,其中 E 实现了 error)。

这条规则针对的是如下写法:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/printfw/wrapptr_demo_test.go

1
err := fmt.Errorf("operation failed: %w", &myErr{msg: "boom"})

以 myErr 为例,myErr 和 *myErr 都实现了 error:

1
2
3
4
5
6
type myErr struct {
msg string
}

// Error 使用值接收者:myErr 和 *myErr 都实现 error。
func (e myErr) Error() string { return e.msg }

当 %w 包装的是 &myErr{} 时,错误链中保存的动态类型是 *myErr 而不是 myErr。这会导致 errors.Is(err, myErr{...})、errors.As(err, &target) 以及针对 myErr 的类型断言全部匹配失败,而编译期没有任何提示。最典型的情况是 fmt.Errorf("%w", &err)(err 是一个 error 变量),它会把 *error 塞进错误链,使 errors.Is、errors.As 整条路径失效。

示例 printfw 演示了这一问题。下面这段代码直接触发 vet:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/printfw/wrapptr_demo_test.go

1
2
3
4
5
6
//go:build printfw_demo

func TestWrapPointer(t *testing.T) {
err := fmt.Errorf("operation failed: %w", &myErr{msg: "boom"})
t.Logf("wrapped: %v", err)
}

运行结果如下:

1
2
3
4
$ go test -tags printfw_demo .
# github.com/jianghushinian/blog-go-example/test/go127/printfw
./wrapptr_demo_test.go:17:39: %w wants operand of error type myErr, not pointer type *myErr (defeats errors.Is)
FAIL github.com/jianghushinian/blog-go-example/test/go127/printfw [build failed]

需要注意的是,该子检查只对生效版本大于等于 1.27 的文件生效(依据 go.mod 的 go 指令判断)。也就是说,只有把 go.mod 的 go 指令升到 1.27,它才会报错;停留在老版本的项目不受影响。

此外,printf 检查位于 vet 中,因此它的触发时机与 vet 一致:go build 不会执行 vet,go vet 会报告,go test 会因默认执行 vet 而失败。

正确的写法是包装值本身,此时错误链中的动态类型就是 myErr,errors.Is、errors.As 可以按预期工作:

https://github.com/jianghushinian/blog-go-example/blob/main/test/go127/printfw/printfw_test.go

1
2
3
4
5
6
7
8
9
10
func TestCorrectWrapping(t *testing.T) {
err := fmt.Errorf("operation failed: %w", myErr{msg: "boom"})
if !errors.Is(err, myErr{msg: "boom"}) {
t.Fatalf("errors.Is should match the value type, got: %v", err)
}
var target myErr
if !errors.As(err, &target) {
t.Fatalf("errors.As should match the value type, got: %v", err)
}
}

其他值得一提的变化

除上述内容外,Go 1.27 还有几处与测试、诊断相关的改动,这里简单提一下:

  • goroutineleak profile 转正:基于 GC 可达性检测永久阻塞的 goroutine,可通过 /debug/pprof/goroutineleak 端点获取,用于排查测试或程序中的 goroutine 泄漏。
  • 推荐使用 testing/cryptotest.SetGlobalRandom:替换已经废弃的 crypto/tls.Config.Rand,用于编写可复现的加密相关测试。

总结

本文梳理了 Go 1.27 中与测试相关的主要变化:

  • httptest.NewTestServer 基于内存网络,自动清理,并将 handler 中的 panic 计入测试结果;需要注意内存网络下必须使用 server.Client(),以及 Start() 与 Client() 的调用顺序。
  • testing/synctest.Sleep 是 time.Sleep 与 synctest.Wait 的组合;需要理解持久阻塞和虚拟时钟,避免并发调用 synctest.Sleep。
  • go test 默认启用 stdversion 检查,-json 新增 OutputType 字段(注意不要与 -v 同时使用)。
  • printf vet 针对 %w 指针包装新增了检查,仅在 go 指令不低于 1.27 时生效。

升级到 Go 1.27 时,可以参考下面的清单:

  • 升级 go 指令前先执行 go vet ./...,确认没有因为 stdversion 或 printf 检查而失败。
  • 排查 fmt.Errorf("%w", ...) 中是否包装了指针,尤其是 &err 这类写法。
  • 若测试因 stdversion 失败,确认是真实的版本违约,还是需要提升 go 指令或调整 vet 配置。
  • 若使用自定义测试报告器,注意适配 -json 的 OutputType 字段,并避免 -json 与 -v 同时使用。

希望此文能对你有所启发。

本文示例源码我都放在了 GitHub 中,欢迎点击查看。

延伸阅读

联系我