[译] Go语言测试进阶版建议与技巧

阅读本篇文章前,你最好已经知道如何写基本的单元测试。本篇文章共包含3个小建议,以及7个小技巧。

建议一,不要使用框架

Go语言自身已经有一个非常棒的测试框架,它允许你使用Go编写测试代码,不需要再额外学习其它的库或测试引擎。关于断言方面的帮助函数,你可以看看这个 testing,或者这个 assert.go :)

建议二,使用”_test”包名

相较于直接使用被测试代码的包名,使用 *_test包名使得测试代码只能访问包中对外暴露出的接口。这使得你在写测试时更多的是站在包使用者的角度来写,从而使得你可以思考包的接口是否设计合理。

建议三,避免全局常量配置项

避免使用全局常量配置项,因为测试代码无法修改常量。下面举了三个例子做对比:

1
2
3
4
5
6
7
8
9
10
11
// 1. 不好,测试代码无法修改它
const port = 8080

// 2. 好一些,测试代码可以修改它
var port = 8080

// 3. 更好的方式,测试代码可以通过 struct 配置 Port
const defaultPort = 8080
type AppConfig {
Port int // 构造函数中初始化为 defaultPort
}

技巧一,加载测试数据

Go对从文件中加载测试数据提供了非常好的支持。首先,Go编译时会忽略testdata目录。然后,当测试代码运行时,Go会将当前目录作为包的目录。这使得你可以使用相对路径来访问testdata目录。看例子:

1
2
3
4
5
6
7
8
func helperLoadBytes(t *testing.T, name string) []byte {
path := filepath.Join("testdata", name) // relative path
bytes, err := ioutil.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return bytes
}

技巧二,保存测试时的预期结果至.golden文件中

将测试时的预期结果保存至.golden文件中。并且提供一个flag来决定是否更新它。使用这个技巧可以避免在测试代码中硬编码预期输出结果非常复杂的内容。看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
var update = flag.Bool("update", false, "update .golden files")
func TestSomething(t *testing.T) {
actual := doSomething()
golden := filepath.Join(“testdata”, tc.Name+”.golden”)
if *update {
ioutil.WriteFile(golden, actual, 0644)
}
expected, _ := ioutil.ReadFile(golden)

if !bytes.Equal(actual, expected) {
// FAIL!
}
}

技巧三,测试时的初始化、清理代码

有时候测试代码比较复杂,在跑测试的case之前需要初始化好环境,这可能会包含很多不相关的错误检查,比如测试文件是否加载成功,测试数据是否能按json格式解析等等。这使得测试代码变得很不纯粹优雅。

为了解决这个问题,你可以把不相关的代码放入帮助函数中。这些函数永远不返回error,而是传入*testing.T,当有错误发生时直接断言报错。

同样的,如果帮助函数需要在结束后做清理工作,帮助函数应该返回一个函数做清理工作。看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func testChdir(t *testing.T, dir string) func() {
old, err := os.Getwd()
if err != nil {
t.Fatalf("err: %s", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("err: %s", err)
}
return func() { // 返回清理函数,供外部需要清理时调用
if err := os.Chdir(old); err != nil {
t.Fatalf("err: %s", err)
}
}
}
func TestThing(t *testing.T) {
defer testChdir(t, "/other")()
// ...
}

上面的例子包含了另外一个关于defer使用的非常酷的技巧。defer testChdir(t, "/other")()会先执行testChdir内的代码,并且在TestThing结束时执行testChdir所返回的清理函数中的代码。

技巧四,当依赖第三方可执行程序时

有时测试代码会依赖第三方可执行程序,我们可以通过以下方法检查程序是否存在,存在则执行测试,不存在则跳过测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
var testHasGit bool
func init() {
if _, err := exec.LookPath("git"); err == nil {
testHasGit = true
}
}
func TestGitGetter(t *testing.T) {
if !testHasGit {
t.Log("git not found, skipping")
t.Skip()
}
// ...
}

技巧五,测试包含os.Exit的代码

该方法通过启动子进程的方式,避免测试包含os.Exit的代码导致测试程序提前退出。看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func CrashingGit() {
os.Exit(1)
}
func TestFailingGit(t *testing.T) {
if os.Getenv("BE_CRASHING_GIT") == "1" { // 子进程进入这个逻辑分支
CrashingGit()
return
}
// 被 go test 执行,
// 设置好环境变量,启动子进程再次执行 TestFailingGit
cmd := exec.Command(os.Args[0], "-test.run=TestFailingGit")
cmd.Env = append(os.Environ(), "BE_CRASHING_GIT=1")
err := cmd.Run()
if e, ok := err.(*exec.ExitError); ok && !e.Success() {
return
}
t.Fatalf("Process ran with err %v, want os.Exit(1)", err)
}

上面例子的思想是,当Go测试框架运行TestFailingGit时,启动一个子进程(os.Args[0]即生成的Go测试程序)。子进程再次运行测试程序,并只执行TestFailingGit(通过参数 -test.run=TestFailingGit 实现),并且设置了环境变量BE_CRASHING_GIT=1,这样子进程将执行CrashingGit()

技巧六,将mocks、helpers放入testing.go文件中

testing.go文件会被当做一个普通的源码文件,而不是测试代码文件。这样在其它的包中或其它包的测试代码可以使用这些mocks、helpers。

技巧七,单独处理耗时长的测试

当存在一些耗时很长的测试时,等待所有的测试结束会让人烦躁。解决方法是将这些耗时长的测试放入_integration_test.go文件中,并在该文件的头部加入编译tag。看例子:

1
// +build integration

这样Go测试时默认不会运行这些测试代码。
如果想运行所有的测试代码,你可以这样:

1
go test -tags=integration

以下是我个人使用alias做的一个简便命令,可以运行当前目录以及子目录中除vendoer目录外的所有测试:

1
alias gtest="go test \$(go list ./… | grep -v /vendor/) -tags=integration"

这个命令可以配合-v参数使用:

1
2
3
4
$ gtest

$ gtest -v

感谢阅读,英文原文地址:Go advanced testing tips & tricks (https://medium.com/@povilasve/go-advanced-tips-tricks-a872503ac859)

本文完,作者yoko,尊重劳动人民成果,转载请注明原文出处: https://pengrl.com/p/32101/

0%