lyyyuna 的小花园

动静中之动, by

RSS

Golang 官方关于单元测试方法的一些建议
发表于 2021-01

前言

本文是翻译+理解+改编自 Go 官方的TestComments,原文是 Go 源码本身开发时Code Review的注意项。

断言

在测试时避免使用断言库。那些有类似xUnit测试框架使用背景的 Go 开发者喜欢写如下的代码:

assert.isNotNil(t, "obj", obj)
assert.stringEq(t, "obj.Type", obj.Type, "blogPost")
assert.intEq(t, "obj.Comments", obj.Comments, 2)
assert.stringNotEq(t, "obj.Body", obj.Body, "")

但有的断言库会过早的终止测试用例(如果断言中调用了t.Fatalf或者panic),有的会漏掉测试如何通过的关键信息。测试应该是精确的,能够一眼看出哪些部分导致用例失败,哪些部分是正确的。不仅如此,放着 Go 的语法不用,断言库却常常创造自己的一整套语法来做非空判断(isNotNil)、字符串比较(stringEq)、表达式求值等等。

综上,上面那个例子应该改写为:

if obj == nil || obj.Type != "blogPost" || obj.Comments != 2 || obj.Body == "" {
    t.Errorf("AddPost() = %+v", obj)
}

使用可读性高的子测试用例名

当使用t.Run来创建子测试时,第一个参数是用例的名字。为了确保测试结果在日志上具备高可读性,用例名应该描述要测试的场景,并保证在转义后仍可读(测试用例执行时,会将空格转换为下划线,并转义不可打印的字符)。

也可以在子测试的函数体中使用t.Log打印子测试用例名,或者包含在失败信息中,这两个地方用例名都不会被转义。

直接比较结构体

如果函数返回的是结构体,不推荐一个一个字段的比较,而是构造出你预期的结果,用下文提到的cmp 方法直接比较。该规则同样适用于数组和字典。

如果结构体之间是某种语义上的相等,或者某些字段不支持比较操作(比如类型为io.Reader的字段),那你可以在cmp.Diffcmp.Equal的参数中传入类型为cmpoptscmpopts.IgnoreInterfaces来忽略它们。要是还不行,那,就自由发挥吧。

如果函数返回多个结果,逐个比较并打印,不必拼成一个结构体。

只比较稳定的结果

如果被测函数依赖的外部包不受控制,导致输出结果不稳定,就该避免在测试中使用该结果。相反,应该去比较那些在语义上稳定的信息。

那些输出格式化/序列化字符串的功能,不该假设其输出的字符串一尘不变。举个实际的例子,json.Marshal并不保证输出的字节流永远是相同的,历史上该函数的实现变动过。如果从字符串是否相等的角度去测试json库,那测试用的执行结果无法稳定。而鲁棒性的做法应去解析 JSON 字符串,然后比较其中的每个对象。

相等性比较和diff

==操作符会按照Go 语言规范定义的行为执行相等比较。数字、字符串和指针可以执行比较操作,结构体的每个字段如果都是以上三种类型,那结构体也可做比较。其中指针比较特别,只支持相等操作。

使用cmp包的cmp.Equal可直接比较两个任意的对象,使用cmp.Diff则会输出这两个对象间的差异,而且可读性非常高。

虽然cmp不在标准库中,但它是由 Go 官方维护的,和每一版的 Go 兼容,适用于大部分对象间的比较需求。

老旧的代码中会使用reflect.DeepEqual函数来比较复杂的结构体,现在建议用cmp包来代替,因为reflect.DeepEqual对一些未导出的字段和实现细节的变动非常敏感。

cmp包使用时添加cmp.Compare(proto.Equal)选项即可直接用于 protocol buffer 消息的比较。)

不仅打印期望值,也要打印实际值

测试结果在打印期望结果之前,应该打印函数的实际结果。通常我们会将测试结果格式化为:YourFunc(%v) = %v, want %v

对于 diff 的输出,期望结果和实际结果谁前谁后不明显,这时需要加入额外信息帮助理解。这两个什么顺序并不重要,重要的是整个工程应该具有一致性。

标识函数名

在大部分测试中,失败消息应该包含所在函数名,即使该消息显而易见来自测试函数。

优先使用:

t.Errorf("YourFunc(%v) = %v, want %v", in, got, want)

而不是:

t.Errorf("got %v, want %v", got, want)

标识输入

在大部分测试中,函数输入参数也应该包含在失败消息中。如果输入参数的相关属性不明显(比如,参数较大或晦涩难懂),你应该在测试名中描述本测试的内容,并且将描述信息放入错误消息中。

对于表格驱动型测试,不要将序号作为测试名的一部分。在测试用例失败后,没人希望回到表格中一个个数来找出失败来自哪个用例。

失败继续执行

即使测试用例遇到了失败,它也应尽可能地继续执行,以便能在一次运行中打印出所有失败检查点。这样,如果有人要依照测试结果修复代码时,不用一遍遍重复执行用例来找出下一个 bug。

从实际角度出发,优先使用t.Error而不是t.Fatal。当比较函数的多个输出时,对每一个分别使用t.Error

t.Fatal适合在 setup 中使用,因为 setup 一旦失败,其余的步骤便没有再执行的必要。表格驱动的测试中,t.Fatal适合在所有子测试开始前使用。表格中的每一测试用例若遇到不可恢复的错误,如何处理要分具体情况:

标记测试辅助函数

辅助函数常用于 setup 和 teardown 任务中,比如构造一个测试数据。

在辅助函数中调用t.Helper后,如果辅助函数中某个判断出错,那在测试日志中的错误提示会忽略该辅助函数的调用栈,标记出错的行会焦点在测试用例中,而非在辅助函数中。有点绕,看个例子便一目了然。

例如未使用t.Helper之前:

package main

import "testing"

func testHelper(t *testing.T) {
	t.Helper()
	t.Fatal()
}

func TestHelloWorld(t *testing.T) {
	testHelper(t)
}

出错信息为:

 FAIL: TestHelloWorld (0.00s)
    main_test.go:6:
FAIL
FAIL	test.test	0.001s
FAIL

标记代码后:

package main

import "testing"

func testHelper(t *testing.T) {
	t.Helper()
	t.Fatal()
}

func TestHelloWorld(t *testing.T) {
	testHelper(t)
}

出错信息为:

 FAIL: TestHelloWorld (0.00s)
    main_test.go:11:
FAIL
FAIL	test.test	0.002s
FAIL

可以看到,显示出错的第几行不一样。显然若testHelper被多个测试用例调用,后者的测试日志更易排查。

打印 diff

如果函数返回的输出比较长,而出错的地方只是其中一小段,那很难一眼看出区别。这对调试不友好,建议直接输出期望和实际结果的 diff 值。

表格驱动测试 vs 多个测试函数

当多个测试用例有着相同的测试逻辑,只是输入数据不同时,就应该使用表格驱动测试方法。

而当每个测试用例需用不同的方法验证时,表格驱动就显得不合适,因为那样就不得不写一堆控制变量放入表格中,将原本的测试逻辑淹没其中,降低了用例的可读性和表格的可维护性。

实际测试两种方法需结合使用。比如可以写两个表格驱动测试方法,一个测试函数的正常返回结果,另一个测试不同错误消息。

测试错误语义

单元测试避免使用字符串比较或者是reflect.DeepEqual去检查函数的错误输出。错误消息若随着业务成长需要经常变动,你会不得不经常修改单元测试用例。

而依赖库中的错误消息则相对稳定,拿来做字符串比较是可接受的。

我们应该区分哪些是为了提高排查效率增添的错误消息,哪些只是用于内部编程,而多用fmt.Errorf恰恰会打破内部的稳定性,应尽量少用。

许多人并不关心他们的 API 返回具体什么错误消息,这种情况下,单元测试中只做错误非空判断就可以了。