Go语言精进之路:从新手到高手的编程思想、方法和技巧(2)
上QQ阅读APP看书,第一时间看更新

42.4 表驱动测试实践中的注意事项

1. 表的实现方式

在上面的示例中,测试中使用的表是用自定义结构体的切片实现的,表也可以使用基于自定义结构体的其他集合类型(如map)来实现。我们将上面的例子改造为采用map来实现测试数据表:

// chapter8/sources/table_driven_strings_with_map_test.go
func TestCompare(t *testing.T) {
    compareTests := map[string]struct {
        a, b string
        i    int
    }{
        `compareTwoEmptyString`:     {"", "", 0},
        `compareSecondParamIsEmpty`: {"a", "", 1},
        `compareFirstParamIsEmpty`:  {"", "a", -1},
    }

    for name, tt := range compareTests {
        t.Run(name, func(t *testing.T) {
            cmp := strings.Compare(tt.a, tt.b)
            if cmp != tt.i {
                t.Errorf(`want %v, but Compare(%q, %q) = %v`, tt.i, tt.a, tt.b, cmp)
            }
        })
    }
}

不过使用map作为数据表时要注意,表内数据项的测试先后顺序是不确定的

执行两次上面的示例,得到下面的不同结果:

// 第一次

$go test -v table_driven_strings_with_map_test.go
=== RUN   TestCompare
=== RUN   TestCompare/compareTwoEmptyString
=== RUN   TestCompare/compareSecondParamIsEmpty
=== RUN   TestCompare/compareFirstParamIsEmpty
--- PASS: TestCompare (0.00s)
    --- PASS: TestCompare/compareTwoEmptyString (0.00s)
    --- PASS: TestCompare/compareSecondParamIsEmpty (0.00s)
    --- PASS: TestCompare/compareFirstParamIsEmpty (0.00s)
PASS
ok         command-line-arguments 0.005s

// 第二次

$go test -v table_driven_strings_with_map_test.go
=== RUN   TestCompare
=== RUN   TestCompare/compareFirstParamIsEmpty
=== RUN   TestCompare/compareTwoEmptyString
=== RUN   TestCompare/compareSecondParamIsEmpty
--- PASS: TestCompare (0.00s)
    --- PASS: TestCompare/compareFirstParamIsEmpty (0.00s)
    --- PASS: TestCompare/compareTwoEmptyString (0.00s)
    --- PASS: TestCompare/compareSecondParamIsEmpty (0.00s)
PASS
ok     command-line-arguments   0.005s

在上面两次测试执行的输出结果中,子测试的执行先后次序是不确定的,这是由map类型的自身性质所决定的:对map集合类型进行迭代所返回的集合中的元素顺序是不确定的。

2. 测试失败时的数据项的定位

对于非表驱动的测试,在测试失败时,我们往往通过失败点所在的行数即可判定究竟是哪块测试代码未通过:

$go test -v non_table_driven_strings_test.go
=== RUN   TestCompare
    TestCompare: non_table_driven_strings_test.go:16: want 1,
        but Compare("", "") = 0
--- FAIL: TestCompare (0.00s)
FAIL
FAIL       command-line-arguments 0.005s
FAIL

在上面这个测试失败的输出结果中,我们可以直接通过行数(non_table_driven_strings_test.go的第16行)定位问题。但在表驱动的测试中,由于一般情况下表驱动的测试的测试结果成功与否的判定逻辑是共享的,因此再通过行数来定位问题就不可行了,因为无论是表中哪一项导致的测试失败,失败结果中输出的引发错误的行号都是相同的:

$go test -v table_driven_strings_test.go
=== RUN   TestCompare
    TestCompare: table_driven_strings_test.go:21: want -1, but Compare("", "") = 0
    TestCompare: table_driven_strings_test.go:21: want 6, but Compare("a", "") = 1
--- FAIL: TestCompare (0.00s)
FAIL
FAIL       command-line-arguments 0.005s
FAIL

在上面这个测试失败的输出结果中,两个测试失败的输出结果中的行号都是21,这样我们就无法快速定位表中导致测试失败的“元凶”。因此,为了在表测试驱动的测试中快速从输出的结果中定位导致测试失败的表项,我们需要在测试失败的输出结果中输出数据表项的唯一标识

最简单的方法是通过输出数据表项在数据表中的偏移量来辅助定位“元凶”

// chapter8/sources/table_driven_strings_by_offset_test.go
func TestCompare(t *testing.T) {
    compareTests := []struct {
        a, b string
        i    int
    }{
        {"", "", 7},
        {"a", "", 6},
        {"", "a", -1},
    }

    for i, tt := range compareTests {
        cmp := strings.Compare(tt.a, tt.b)
        if cmp != tt.i {
            t.Errorf(`[table offset: %v] want %v, but Compare(%q, %q) = %v`, i+1, tt.i, tt.a, tt.b, cmp)
        }
    }
}

运行该示例:

$go test -v table_driven_strings_by_offset_test.go
=== RUN   TestCompare
    TestCompare: table_driven_strings_by_offset_test.go:21: [table offset: 1] want 7, but Compare("", "") = 0
    TestCompare: table_driven_strings_by_offset_test.go:21: [table offset: 2] want 6, but Compare("a", "") = 1
--- FAIL: TestCompare (0.00s)
FAIL
FAIL       command-line-arguments 0.005s
FAIL

在上面这个例子中,我们通过在测试结果输出中增加数据项在表中的偏移信息来快速定位问题数据。由于切片的数据项下标从0开始,这里进行了+1处理。

另一个更直观的方式是使用名字来区分不同的数据项

// chapter8/sources/table_driven_strings_by_name_test.go
func TestCompare(t *testing.T) {
    compareTests := []struct {
        name, a, b string
        i          int
    }{
        {"compareTwoEmptyString", "", "", 7},
        {"compareSecondStringEmpty", "a", "", 6},
        {"compareFirstStringEmpty", "", "a", -1},
    }

    for _, tt := range compareTests {
        cmp := strings.Compare(tt.a, tt.b)
        if cmp != tt.i {
            t.Errorf(`[%s] want %v, but Compare(%q, %q) = %v`, tt.name, tt.i, tt.a, tt.b, cmp)
        }
    }
}

运行该示例:

$go test -v table_driven_strings_by_name_test.go
=== RUN   TestCompare
    TestCompare: table_driven_strings_by_name_test.go:21: [compareTwoEmptyString] want 7, but Compare("", "") = 0
    TestCompare: table_driven_strings_by_name_test.go:21: [compareSecondStringEmpty] want 6, but Compare("a", "") = 1
--- FAIL: TestCompare (0.00s)
FAIL
FAIL       command-line-arguments 0.005s
FAIL

在上面这个例子中,我们通过在自定义结构体中添加一个name字段来区分不同数据项,并在测试结果输出该name字段以在测试失败时辅助快速定位问题数据。

3. Errorf还是Fatalf

一般情况下,在表驱动的测试中,数据表中的所有表项共享同一个测试结果的判定逻辑。这样我们需要在Errorf和Fatalf中选择一个来作为测试失败信息的输出途径。前面提到过Errorf不会中断当前的goroutine的执行,即便某个数据项导致了测试失败,测试依旧会继续执行下去,而Fatalf恰好相反,它会终止测试执行。

至于是选择Errorf还是Fatalf并没有固定标准,一般而言,如果一个数据项导致的测试失败不会对后续数据项的测试结果造成影响,那么推荐Errorf,这样可以通过执行一次测试看到所有导致测试失败的数据项;否则,如果数据项导致的测试失败会直接影响到后续数据项的测试结果,那么可以使用Fatalf让测试尽快结束,因为继续执行的测试的意义已经不大了。

小结

在本条中,我们学习了编写Go测试代码的一般逻辑,并给出了编写Go测试代码的最佳实践——基于表驱动测试,以及这种惯例的优点。最后我们了解了实施表驱动测试时需要注意的一些事项。