go 语言 测试惯例

运行某个包的测试

不指定包名:go test 命令如果没有参数指定包,默认使用当前目录对应的包运行测试,且测试结果不会被缓存。第二次运行测试,测试结果中没有包含(cached)标示,说明测试结果没有被缓存,每次执行都会重新构建测试

# gott/hello 当前目录为包名为 hello的目录
go test
PASS
ok  	gott/hello	1.025s

go test
ok  	gott/hello	1.025s

指定包名:测试结果会被缓存

go test gott/hello
ok  	gott/hello	(cached)

运行所有测试

使用go test ./...标记运行所有包的测试,测试结果会被缓存

go test ./...
ok  	gott/hello	1.026s
ok  	gott/hi	0.040s
ok  	gott/pprint	0.017s
ok  	gott/prime	0.014s

go test ./...
ok  	gott/hello	(cached)
ok  	gott/hi	(cached)
ok  	gott/pprint	(cached)
ok  	gott/prime	(cached)

有效的记录测试失败信息

测试失败的信息一般的形式是“f(x) = y, want z”,其中f(x)解释了失败的操作和对应的输入,y是实际的运行结果,z是期望的正确的结果。比起那些失败就打印满屏的堆栈信息的错误日志,这种记录格式使得测试人员很容易定位到问题所在,甚至都不需要去看源码文件。

got := sum(x1, x2)
if got != want {
	t.Errorf("sum(%d, %d) = %d, want %d", x1, x2, got, want)
}

随机测试如何预判结果

一种方法是编写另一个对照函数,使用简单和清晰的算法,虽然效率较低但是行为和要测试的函数是一致的,然后针对相同的随机输入检查两者的输出结果。

第二种是生成的随机输入的数据遵循特定的模式,这样我们就可以知道期望的输出的模式。比如基于随机种子生成需要的大量数据,测试日志中不用去记录这些大量的数据,只需要记录这个随机种子即可,之后可以根据这个种子重现失败的测试用例,查找代码问题所在

测试中的异常

在测试代码中不要调用 log.Fatalos.Exit ,调用这类函数会导致整个测试提前退出,后面的测试都将无法运行。调用这些函数的特权应该放在 main 函数中。如果真的有意外发生导致测试过程中发生 panic 异常,那么在测试中应该尝试用 recover 捕获异常,并记录下来,然后将当前测试当作失败处理(即调用t.Effor/t.Fail/t.FailNow之类的方法)。

在运行测试的时候,应该确保所有测试都得到运行,这样当测试运行结束后,就可以得到所有失败的测试用例的信息,而不是在某个测试失败后,就停止运行其后面的测试。

func TestLogFatal(t *testing.T) {
	log.Fatal("Test encounter a fatal")
	// os.Exit(2)
}

func TestPrint(t *testing.T) {
	t.Log("just Print")
	t.Log(os.Getenv("GOROOT"))
}

由于调用了log.Fatalos.Exit,在TestLogFatal()后面的测试用例都不会被运行

go test -v  gott/hello
=== RUN   TestLogFatal
2021/07/24 17:34:02 Test encounter a fatal
FAIL    gott/hello      0.007s
FAIL
# 没有打印 TestPrint()的测试日志

# 把 TestPrint()放到 TestLogFatal()的前面
 go test -v  gott/hello
=== RUN   TestPrint
    hello_test.go:24: just Print
    hello_test.go:25: /Users/ga/m/opt/go/go_root
--- PASS: TestPrint (0.00s)
=== RUN   TestLogFatal
2021/07/24 17:37:04 Test encounter a fatal
FAIL    gott/hello      0.013s
FAIL

TestPrint 可以运行,但 TestLogFatal 之后的测试用例都不会得到运行,在调用 log.Fatal 函数后,整个测试程序就退出了

mock 测试中的敏感对象

  • 对外部环境的依赖

    数据库的连接,第三方接口的调用

  • 导致生产代码产生一些调试信息的钩子函数

  • 诱导生产代码进入某些重要状态的改变

    超时、错误,甚至是一些刻意制造的并发行为等因素

应该对以上这些对象进行仿造(mock),从而得到一个纯净的测试环境。有一个需要注意的地方,如果在某个测试用例中mock了某些对象,在这个测试用例运行完成后,要对这些mock的对象进行还原,以避免影响其它测试用例。

func TestCheckQuotaNotifiesUser(t *testing.T) {
    // Save and restore original notifyUser.
  	// 保存 及 恢复 mock的原始对象
    saved := notifyUser
    defer func() { notifyUser = saved }()

    // Install the test's fake notifyUser.
    var notifiedUser, notifiedMsg string
    notifyUser = func(user, msg string) {
        notifiedUser, notifiedMsg = user, msg
    }
    // ...rest of test...
}

先把要mock的对象保存起来,等测试完成后,再对其进行恢复,可以使用defer语句来延后执行处理恢复的代码

避免脆弱的测试

  • 一个好的测试不应该在程序仅仅只是做了微小变化就失败

  • 一个好的测试不应该在遇到一点小错误时就立刻退出测试,它应该尝试报告更多的相关的错误信息,因为我们可能从多个失败测试的模式中发现错误产生的规律

  • 一个好的测试的关键是首先实现你期望的具体行为,然后才是考虑简化测试代码、避免重复。如果直接从抽象、通用的测试库着手,很难取得良好结果。

  • 保持测试代码的简洁和内部结构的稳定,特别是对断言部分要有所选择,比如不要对字符串进行全字匹配,而是针对那些在项目的发展中是比较稳定不变的子串

  • 测试时涉及到对全局变量产生修改的那些测试,要以串行的方式运行,不能并行运行