Go Wiki:Go 测试注释

此页面是对Go 代码审查注释的补充,但专门针对测试代码。

在编辑此页面之前,请先讨论变更,即使是微小的变更。许多人对此有意见,这里不是编辑战的地方。

断言库

避免使用 ‘assert’ 断言库来辅助您的测试。来自 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, "")

但这会过早地停止测试(如果 assert 调用 t.Fatalfpanic),或者省略关于测试哪些地方正确的重要信息。这还迫使 assert 包创建一个全新的子语言,而不是重用现有编程语言(Go 本身)。Go 对打印结构体有很好的支持,所以编写此代码的更好方法是:

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

断言库使得编写不精确的测试变得太容易,并且不可避免地会复制语言中已有的功能,例如表达式求值、比较,有时甚至更多。努力编写精确的测试,既要说明哪里出了问题,也要说明哪里对了,并利用 Go 本身,而不是在 Go 内部创建一种微型语言。

选择人类可读的子测试名称

当您使用 t.Run 创建子测试时,第一个参数用作测试的描述性名称。为了确保测试结果对阅读日志的人类来说是清晰易读的,请选择在转义后仍然有用和可读的子测试名称。(测试运行器会将空格替换为下划线,并转义非打印字符)。

标识输入,请在子测试主体中使用 t.Log 或将它们包含在测试的失败消息中,在那里它们不会被测试运行器转义。

比较完整结构

如果您的函数返回一个结构体,请不要编写对结构体每个字段进行单独比较的测试代码。相反,构造您期望函数返回的结构体,并使用差异深度比较进行一次性比较。同样的规则适用于数组和 map。

如果您的结构体需要进行近似相等或某种其他语义相等比较,或者它包含无法进行相等比较的字段(例如,如果其中一个字段是 io.Reader),可以使用 cmpopts 选项(例如 cmpopts.IgnoreInterfaces)调整 cmp.Diffcmp.Equal 比较来满足您的需求(示例);否则,这种技术就行不通,所以请使用可行的方法。

如果您的函数返回多个返回值,您不需要在比较它们之前将它们包装在一个结构体中。只需单独比较返回值并打印它们即可。

比较稳定结果

避免比较可能固有地依赖于您无法控制的某个外部包的输出稳定性的结果。相反,测试应该比较语义上相关的信息,这些信息是稳定的,并且不易受依赖项更改的影响。对于返回格式化字符串或序列化字节的功能,通常不安全地假定输出是稳定的。

例如,json.Marshal 不保证其可能发出的确切字节。它有自由更改(过去也曾更改)输出。如果 json 包更改其序列化字节的方式,对精确的 JSON 字符串执行字符串相等比较的测试可能会中断。相反,一个更健壮的测试会解析 JSON 字符串的内容,并确保其在语义上等同于某些期望的数据结构。

相等比较和差异

== 运算符使用语言定义的比较来评估相等性。它可以比较的值包括数字、字符串和指针值,以及包含这些值字段的结构体。特别地,它只确定两个指针是否相等,当且仅当它们指向同一个变量。

使用 cmp 包。使用 cmp.Equal 进行相等比较,并使用 cmp.Diff 获取对象之间人类可读的差异。

尽管 cmp 包不是 Go 标准库的一部分,但它由 Go 团队维护,应在 Go 版本更新中产生稳定的结果。它是用户可配置的,应能满足大多数比较需求。

您会发现旧代码使用标准库的 reflect.DeepEqual 函数来比较复杂结构体。新代码优先使用 cmp,并考虑在可行的情况下将旧代码更新为使用 cmpreflect.DeepEqual 对未导出字段和其他实现细节的变化很敏感。

注意:cmp 包也可以用于比较 protocol buffer 消息,通过在比较 protocol buffer 消息时包含 cmp.Comparer(proto.Equal) 选项。

实际值在前,期望值在后

测试输出应在打印期望值之前输出函数返回的实际值。打印测试输出的常用格式是 “YourFunc(%v) = %v, want %v”。

对于差异,方向性不太明显,因此包含一个键来帮助解释失败很重要。参见打印差异

无论您在失败消息中使用哪种顺序,都应明确将该顺序作为失败消息的一部分指示出来,因为现有代码在顺序上不一致。

标识函数

在大多数测试中,失败消息应包含失败函数的名称,即使从测试函数的名称中看起来很明显。

优先采用

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

而不是

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

标识输入

在大多数测试中,如果输入较短,您的测试失败消息应包含函数输入。如果输入的 relevant properties 不明显(例如,因为输入很大或不透明),您应为测试用例命名,并附上测试内容的描述,并将描述作为错误消息的一部分打印出来。

不要使用测试表中测试的索引来代替命名您的测试或打印输入。没有人想去查看您的测试表并计算条目,以找出哪个测试用例失败了。

继续执行

即使测试用例遇到失败,它们也应该尽可能继续运行,以便在一次运行中打印出所有失败的检查。这样,修复失败测试的人就不必玩打鼹鼠游戏(whac-a-mole),修复一个错误,然后重新运行测试去寻找下一个错误。

在实践层面,优先调用 t.Error 而不是 t.Fatal。当比较函数输出的多个不同属性时,对每个比较都使用 t.Error

t.Fatal 通常只适用于测试设置的某个部分失败,而没有该设置就根本无法运行测试的情况。在表驱动测试中,t.Fatal 适用于在测试循环之前设置整个测试函数时发生的失败。影响测试表中单个条目,导致无法继续该条目的失败,应按如下方式报告:

标记测试辅助函数

测试辅助函数是一个执行设置或拆卸任务(例如构造输入消息)的函数,它不依赖于被测试的代码。

如果您传入 *testing.T,请调用 t.Helper,将测试辅助函数中的失败归因于调用该辅助函数的行。

func TestSomeFunction(t *testing.T) {
  golden := readFile(t, "testdata/golden.txt")
  // ...
}

func readFile(t *testing.T, filename string) string {
  t.Helper()

  contents, err := ioutil.ReadFile(filename)
  if err != nil {
    t.Fatal(err)
  }

  return string(contents)
}

当这种模式模糊了测试失败与导致失败的条件之间的联系时,请勿使用此模式。具体来说,不应使用 t.Helper 来实现断言库。

如果您的函数返回的输出很大,那么当测试失败时,阅读失败消息的人可能很难找到差异。与其同时打印返回的值和期望的值,不如生成一个差异。

在您的失败消息中添加一些文本,解释差异的方向。

当您使用 cmp 包时(如果您将 (want, got) 传递给函数),类似 “diff -want +got” 的文本很好,因为您添加到格式字符串中的 -+ 将与差异行开头实际出现的 +- 相匹配。

差异将跨越多行,因此您应该在打印差异之前打印一个换行符。

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

表驱动测试应在许多不同测试用例可以使用相似测试逻辑进行测试时使用,例如测试函数的实际输出是否等于期望输出(示例),或测试函数的输出是否总是符合同一组不变量。

当某些测试用例需要使用与其他人不同的逻辑进行检查时,编写多个测试函数更为合适。当表中的每个条目都必须经过多种条件逻辑才能对特定输入执行正确的输出检查时,您的测试代码逻辑会变得难以理解。如果它们有不同的逻辑但设置相同,那么在单个测试函数内的一系列子测试也可能是有意义的。

您可以将表驱动测试与多个测试函数结合使用。例如,如果您正在测试函数的非错误输出与期望输出完全匹配,并且您还在测试当函数接收到无效输入时是否返回某个非 nil 错误,那么通过编写两个独立的表驱动测试函数(一个用于正常的非错误输出,一个用于错误输出)可以实现最清晰的单元测试。

测试错误语义

当单元测试执行字符串比较或使用 reflect.DeepEqual 检查特定输入是否返回特定类型的错误时,您可能会发现,如果将来不得不修改任何错误消息的措辞,您的测试会变得脆弱。由于这有可能将您的单元测试变成一个变化检测器,请勿使用字符串比较来检查您的函数返回的是哪种类型的错误。

可以使用字符串比较来检查被测包返回的错误消息是否满足某些属性,例如,是否包含参数名称。

如果您关心测试函数返回错误的精确类型,则应将面向人类的错误字符串与暴露用于程序化使用的结构体分开。在这种情况下,您应避免使用 fmt.Errorf,因为它倾向于破坏语义错误信息。

许多编写 API 的人并不关心他们的 API 对不同输入返回的具体错误类型。如果您的 API 是这样的,那么使用 fmt.Errorf 创建错误消息就足够了,然后在单元测试中,只测试当您期望出现错误时,错误是否为非 nil。


此内容是Go Wiki的一部分。