平等的Go包:github.com/google/go-cmp

313 阅读4分钟

促进平等的Go包:github.com/google/go-cmp

Jan 22, 2021


软件测试可以归结为,从字面上看,就是比较两个值:实际输出预期输出。要产生预期的输出,步骤通常是非常简单的。

  1. 有一个接收某种输入(或输入)的API,例如一个函数或一个方法。
  2. 用一些众所周知的输入来调用该API,并且
  3. 将调用的实际输出预期输出(或输出)进行比较。

这些步骤可能需要根据实际测试的API进行调整,但这是常见的模式。

用Go写测试并不困难,因为标准库已经包括了一个为自动测试提供支持的,也许困难的部分是在比较实际预期结果时使用什么方法。

公平地说,因为大多数时候我们是使用等/不等运算符来比较基本类型,所以我们的最终目标很容易实现,让我们讨论一下在其他情况下这有什么难的。

比较结果

下面是一些代码片段,实际运行完整的代码例子,请参考最终仓库

当使用基本类型时,比较结果很简单,例如测试下面的函数Sum ,很容易测试。

// operation.go
func Sum(a, b int) int { /* ... */ }

// operation_test.go -> TestSumSimple
if actual := Sum(2, 1); actual != 3 {
	t.Fatalf("expected 3, actual %d", actual)
}

而且这些运算符在使用非基本类型时也能工作,比如struct 类型。

// operation.go
type Dollar struct {
	Superunit int
	Subunit   int
}

func SumDollars(a, b Dollar) Dollar { /* ... */ }

// operation_test.go -> TestSumDollars_PrimitiveTypes
expected := Dollar{4, 10}
if actual := SumDollars(Dollar{1, 20}, Dollar{2, 90}); actual != expected {
	t.Fatalf("expected %#v, actual %#v", expected, actual)
}

然而,当涉及到非基本类型,如切片或地图时,它开始变得复杂。那么,我们有什么选择呢?

选项1:使用等于和不等于运算符

让我们以切片为例,当比较结果时,做类似以下的工作。

// operation.go
func ConcatenateSlice(a, b []int) []int { /* ... */ }

// operation_test.go -> TestConcatenateSlice_Equally
actual := ConcatenateSlice(test.input.a, test.input.b)

if len(actual) != len(test.expected) {
	t.Fatalf("result lenghts are different: expected %d, actual %d", len(test.expected), len(actual))
}

for i, v := range actual {
	if v != test.expected[i] {
		t.Fatalf("values at %d index are different: expected %d, actual %d", i, test.expected[i], v)
	}
}

关于上面的代码,需要记住的一点是,为了确保切片是相同的,我们实际上是使用它们的索引值一个一个地进行比较**,**可能这就是我们试图测试的东西,但是如果我们试图确认两个切片包含相同的值,无论它们的顺序如何,那么我们的代码将变成这样。

// operation_test.go -> TestConcatenateSlice_Semantically
actual := ConcatenateSlice(test.input.a, test.input.b)

if len(actual) != len(test.expected) {
		t.Fatalf("result lenghts are different: expected %d, actual %d", len(test.expected), len(actual))
}

found := make(map[int]struct{})

for _, v := range actual {
		found[v] = struct{}{}
}

for _, v := range test.expected {
		if _, ok := found[v]; !ok {
				t.Fatalf("value %d is missing in the result", v)
		}

		delete(found, v)
}

if len(found) != 0 {
		t.Fatal("result does not contain expected values")
}

但是上面的代码并不包括输入包含重复值的情况,我们需要更新它以支持这种情况。

选项2:使用reflect.DeepEqual

另一种比较结果的方法是使用 reflect.DeepEqual.这个函数比较的值是字面上的相等,所以与选项1类似,语义上的相等不支持开箱即用。

// operation.go
func ConcatenateSlice(a, b []int) []int { /* ... */ }

// operation_test.go -> TestConcatenateSlice_Reflect
actual := ConcatenateSlice(test.input.a, test.input.b)

if !reflect.DeepEqual(test.expected, actual) {
	t.Fatalf("values are not the same: expected %#v, actual %#v", test.expected, actual)
}

关于reflect.DeepEqual ,有一点需要注意的是,由于它的实现方式,当数值实际上不一样的时候,你可能会得到正面的结果,请参考这个评论

选项3:使用github.com/google/go-cmp

另一种比较结果的方法是使用github.com/google/go-c…包,这种方法与前两种方法相比有一些非常重要的区别和特点。

如果需要,可以重写默认的比较行为

例如允许使用近似值的结果是相同的,在这种情况下,math.Pi 和至少有4位小数的近似值相等的

// operation_test.go -> TestCmp_ApproximatePi
if !cmp.Equal(math.Pi, test.input, cmpopts.EquateApprox(0, 0.0001)) {
	t.Fatalf("value diverge too far: %f", test.input)
}

或者当测试值时,我们关心的是它们的内容而不是它们的顺序,比如[]int slice。

// operation_test.go -> TestCmp_SlicesSematicallySorting
opt := cmpopts.SortSlices(func(a, b int) bool {
	return a < b
})

if !cmp.Equal(test.input, test.output, opt) {
	t.Fatalf("values are not the same %s", cmp.Diff(test.input, test.output, opt))
}

以及类似于比较地图时的情况,如map[int]string

// operation_test.go -> TestCmp_MapsSematicallySorting
opt := cmpopts.SortMaps(func(a, b int) bool {
	return a < b
})

if !cmp.Equal(test.input, test.output, opt) {
	t.Fatalf("values are not the same %s", cmp.Diff(test.input, test.output, opt))
}

类型可以定义一个Equal 方法,以用于平等。

例如,在我们需要明确地允许用不同的方式来表示平等的情况下。

// operation.go
type Message string

func (m Message) Equal(b Message) bool {
	return strings.ToLower(string(m)) == strings.ToLower(string(b))
}

// operation_test.go -> TestMessage_Equal
if !cmp.Equal(test.input, test.output) {
	t.Fatalf("values are not the same %s", cmp.Diff(test.input, test.output))
}

默认情况下,未导出的字段不被比较

这与reflect.DeepEqual 的做法正好相反。当使用这个包时,我们必须明确指出在比较未导出的字段时我们的计划是什么。要忽略它们,我们可以使用类似的方法。

// operation.go
type Alert struct {
	Message Message
	code    int
}

// operation_test.go -> TestAlert
if !cmp.Equal(test.input, test.output, cmpopts.IgnoreUnexported(operation.Alert{})) {
	t.Fatalf("values are not the same %s", cmp.Diff(test.input, test.output, cmpopts.IgnoreUnexported(operation.Alert{})))
}

如果需要比较未导出的字段,那么推荐使用Exporter的方式。

有一种方法可以获得比较值的差异

我非常喜欢这个功能,比如说

type Person struct {
	Name string
	Age  int
}
	
fmt.Println(cmp.Diff(Person{"Name", 99}, Person{"Name", 100}))

打印出来。

  main.Person{
  	Name: "Name",
- 	Age:  99,
+ 	Age:  100,
  }

当那些没有通过的测试失败时,这个输出对调试测试失败很有用。

总结

虽然在标准库中已经有了一些选项,但是使用github.com/google/go-cmp 来测试结果,给了我们新的灵活性和额外的功能,以表明如何比较数值并确定到底有什么不同,不仅如此,还有另外一些包,如 protobuf/testing/protocmpgotest.tools/v3/assert这些包是对这个包的补充,根据你正在进行的项目和你目前的需要,也可能是有用的。

最后,github.com/google/go-cmp 是一个很好的外部包,它使Go的测试变得更加愉快。

回到帖子