测试多个条件

到目前为止,我们已经讨论了如何构建和编写测试。然而,开发人员还需要了解应该测试代码的哪些方面,以及如何进行测试。请记住,在测试金字塔的底部,测试运行的成本和时间更低。因此,开发人员需要知道如何尽可能地在系统堆栈的底层进行代码测试。在本章中,我们将重点讨论如何作为开发者测试策略的一部分,覆盖边界情况。

如第 1 章《了解测试驱动开发》中所述,自动化测试应该基于我们实现的系统需求。通常,系统需求会关注成功场景和系统功能扩展的规范。围绕这些需求设计测试策略的主要目的是确保系统满足其功能性要求。

测试策略的次要目的是验证系统在故障情况下的行为和鲁棒性,例如处理错误/意外输入、瞬时错误或慢响应。开发人员需要确保他们的系统能够优雅地处理各种操作条件。

我们将学习如何识别这些条件,并应用我们迄今为止学习的测试技术,制定出测试策略,以便无论系统在什么输入或操作条件下运行,我们都能对我们的解决方案充满信心。

图4.1 展示了测试的双重性质,包括正面测试和负面测试,确保系统的功能性和错误处理得到了正确实现。

image 2025 01 04 16 44 07 069
Figure 1. Figure 4.1 – The two types of tests

这两种类型的测试如下:

  • 正面测试,使用有效的输入来测试 UUT(被测单元),并验证 UUT 是否返回预期的结果。这类测试确保应用程序根据功能要求正常运行。正面测试涵盖以下内容:

    • UUT 如何处理有效输入

    • UUT 在预期场景下的行为

    • UUT 如何满足系统需求

  • 负面测试,使用无效的输入来测试 UUT,并验证 UUT 是否返回错误。这类测试确保应用程序能够优雅地处理无效输入,给出有意义的错误信息,并避免崩溃。负面测试涵盖以下内容:

    • UUT 如何处理无效输入

    • UUT 在意外场景下的行为

    • UUT 在超出系统需求的情况下的行为

每种测试由不同类型的测试场景组成,这些场景的复杂度根据输入变量的值及其组合而有所不同。

负面测试的重要性

正面测试和负面测试对生产系统同样重要。错误处理是用户旅程的重要部分。我们希望用户在发生错误时能够收到有意义的信息,并且在系统变慢或发生故障时能够成功恢复。

“快乐路径测试” 或 “快乐流测试” 是指验证默认的成功场景,而没有错误或异常。覆盖默认和需求特定的场景确保系统在理想情况下的表现良好。然而,作为开发人员,我们需要了解的不仅仅是系统的理想行为。

图4.2 展示了给定系统输入参数的不同类型的测试用例。不同类型的测试用例涵盖了输入参数值的整个范围:

image 2025 01 04 16 46 39 951
Figure 2. Figure 4.2 – The types of test cases of a given input variable

一个好的测试策略应该涵盖以下四种主要类型的测试用例:

  • 基础用例:发生在操作参数的预期值上。例如,给定一个表示姓名的输入参数,其基础用例将是一个短的有效字符串值。这些用例通常在系统需求中定义,构成了快乐路径测试策略的场景。

  • 边界用例:发生在操作参数的极限上。例如,给定一个字符串输入参数,边界用例可能是一个空字符串、多行字符串,或者包含特殊字符的字符串。

  • 边缘用例:发生在操作参数的极限值两侧,接近极端值。这些用例对于验证必须具有特定值的参数尤为重要。例如,给定一个表示温度的数字输入参数,用于水温测量的应用程序,我们可以测试它在水的冰点和沸点附近的值。

如这些示例所示,边缘用例通常基于输入/用户参数的数据类型及其目的。我们将在下一节中探讨其他类型的参数,以及如何识别它们的极限/边缘值。

系统通常会基于多个输入变量运行。这些输入变量及其边界情况的组合可能会导致系统行为的不同。

图4.3 展示了最后一种类型的测试用例,它测试多个输入变量的边缘用例的特定场景:

image 2025 01 04 16 47 06 806
Figure 3. Figure 4.3 – Corner cases

角落用例:发生在多个操作参数的极限或边界用例上。任何两个输入变量的边界情况的组合都会导致一个角落用例。例如,给定多个字符串输入参数,我们可以通过这些参数的任意边缘用例的组合来创建角落用例。

图4.4 展示了两个输入变量的测试用例组合:

image 2025 01 04 16 47 55 801
Figure 4. Figure 4.4 – Combining two input variables in a testing strategy

随着系统输入参数数量的增加,边缘用例的组合数量也会增加,从而导致大量的角落用例需要进行测试。为了最小化测试编写和维护的工作量,重要的是从所有可能的测试场景中识别出用户可访问的场景子集。这些场景应优先列入测试策略,随着项目的成熟,测试可以进一步扩展。

边缘用例和角落用例的区别

“边缘用例” 和 “角落用例” 这两个术语经常互换使用。记住它们的区别有一个简单的方法:边缘用例推动参数的极限,而角落用例通过将多个极限推到一起,迫使用户进入一个 “角落” 配置。

识别边缘情况

对于变量和算法,识别边界情况并没有一个特别明确的程序。这是软件测试人员和工程师经验发挥巨大作用的地方,因为他们可以通过检查直觉地识别代码和需求的边界情况。然而,我们可以提供一些建议,帮助你注意到需要关注的地方。

图4.5 展示了基于变量类型的特殊情况:

image 2025 01 04 16 49 56 271
Figure 5. Figure 4.5 – Special cases of different variable types

变量类型的特殊情况如下:

  • 字符串类型的变量 有以下特殊情况:

    • 空字符串或零字符字符串 — ""

    • 长字符串,超出基本有效字符串的预期长度 — "a very very very very long string"

    • 包含特殊字符的字符串,包括 Unicode 字符和特殊的重音字符 — "a $p€¢iał string!"

    • 多行字符串,包含换行符 — "a multi \n line string"。 请记住,Go 允许通过使用反引号来定义原始字符串字面量,它也可以包含其他特殊字符。

  • 数字类型的变量 有以下特殊情况:

    • 零值 — 0

    • 最小值和最大值,根据数字类型的不同而有所不同。例如,int8 类型的最小值为 -128,最大值为 127,而 uint8 类型的最小值为 0,最大值为 255。这些值根据给定类型的内存分配增加。

    • 正数和负数的值,可能需要根据 UUT(被测单元)的逻辑进行特殊处理。

  • 自定义结构体类型 有以下特殊情况:

    • 自定义结构体的零值,未进行初始化 — a := MyType{}

    • 如果通过指针传递的 nil 值 — var a *MyType

    • 初始化和未初始化字段的组合 — a := MyType{field1: "Value"}。测试这些组合可以揭示是否需要将某些字段添加到初始化/构造函数中。虽然Go不提供默认的构造函数实现,但通常会声明包范围的函数来初始化实例并返回它 — func NewMyType(v string) *MyType

  • 集合类型 封装了 Go 内置的集合类型 — 数组、切片和映射:

    • 零元素或空集合 — c := []int{}

    • 单元素集合 — c := []int{0}

    • nil 值或未分配内存的集合 — var c []int

    • 重复元素 — c := []int{0, 0}

    • 含有大量元素的集合 — var c [999]int

每种变量类型的特殊情况应该帮助你决定哪些边界情况需要覆盖,但你还应扩展边界情况,涵盖任何系统需求和边界情况的边界。

在制定测试用例时,你应该将 UUT(被测单元)拆分成小的逻辑块,识别它们的输入和边界情况,然后构建测试套件来验证这些情况。我们将在本章后续内容中学习如何使用表驱动测试来轻松编写测试套件。

外部服务

现在我们理解了如何根据输入参数的类型和系统需求来识别边界情况,我们可以将注意力转向与外部服务的测试。如第 3 章《Mocking 和断言框架》所讨论的,UUT(被测单元)的任何直接依赖都应该被 mock 掉,这样我们就可以在隔离的环境中测试 UUT

由于 Go 包为我们提供了一种构建小型、独立 API 的简便方法,我们可以将所有依赖视为外部服务。这些依赖可以分为两类:

  • 内部系统依赖:位于我们正在测试的系统内部,无论是在同一服务内还是跨服务。我们对这些依赖具有完全控制权。

  • 外部系统依赖:位于我们正在测试的系统外部,提供额外的功能,如数据库或第三方功能。我们无法完全控制这些依赖。

始终 mock 外部系统依赖

由于我们无法控制系统依赖,直接在测试中使用它们的实际版本可能会引入脆弱性和额外的成本。除了数据库外,应该始终 mock 外部系统依赖。我们将在第 5 章《执行集成测试》中进一步探讨数据库测试。

在涉及外部系统依赖的边界情况时,这些 API 通常会通过某种网络连接与我们的系统连接。它们的边界情况受到这种连接的强烈影响。

图4.6展示了 UUT 与外部服务集成时可能发生的错误:

image 2025 01 04 16 52 40 285
Figure 6. Figure 4.6 – Possible errors in communication between the UUT and external service

当交换的每个部分都通过网络进行时,请求和响应都需要能够容忍延迟和重试:

  • 外部服务可能会出错并返回内部服务错误。在这种情况下,UUT 需要处理整个服务中断,并返回一个默认响应。

  • 请求可能需要很长时间才能送达外部服务。在这种情况下,UUT 需要等待响应一段预定义的时间,然后将请求视为失败。UUT 可能会决定重试该请求以获取资源。

  • 外部服务的响应可能根本不会到达。在这种情况下,UUT 需要重试整个请求周期,并在应用逻辑中处理这种重复流。

错误是编写代码和运行应用程序中不可避免的一部分,尤其是那些依赖外部服务来实现功能的应用。

现代系统将依赖许多类型的外部 API,这些 API 可能通过 REST API、RPC 调用,甚至通过事件总线异步通信。这些集成的测试问题是类似的,因为 UUT 与外部服务之间的通信将是集成中最容易出错的部分。

作为设计的幂等性

在 API 设计中,幂等操作可以重复调用而不会改变初始结果。将所有操作设计为幂等性操作是良好的实践,以确保在错误恢复时可以重试操作。

错误处理复习

到目前为止,我们讨论了如何识别可能的边界情况并为其编写测试,但系统的健壮性和错误处理始于 UUT(被测单元)的实现。对于 Go 开发者来说,这一点尤其重要,因为 Go 的语言设计要求显式地处理错误情况。让我们简要总结一下 Go 中的错误处理,以补充我们对边界情况识别和错误测试的讨论。

错误处理在编写 Go 代码中起着至关重要的作用。Go 团队选择了显式的错误处理,使用内置的 error 类型,以避免异常机制和 try-catch-finally 风格的代码块,这些机制容易导致脆弱和易出错的代码。

error 类型是一个简单的接口:

type error interface {
    Error() string
}

该接口还使我们能够创建自己的自定义错误类型,只需实现 Error() string 方法即可。错误像其他值一样返回,最常见的是通过多个返回值返回错误,并且它们的处理方式与其他返回值相同。

例如,我们已经看到过,Parser 计算器在遇到无效的数学表达式时会返回一个错误:

func (p *Parser) ProcessExpression(expr string) error

error 类型的零值是 nil。最常见的情况是,nil 错误值表示执行过程中没有发生问题。

通常的做法是,在代码中首先处理错误,方法是调用可能出错的表达式:

if err := parser.ProcessExpression(*expr); err != nil {
    log.Fatal(err)
}

在这个例子中,我们在调用可能出错的函数时同时初始化 err 变量,将变量的作用域限制在 if 语句块内。

请注意,我们检查的是错误是否存在,而不是它的不存在。如果 err != nil,我们会直接调用 log.Fatal 函数来终止程序。这是 Go 中处理错误的典型方式。

使用 error 类型显式处理错误的优势:

  • 保证错误情况得到处理,避免后续的panic或nil指针错误:在函数代码的顶部先处理错误,减少了后续代码中对有效数据的检查,这有助于简化代码执行流程。

  • 让我们清楚地看到需要在测试策略中覆盖的错误场景:函数签名会显示哪些方法和函数可能产生错误,强制调用方显式地处理这些错误。

  • 提供统一的错误状态表示和错误消息返回方式:内置的 error 类型为所有 Go 代码库提供了一种统一的错误状态表示方式,这使得构造和返回用户友好的错误信息变得容易。

然而,一些开发者认为错误检查的代码块过于冗长和重复。常见的批评是,它们需要处理所有错误,即使是那些相对不太可能发生的错误。虽然可以使用空白标识符(_ 操作符)或不将返回值赋值给任何变量来忽略错误,但这通常是不被推荐的做法。

你可以根据自己的判断决定 Go 的显式错误处理方式,但我们将在本书中始终使用它,因为这是 Go 编程中的约定和标准实践。

先处理错误,但将其作为最后一个参数返回

在一个具有多个返回值的函数中,请记住,错误类型通常是最后一个返回值。因此,你应该先处理错误情况,并在异常场景中提前返回,同时保持代码的最小缩进。