表驱动测试实践

现在我们已经讨论了如何识别边界情况和处理错误,接下来我们可以开始了解如何构建覆盖各种场景的测试套件。在 Go 中,一种常用的技巧是使用基于 表格驱动的测试。这种技巧利用我们到目前为止学到的基本知识,来构建覆盖各种场景的测试套件。

让我们从一个简单的例子开始,演示编写测试的过程。我们将实现一个新的除法数学操作,该操作的功能如下:

  • 返回一个格式化为两位小数的字符串结果

  • 当除数为 0 时返回一个错误

根据前面的需求,我们可以为这个新操作定义以下签名:

func Divide(x, y int8) (*string, error)

我们记得,int8 的最小值是 -128,最大值是 127

如前所述,我们利用多个返回值来鼓励调用代码显式地处理错误。根据功能需求以及从之前的《识别边界情况》部分学到的知识,我们可以识别以下测试用例:

  • 基本用例

    • x 和 y 都是正值

    • x 和 y 都是负值

  • 边界用例

    • x 和 y 相等

    • x 的最大值和 y 为正值

    • x 的最小值和 y 为正值

    • x 为 0 且 y 为非 0 值

    • x 为正值且 y 为 0

  • 拐角用例

    • x 和 y 都是 0

    • x 和 y 都是最大值

    • x 和 y 都是最小值

在《第二章,单元测试基础》中,我们已经看过如何编写测试并实现不同的场景子测试。具体而言,这涉及到声明共享的测试设置,并为每个用例声明一个子测试。例如,以下是第一个测试用例的实现:

func TestDivide(t *testing.T) {
    t.Run("positive x, positive y", func(t *testing.T) {
        x, y := int8(8), int8(4)
        r, err := table.Divide(x, y)
        assert.Nil(t, err)
        assert.Equal(t, "2.00", *r)
    })
}

从这个代码片段中可以看到,以下几个部分会根据我们运行的测试用例而变化:

  • 测试用例的名称,这样可以使测试输出更易于阅读

  • 根据我们运行的测试用例,输入的值会发生变化

  • 根据我们运行的测试用例,预期的结果值和错误值也会变化

从前面的代码片段中可以看出,有许多重复的样板代码是可以在不同测试用例中复用的:

  • 测试函数的声明和所需的 UUT 设置

  • 子测试的声明及其嵌套的测试函数

  • 使用输入值调用 Divide 函数

由于与 *testing.T 对象的交互是测试实现中最冗长的部分,因此一种更简短且更简单的替代方案是使用基于表格驱动的测试,接下来我们将通过上一节的 Divide 函数示例来演示每个步骤。

步骤 1 – 声明函数签名

我们首先声明我们之前展示的函数签名,并编写足够的代码以确保代码能够编译:

package table

func Divide(x, y int8) (*string, error) {
    return nil, nil
}

该函数的签名返回一个字符串指针和一个错误。在实际操作中,我们期望这两个值中只有一个会是 nil

  • 在正常流程中,结果字符串将是非 nil,而错误值将是 nil

  • 在异常流程中,结果字符串将是 nil,而错误值将是非 nil

因此,通过将这两个值都设置为 nil,我们可以确保不会意外地通过任何测试用例。这有助于我们开始测试驱动开发(TDD)过程中的红色阶段——即 红-绿-重构流程。

步骤 2 – 声明我们的测试用例结构

编写测试代码的第一步是声明一个自定义类型,用来包装我们的测试用例。这个结构体的目的是保存测试用例的输入和期望输出。通常,这个类型是在测试函数的作用域内声明的,但它也可以在多个测试中共享。

我们 Divide 函数的测试用例如下所示:

func TestDivide(t *testing.T) {
    type testCase struct {
        x, y    int8
        wantErr error
        want    *string
    }
}

这个自定义类型是一个简单的结构体类型,用来包装 xy —— 即函数的两个输入参数,以及两个期望的结果 —— 格式化后的结果和可能返回的错误。

需要注意的是,在 Go 中,通常将期望结果命名为 want 或以 want 为前缀,这与其他语言的命名约定不同,其他语言通常以 expected 开头。

步骤 3 – 创建我们的测试用例集合

现在我们有了一种表达测试用例的方式,可以开始为我们的函数创建我们想要测试的所有用例的集合。基于之前为 Divide 函数识别的两个基本用例,我们可以创建如下的测试集合:

tests := map[string]testCase{
    "pos x, pos y": {x: 8, y: 4, want: "2.00"},
    "neg x, neg y": {x: -4, y: -8, want: "0.50"},
}

我们偏好使用 map 来为每个测试用例添加一个对应的名称,这样我们就可以将名称作为键,测试用例作为值进行存储。另一种解决方案是使用 slice,并将测试用例的名称作为字段保存在 testCase 类型中。

需要注意的是,在上面的测试用例中,我们没有为 wantErr 字段提供值,因为基本用例不需要验证错误。错误类型的零值是 nil,因此不设置其值等同于声明一个正常路径(happy path)的测试用例。

我们还可以进一步优化测试集合的实现,使用匿名结构体类型来代替 testCase 类型,从而减少样板代码并保持 testCase 类型的作用域小:

tests := map[string]struct {
    x, y     int
    wantErr  error
    want     string
}{
    "pos x, pos y": {x: 8, y: 4, want: "2.00"},
    "neg x, neg y": {x: -4, y: -8, want: "0.50"},
}

这种方式可以进一步缩短测试声明,但不允许我们在多个测试中共享 testCase 类型。

步骤 4 – 执行每个测试

有了测试用例的表格后,我们将执行每个测试用例作为子测试。我们将使用 range 语句来遍历测试集合,这样就可以返回测试用例的名称和测试用例实例。然后,我们将测试名称作为子测试的名称,并在测试设置和执行时使用测试用例:

for name, tc := range tests {
    t.Run(name, func(t *testing.T) {
        // 测试执行
    })
}

这一步允许我们在整个测试套件中为所有测试用例设置与测试运行器的交互。记住,每个子测试都是它自己的函数,因此我们可以使用 testing.T 辅助方法单独使某个测试失败,或停止整个测试套件的执行,正如我们在第 2 章《单元测试基础》中所探讨的那样。

步骤 5 – 实现测试断言

一旦我们设置了测试映射表并与测试运行器建立了交互,就可以根据在 testCase 类型中定义的输入和输出开始实现测试逻辑:

for name, tc := range tests {
    t.Run(name, func(t *testing.T) {
        x, y := int8(tc.x), int8(tc.y)
        r, err := table.Divide(x, y)
        if tc.wantErr != nil {
            assert.Equal(t, tc.wantErr, err)
            return
        }
        assert.Nil(t, err)
        assert.Equal(t, tc.want, *r)
    })
}

基于从 tests 映射表中获取的 tc 测试用例值,我们使用其 xy 值调用 Divide 函数。然后,我们验证 tc 测试用例中的错误值和结果值。注意,和错误处理一样,我们首先验证错误值,并在出现错误的情况下从测试中返回。

步骤 6 – 运行失败的测试

我们的表驱动测试套件已经通过五个简单的步骤成功实现!基础的测试运行和断言机制已经到位,因此我们现在可以运行测试并查看它们失败的情况。我们可以像之前一样使用 go test 命令运行测试:

$ go test -run TestDivide ./chapter04/table -v
--- FAIL: TestDivide (0.00s)
--- FAIL: TestDivide/pos_x,_pos_y (0.00s)

从输出中可以看到,所有的测试都在各自的子测试中运行,并将给定的场景名称传递给了测试运行器。-v 标志是详细输出标志,它会显示所有运行测试的完整输出。

步骤 7 – 实现基本情况

我们现在开始实现 Divide 函数的正常路径(happy path)测试用例。我们将编写两行简单的代码,使得我们之前编写的基本用例通过:

func Divide(x, y int8) (*string, error) {
    r := float64(x) / float64(y)
    result := fmt.Sprintf("%.2f", r)
    return &result, nil
}

这两行代码将处理正常的程序流程。然后,我们重新运行我们之前编写的基本用例测试,并确认它们通过:

$ go test -run TestDivide ./chapter04/table -v
--- PASS: TestDivide (0.00s)
--- PASS: TestDivide/pos_x,_pos_y (0.00s)
--- PASS: TestDivide/neg_x,_neg_y (0.00s)

一旦这些测试通过,我们就进入了测试驱动开发(TDD)过程中的绿色阶段。

步骤 8 – 扩展测试用例集合

在基本测试用例通过后,是时候扩展我们的测试用例集合,以包括错误情况了。根据我们在前一节中为 Divide 函数确定的 10 个测试用例,我们可以将以下测试用例添加到测试集合中:

tests := map[string]struct {
    x, y int
    wantErr error
    want string
}{
    "pos x, pos y": {x: 8, y: 4, want: "2.00"},
    "neg x, neg y": {x: -4, y: -8, want: "0.50"},
    "equal x, y": {x: 4, y: 4, want: "1.00"},
    "max x, pos y": {x: 127, y: 2, want: "63.50"},
    "min x, pos y": {x: -128, y: 2, want: "-64.00"},
    "zero x, pos y": {x: 0, y: 2, want: "0.00"},
    "pos x, zero y": {x: 10, y: 0, wantErr: errors.New("cannot divide by 0")},
    "zero x, zero y": {x: 0, y: 0, wantErr: errors.New("cannot divide by 0")},
    "max x, max y": {x: 127, y: 127, want: "1.00"},
    "min x, min y": {x: -128, y: -128, want: "1.00"},
}

实际上,我们会一次扩展一个边界和角落情况,确保每个测试都能通过。然而,为了简洁起见,我们将在此一步中一次性添加它们。

步骤 9 – 扩展功能代码

正如预期的那样,当我们使用典型的 go test 命令运行时,新的错误边界情况会失败,提示我们需要实现功能代码。我们扩展 Divide 函数以处理用户需求中描述的错误情况:

func Divide(x, y int8) (*string, error) {
    if y == 0 {
        return nil, errors.New("cannot divide by 0")
    }
    r := float64(x) / float64(y)
    result := fmt.Sprintf("%.2f", r)
    return &result, nil
}

如往常一样,错误处理放在函数的顶部,保持代码尽可能少的缩进。请注意,我们使用 errors.New 函数初始化错误,该函数接受一条消息。我们还可以通过其他方式初始化错误。

最后一步是运行我们完全实现的表驱动测试套件,使用 go test 命令:

$ go test -run TestDivide ./chapter04/table -v
--- PASS: TestDivide (0.00s)
--- PASS: TestDivide/zero_x,_pos_y (0.00s)
--- PASS: TestDivide/max_x,_max_y (0.00s)
--- PASS: TestDivide/max_x,_pos_y (0.00s)
--- PASS: TestDivide/min_x,_pos_y (0.00s)
--- PASS: TestDivide/equal_x,_y (0.00s)
--- PASS: TestDivide/min_x,_min_y (0.00s)
--- PASS: TestDivide/pos_x,_zero_y (0.00s)
--- PASS: TestDivide/zero_x,_zero_y (0.00s)
--- PASS: TestDivide/pos_x,_pos_y (0.00s)
--- PASS: TestDivide/neg_x,_neg_y (0.00s)
PASS ok github.com/PacktPublishing/Test-DrivenDevelopment-in-Go/chapter04/table 0.298s

从输出中可以看出,所有的测试都成功运行,并且每个子测试都显示了给定的场景名称。我们的第一个表驱动测试套件已经成功实现。这是一种常见的测试技术,您在编写 Go 代码时会经常使用,因此掌握这种方法非常重要。

并行化

默认情况下,包中的所有测试将按顺序运行,但来自多个包的测试将并行运行。随着测试数量的增加,单个包的顺序执行时间也可能增加。

图 4.7 演示了顺序和并行测试运行的行为:

测试运行生命周期如下:

  • 测试开始运行。不同包中的测试并行运行——包 A 中的测试可以与包 B 中的测试同时运行。这有助于减少运行时间。

  • 默认情况下,同一包中的测试按顺序运行。这可以通过包 A 中的测试进行演示——TestCase 1 需要在 TestCase 2 之前完成。

  • 同一包中的测试可以配置为并行运行。这可以通过包 B 中的测试进行演示——TestCase 1 可以与 TestCase 2 并行运行。

我们可以并行运行的测试数量受限于测试运行器的可用资源,但并行化测试运行是减少测试运行时间的好方法,这可以进一步减少 CI/CD 管道的反馈周期。

*testing.T 类型提供了 t.Parallel() 方法,允许我们指定哪些测试可以与同一包中的其他并行标记的测试一起运行。由于我们的表驱动测试的子测试是独立运行的,我们需要将每个子测试标记为并行运行,而不仅仅是顶级测试。

标记某些测试进行并行化的功能对于表驱动测试特别有用,因为表驱动测试包含可以独立运行的测试用例。我们可以通过两行简短的代码轻松调整我们的表驱动测试以并行运行:

for name, rtc := range tests {
    tc := rtc
    t.Run(name, func(t *testing.T) {
        t.Parallel()
        x, y := int8(tc.x), int8(tc.y)
        r, err := table.Divide(x, y)
        if tc.wantErr != nil {
            assert.Equal(t, tc.wantErr, err)
            return
        }
        assert.Nil(t, err)
        assert.Equal(t, tc.want, *r)
    })
}

我们将当前的测试用例赋值给一个本地的 tc 变量,以捕获测试用例范围变量。这是必要的,因为子测试现在将在后台以 goroutine 形式运行。我们需要将当前测试用例的值复制到子测试闭包中,而不是直接引用不断变化的范围返回值。

第二个变化是我们在子测试中添加了 t.Parallel() 的调用,标记每个子测试允许并行运行。

默认情况下,可以并行运行的二进制文件数量等于 CPU 的数量。此变量可以通过 go test 命令上的 –parallel 标志进行覆盖。

将表驱动测试标记为并行后,我们可以再次使用 go test 运行我们的测试:

$ go test -run TestDivide ./chapter04/table -v
=== RUN TestDivide
=== RUN TestDivide/pos_x,_pos_y
=== PAUSE TestDivide/pos_x,_pos_y
=== RUN TestDivide/neg_x,_neg_y
=== PAUSE TestDivide/neg_x,_neg_y
=== CONT TestDivide/pos_x,_pos_y
=== CONT TestDivide/neg_x,_neg_y
--- PASS: TestDivide (0.00s)

--- PASS: TestDivide/pos_x,_pos_y (0.00s)
--- PASS: TestDivide/neg_x,_neg_y (0.00s)
ok github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter04/table 0.223s

测试运行的输出已被简化。正如我们从交错输出中看到的那样,测试现在以交错的方式并行运行:运行、暂停和继续。

表驱动测试的优缺点

这标志着我们对表驱动测试的探索结束。让我们通过简短的讨论总结其优缺点。表驱动测试最适用于涵盖多种不同输入和输出的测试场景。

优点

表驱动测试有以下优点:

  • 提供了一种简洁的方式来定义和运行多个测试用例,从而减少了模板代码。

  • 通过简单地修改测试用例集合,可以轻松地添加和删除新的测试。

  • 由于所有测试用例都使用相同的代码框架运行,我们可以轻松地重构测试设置和断言代码。

缺点

表驱动测试也有一些缺点:

  • 由于所有的测试用例都以相同的方式运行,因此可能很难在测试设置和断言代码上进行即使是很小的变动。

  • 表驱动测试不适合需要不同测试设置和拆卸逻辑的测试用例。它们也使得使用需要不同行为的模拟对象(mocks)变得困难。

  • 一些开发人员认为表驱动测试难以阅读。虽然测试用例的名称可以帮助我们命名每个测试,但与行为驱动开发(BDD)风格的测试编写方式相比,这种方式的代码可读性较差。

当正确实现时,表驱动测试是一个很好的方式,可以在多种场景和边缘情况下测试你的代码。它帮助我们创建了一种统一的方式来运行测试,这也使得维护和重构测试代码变得更容易。许多开发人员建议从一开始就实现表驱动测试,即使在开始时你并没有很多测试用例。随着代码的成熟,你将有一种简单的方式来添加新的测试用例。

如果你的测试设置有较大的变化,可以使用不同的测试和专门的子测试来对测试进行分组。