重新审视表驱动测试

现在我们已经了解了实现泛型代码的基础知识,我们可以将注意力转向测试它。Go 社区中泛型的采用仍处于起步阶段。我们已经确定泛型代码由编译器静态强制执行,但这种灵活性的增加是否会导致更复杂的测试代码?

我们可以继续使用我们实现的泛型 GetSortedValues 函数进行探索。现在应该编写测试来断言函数在各种输入类型和值下的行为。我们可以通过使用我们在第4章《构建高效的测试套件》中探讨的表驱动测试技术来实现这一点。泛型表驱动测试的实现遵循一系列步骤。

步骤 1 – 定义泛型测试用例

我们首先创建一个泛型测试用例类型来保存我们的输入映射和值:

type testCase[K ~int, V comparable] struct {
    input map[K]V
}

泛型测试用例允许我们构建具有正确键和值类型约束的输入映射,这些约束与我们用于泛型 GetSortedValues 函数的约束相同。我们将使用输入映射作为 UUT 的参数。

步骤 2 – 创建测试用例

正如我们从表驱动测试的介绍中记得的那样,我们通常会构建一个测试用例映射,其中包含我们将运行测试的场景。由于类型的差异,我们构建了多个测试用例映射:

type CustomI int

testStrings := map[string]testCase[int, string]{
    "unordered":       {input: map[int]string{99: "A", 50: "X"}},
    "empty map":       {input: map[int]string{}},
    "negative values": {input: map[int]string{-99: "A", -1: "X"}},
}

testFloats := map[string]testCase[CustomI, float64]{
    "unordered":     {input: map[CustomI]float64{99: 1.2, 0: 4.6}},
    "empty map":     {input: map[CustomI]float64{}},
    "negative keys": {input: map[CustomI]float64{-99: 1.2, 0: 4.6}},
}

在此示例中,我们创建了两个映射,每个映射包含三个场景。第一个 testCase 映射具有 int 键类型和 string 值类型。第二个 testCase 映射具有基于 int 的自定义类型 CustomI。这是键和值类型的两种可能组合。我们可以根据需要扩展这些类型和场景组合。

步骤 3 – 实现泛型测试运行函数

下一步是编写一个测试辅助函数,其中包含循环遍历测试用例映射并断言 GetSortedValues 函数功能的所有逻辑。通常,此功能包含在测试体内,但我们将提取一个函数,因为我们需要处理多个输入映射:

func runTests[K ~int, V comparable](t *testing.T, tests map[string]testCase[K, V]) {
    t.Helper()
    for name, rtc := range tests {
        tc := rtc
        t.Run(name, func(t *testing.T) {
            keys := make([]K, 0, len(tc.input))
            for k := range tc.input {
                keys = append(keys, k)
            }
            sort.Slice(keys, func(i, j int) bool {
                return keys[i] < keys[j]
            })
            sortedValues, err := gs.GetSortedValues(tc.input, gs.ASC)
            require.Nil(t, err)
            require.NotNil(t, sortedValues)
            for index, v := range sortedValues {
                key := keys[index]
                assert.Equal(t, tc.input[key], v)
            }
        })
    }
}

runTests 函数具有以下实现:

  1. 函数签名为键和值类型接受两个类型参数。它们与我们声明的测试用例类型具有相同的类型约束。它接受两个参数:*testing.T 运行器和具有相同键和值类型参数的测试用例映射。

  2. 此函数使用 t.Helper 标记为辅助函数,因此它不会污染测试输出。

  3. 我们循环遍历测试用例映射,并使用 t.Run 函数在每个子测试中运行它们。

  4. 函数体的其余部分与任何测试实现相同。我们调用被测函数并断言元素的顺序。

步骤 4 – 将一切结合起来

有了我们所有的构建块,我们可以为 GetSortedValues 函数编写第一个泛型测试:

func TestGetSortedValues(t *testing.T) {
    t.Run("[int]string", func(t *testing.T) {
        testStrings := map[string]testCase[int, string]{
            // 值声明
        }
        runTests(t, testStrings)
    })
    t.Run("[CustomI]float64", func(t *testing.T) {
        testFloats := map[string]testCase[CustomI, float64]{
            // 值声明
        }
        runTests(t, testFloats)
    })
}

该测试包含每种键和值类型组合的子测试。在子测试内部,我们创建测试用例映射并使用正确的参数调用 runTests 函数。

步骤 5 – 运行测试

最后一步是运行测试以查看我们的泛型代码的实际效果:

$ go test -run TestGetSortedValues ./chapter11/generics/sort -v
=== RUN TestGetSortedValues
--- PASS: TestGetSortedValues
--- PASS: TestGetSortedValues/[int]string
--- PASS: TestGetSortedValues/[int]string/unordered
--- PASS: TestGetSortedValues/[int]string/empty_map
--- PASS: TestGetSortedValues/[int]string/negative_values
--- PASS: TestGetSortedValues/[CustomI]float64
--- PASS: TestGetSortedValues/[CustomI]float64/unordered
--- PASS: TestGetSortedValues/[CustomI]float64/empty_map
--- PASS: TestGetSortedValues/[CustomI]float64/negative_keys
PASS
ok github.com/PacktPublishing/Test-Driven-Development-in-Go/chapter11/generics/sort 0.201s

子测试的使用为我们提供了结构化的测试运行输出。我们还轻松地调整了表驱动测试以使用泛型,从而能够使用不同类型的输入值测试我们的 GetSortedValues 函数。

正如我们从这个简单示例中看到的,泛型的功能扩展到了测试代码。泛型的另一个有趣用途是创建测试实用程序。我们将在下一节中查看此示例。

测试工具

我们迄今为止使用的测试断言库 testifyginkgo 是在 Go 泛型引入之前编写的。它们使用反射来实现对变量的比较和断言。虽然这是一个强大的工具,但使用它编写我们自己的断言和测试实用程序可能很困难。泛型的引入使这一过程变得更加容易,因此我们可以轻松创建自己的测试实用程序以在测试中重用它们。

反射简介

反射是程序在运行时分析和操作程序元素的能力。这使我们能够检查类型、修改行为和测试代码。Go 标准库提供了 reflect 包,它提供了实现反射的功能。

继续我们之前编写的 TestGetSortedValues 示例,我们可以通过提取一个可以在其他测试中使用的测试实用程序来简化我们的 runTests 辅助函数。我们可以轻松创建自己的泛型测试辅助函数,以断言键和值的顺序是否符合预期:

func AssertMapOrderedByKeys[K ~int, V comparable](t *testing.T, input map[K]V, want []V) {
    t.Helper()
    keys := make([]K, 0, len(input))
    for k := range input {
        keys = append(keys, k)
    }
    sort.Slice(keys, func(i, j int) bool {
        return keys[i] < keys[j]
    })
    for index, v := range want {
        key := keys[index]
        assert.Equal(t, input[key], v)
    }
}

我们提取代码并为键和值类型使用两个类型参数。我们使用与编写 runTests 辅助函数相同的技术,但仅提取断言映射值顺序的代码。此测试辅助函数仍然接受测试运行器 *testing.T 作为参数,允许它在其中的断言失败时使测试失败。

在没有泛型的情况下,我们将不得不使用空接口 interface{} 来允许我们的测试接受各种参数。这不允许我们编写类型安全的代码,因此编写辅助函数更加困难且容易出错。

泛型可以帮助我们简化应用程序和测试代码。正如我们在本节中看到的,我们可以使用它来扩展表驱动测试技术,使我们能够编写涵盖各种输入类型和场景的测试。我们还看到了如何使用泛型创建我们自己的测试实用程序。