Golang - test package 在內跟在外的差別

今天要談談的是 golang test file 的 package 要在 internal 還是要放在 external 上?所謂的 internal 跟 external 指的是,你的 test file 是否要放在你要測試的程式碼的同一個 package 下,還是應該放在另外一個 package 上,例如 xxx_test package。

這兩者選擇時機點有點不太一樣,有什麼好處跟壞處呢?今天就來探討一下。

如果有在看 Go 的原始碼就會發現 internal or external 的 package 都有在使用,例如我們來看,golang/go/src/net/http 下的 internal 跟 external test 的數量各自為多少。這時候需要介紹一下可以用 go list 指令就能很方便地幫我們做計算,原因在於 go list 指令有紀錄:

1
2
3
4
5
6
7
type Package struct {
...
// Source files
...
TestGoFiles []string // _test.go files in package
XTestGoFiles []string // _test.go files outside package
}

來看看 net/http package 下的兩者 test 數量的差別:

1
2
3
4
5
go list -f={{.TestGoFiles}} .
[cookie_test.go export_test.go filetransport_test.go header_test.go http_test.go proxy_test.go range_test.go readrequest_test.go requestwrite_test.go response_test.go responsewrite_test.go server_test.go transfer_test.go transport_internal_test.go]

go list -f={{.XTestGoFiles}} .
[alpn_test.go client_test.go clientserver_test.go example_filesystem_test.go example_handle_test.go example_test.go fs_test.go main_test.go request_test.go serve_test.go sniff_test.go transport_test.go]

可以看出來兩者 test 數量差不多。

internal package

先來講講 internal package 的優點及缺點是什麼,如果你的 test file 跟 你要測試的程式碼放同一個 package,那最大的優點就是可以直接存取 package 內上的所有變數或是函數等等之類的,方便你做一個全面性的測試,也因此你的測試覆蓋率也就會提高。而且寫的測試也比較能測出你想要看的每個細節。

但缺點是什麼呢?

  1. 測試程式碼與你的被測試的程式碼本身是被綁定的,一旦裡面的實作細節改了,那你的測試程式碼通常也會壞掉,需要做維護跟修正,但這樣是應該的,你也應該去修正測試去符合你的新 feature。
  2. 再來比較麻煩的就是會遇到 import cycle 的問題,有可能你的 test 上需要用到其他 package 下的 function 來輔助測試之類的,例如 testutil,但問題是 testutil 裡面也需要 import 被測試的程式碼的 package。那就會遇到 import cycle 的問題,在 Go 這樣的行為是不允許的。

例如我們常用的 strings package 就有運用 external package 的方法來避免上面說的 import cycle 問題,主因在於 strings package 下的測試需要用 testing package,那 testing package 本身又需要用到 strings package 的 function,因此如果你 strings package 下的 test file package 也是 strings 的話就會出錯,因此可以看到官方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package strings_test

import (
"bytes"
"fmt"
"io"
"math"
"math/rand"
"reflect"
"strconv"
. "strings"
"testing"
"unicode"
"unicode/utf8"
"unsafe"
)

將 package 改成 strings_test 就是為了這個原因。另外可以注意到要測試 strings 下的 public function 就會透過 import . "strings" 來達到。

但是如果要測試 strings 下未公開的 function 或是 variable 的話怎麼辦呢?從官方的 test file 可以發現有一個叫做 export_test 的檔案,這個的 package 命名成 strings 並且將未公開的 function or variable 來進行 public 出去。以方便 striings_test import strings package 的時候可以拿到,並且進行測試。

1
2
3
4
5
6
7
8
9
10
11
12
13
package strings

func (r *Replacer) Replacer() any {
r.once.Do(r.buildOnce)
return r.r
}

func (r *Replacer) PrintTrie() string {
r.once.Do(r.buildOnce)
gen := r.r.(*genericReplacer)
return gen.printNode(&gen.root, 0)
}
...

在這邊比較特別是在這邊定義一些 helper 的功能來輔助 test 的撰寫。

如果你看 fmt package 的測試,會發現也有 export_test file:

1
2
3
4
5
6
7
8
// Copyright 2012 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package fmt

var IsSpace = isSpace
var Parsenum = parsenum

net/http package 下也有:

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
37
// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Bridge package to expose http internals to tests in the http_test
// package.

package http

import (
"context"
"fmt"
"net"
"net/url"
"sort"
"sync"
"testing"
"time"
)

var (
DefaultUserAgent = defaultUserAgent
NewLoggingConn = newLoggingConn
ExportAppendTime = appendTime
ExportRefererForURL = refererForURL
ExportServerNewConn = (*Server).newConn
ExportCloseWriteAndWait = (*conn).closeWriteAndWait
ExportErrRequestCanceled = errRequestCanceled
ExportErrRequestCanceledConn = errRequestCanceledConn
ExportErrServerClosedIdle = errServerClosedIdle
ExportServeFile = serveFile
ExportScanETag = scanETag
ExportHttp2ConfigureServer = http2ConfigureServer
Export_shouldCopyHeaderOnRedirect = shouldCopyHeaderOnRedirect
Export_writeStatusLine = writeStatusLine
Export_is408Message = is408Message
)

把原本未公開的 error variable public 出去。

而且也不用擔心 export_test public 出去的東西會被非 test go file 存取到,因為 Go 的設計關係不會讓 _test file 出現的 public 的東西可以讓其他檔案 import 到的也存取不到的。

external package

回過頭來講那 external package 說了可以特別解決 import cycle 的問題,又能帶來什麼好處呢?首先,external package 本身的定義就是只能測試 public 的東西,所以你可以想像妳測試的東西會是這個 package 下公開的 interface,或是你要稱為 API 也可以,通常 Public 的東西除非要升級重大版本不然是不會輕易做改變的,因為這樣會帶來 breaking change。所以 client 端的程式碼都要跟著做修正,也因此通常 external package 的 test 是不會那麼頻繁的改動的。

相對的 external package 的缺點就在於,不過無法測試到未公開的 function 之類的。這時候當然就是要搭配 export_test 的做法去實現。

不過也有另外一種看法是,internal package 跟 external package 都做,internal 當然是針對 package 下所有的去做全面測試,但 external package 的方式就偏向是 integration test,是以 client 端的角度去看待的。

例如我們看 /net/http 下的 client_test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestClient(t *testing.T) {
setParallel(t)
defer afterTest(t)
ts := httptest.NewServer(robotsTxtHandler)
defer ts.Close()

c := ts.Client()
r, err := c.Get(ts.URL)
var b []byte
if err == nil {
b, err = pedanticReadAll(r.Body)
r.Body.Close()
}
if err != nil {
t.Error(err)
} else if s := string(b); !strings.HasPrefix(s, "User-agent:") {
t.Errorf("Incorrect page body (did not begin with User-agent): %q", s)
}
}

其中就是 import httptest 的 Server 來進行測試,而 client_test package 則是命名為 http_test。在這樣的設計下,可以盡情的 import 其他 package 的操作來輔助而不用擔心 import cycle。

總結

最後來想想 internal 跟 external 的 test file 命名方式有需要區分嗎?可以參考 net/http 下的 transport test 的命名,分別有 transport_internal_test.gotransport_test.go 來作為 internal 跟 external 的區別。

今天的文章比較短,但我覺得挺有趣的。也算是提供 import cycle 的解法,另外在寫 test 的時候也可以好好思考究竟我的 test 是屬於什麼性質我該放在 package 下 還是 external package 下。