探索 Mock(模拟)

在本节中,我们将探索一种机制,允许我们测试依赖于外部依赖项的代码。我们将看到如何使用和生成模拟对象(mocks),从而使我们能够在与其依赖项的行为隔离的情况下验证 UUT(单元测试目标)。

模拟对象 有时也被称为 测试替身test doubles),它们是一个简单但强大的概念。模拟对象满足接口要求,但它们是实际实现的伪造版本。我们可以完全控制这些伪造实现,从而自由控制其行为。然而,如果真实实现发生了变化,而我们的模拟对象没有更新,那么测试可能会给我们带来虚假的信心。

在 Go 中,我们有以下几种不同的模拟选项:

  • 函数替代(Function substitution):这意味着将替代的伪造函数传递给 UUT。Go 本身对高阶函数(higher-order functions)有原生支持,因此在 Go 中,替换函数变量和参数非常容易,能够替代 UUT 的行为。

  • 接口替代(Interface substitution):这意味着注入伪造版本的 UUT 依赖的接口。这些是满足真实实现接口的伪造存根(stub)实现,可以用来替代完整的实现,而 UUT 并不需要知道这一点。

高阶函数复习

高阶函数是指接收其他函数作为参数或返回函数的函数。在 Go 中,函数和其他类型一样。

函数替代在 Go 中的应用不如接口替代普遍,应该谨慎使用,因为它可能会使代码变得不太可读。

现在,让我们修改代码以便能够利用接口替代。首先,我们将定义两个接口:

// OperationProcessor 是处理数学表达式的接口
type OperationProcessor interface {
    ProcessOperation(operation *calculator.Operation) (*string, error)
}

// ValidationHelper 是输入验证的接口
type ValidationHelper interface {
    CheckInput(operator string, operands []float64) error
}

以下是对上述代码的说明:

  • 我们首先定义了希望在 UUT 中使用的外部功能的接口。在我们的案例中,UUTinput.Parser,它需要两个依赖项:

    • OperationProcessor 接口封装了 ProcessOperation 方法。该功能将由 calculator.Engine 满足,它将计算解析出的操作符和操作数的数学结果。

    • ValidationHelper 接口封装了 CheckInput 方法。该功能将由 input.Validator 满足,确保用户提供的输入可以被正确处理。

导出的依赖接口

请注意,封装依赖项的接口已被导出,如接口名称的首字母大写所示。通常的做法是将接口导出,而将对应的结构体保持在包作用域内。这使我们能够对外部暴露的功能进行细粒度的控制。

接下来,我们将 input.Parser 类型的依赖项包装成刚才定义的接口:

// Parser 负责将输入转换为数学操作
type Parser struct {
    engine OperationProcessor
    validator ValidationHelper
}

正如我们在前一节中讨论的那样,Go 中的依赖通常表示为接口,而不是结构体类型。这使得我们能够注入任何满足给定接口的类型,而不仅仅是具体的结构体。这是一个非常强大的机制。

使用接口表示依赖的另一个大优点是,它们允许我们打破包之间的依赖关系,编写松耦合的代码。

图 3.3 展示了我们如何打破硬依赖关系:

image 2025 01 04 16 31 43 914
Figure 1. Figure 3.3 – Creating loosely coupled code using interfaces

正如我们所看到的,通过使用内部定义的接口表示依赖关系,使得我们能够打破模块之间的硬依赖关系。满足这些接口的外部结构体可以在包外部创建并注入到 UUT 中,而无需引入硬依赖。

此外,由于结构体可以满足多个接口,它们还为我们提供了灵活性,可以减少 UUT 中需要访问的操作范围。当我们在处理大型 SDK 或复杂的外部 API 时,尤其有用,因为在这种情况下,我们可能不希望定义或模拟所有的函数。

Mock 框架

现在我们已经重构了代码,利用接口的强大功能创建了松耦合的代码,让我们看看如何在测试中也能利用它们的力量。

在创建 mocks 时,有两个流行的 mocking 框架可以帮助我们轻松生成和断言 mocks

  • golang/mock 是一个开源框架,首次发布于 2011 年 3 月。你可以在 https://github.com/golang/mock 阅读相关内容。它包含一个 mocking 包和一个代码生成工具 mockgen

  • testify/mock 是一个开源框架,首次发布于 2012 年 10 月。你可以在 https://github.com/stretchr/testify/#mock-package 阅读相关内容。和 golang/mock 一样,它也包含一个 mocking 包和一个代码生成工具 mockery

这两个框架提供了非常相似的功能,因此选择其中一个看起来有些随意。根据当前的统计,testify/mock 包已经被超过 13,000 个包导入(参见 https://pkg.go.dev/github.com/stretchr/testify/mock?tab=importedby ),而 golang/mock 包被超过 12,000 个包导入(参见 https://pkg.go.dev/github.com/golang/mock/gomock?tab=importedby )。这进一步说明,它们是 Go 开发者中非常流行的两个框架。

正如我们将在下一节 “与断言框架一起工作” 中看到的那样,testify 还提供了一个非常强大且流行的断言框架。因此,本书将使用 testify/mock 作为我们的 mocking 解决方案。

要使用此框架,你需要通过以下命令安装其两个主要组件,这些命令在撰写时是正确的:

$ go get github.com/stretchr/testify
$ go install github.com/vektra/mockery/v2@latest

这两个命令将为我们设置框架,以便在接下来的工作中使用。确保运行这两个命令,以便能够跟随本书提供的代码示例。虽然我们将使用这个框架进行 mocking,但所讨论的概念也适用于 golang/mock

生成 Mock

到目前为止,我们已经准备好了依赖项,选择了一个 mocking 框架,并安装了它。现在,让我们学习如何将它付诸实践。我们之前提到,testify 提供了一个代码生成工具,用于创建 mocks。这个工具使得生成模板化的 mock 代码变得简单,我们不再需要手动创建和维护它。

testify 中生成 mocks 不需要任何特殊的注解。可以为接口和函数生成 mocks,这使得它们适用于函数替换和接口替换。

mockery 命令支持多种标志。以下是一些常见的标志:

  • --dir string 标志指定要查找接口的目录。

  • --all 标志指定搜索所有子目录并生成 mocks

  • --name string 标志指定在搜索接口时要匹配的名称或正则表达式。

  • --output string 标志指定生成的 mocks 文件所在的目录。默认情况下,这个目录是 /mocks

你可以使用 mockery --help 查看此命令的所有其他选项。

现在,我们可以使用以下命令生成我们的接口的 mocks

$ mockery --dir "chapter03" --output "chapter03/mocks" --all

此命令会在 chapter03 目录及其所有子目录中查找所有接口,并将生成的文件放置在 chapter03/mocks 目录中。该命令的输出应如下所示:

11 Sep 22 17:38 BST INF Starting mockery dry-run=false
version=v2.14.0
11 Sep 22 17:38 BST INF Walking dry-run=false version=v2.14.0
11 Sep 22 17:38 BST INF Generating mock dry-run=false
interface=OperationProcessor qualified-name=github.com/
PacktPublishing/Test-Driven-Development-in-Go/chapter03/input
version=v2.14.0
11 Sep 22 17:38 BST INF Generating mock dry-run=false
interface=ValidationHelper qualified-name=github.com/
PacktPublishing/Test-Driven-Development-in-Go/chapter03/input
version=v2.14.0

从输出中可以看出,我们的两个接口 OperationProcessorValidationHelper 被检测到,并且已经为它们生成了 mocks。生成的文件将包含满足定义接口的结构体:

// OperationProcessor 是为 OperationProcessor 类型自动生成的 mock 类型
type OperationProcessor struct {
	mock.Mock
}

// ProcessOperation 提供了一个具有给定字段的 mock 函数:operation
func (_m *OperationProcessor) ProcessOperation(operation calculator.Operation) (*string, error) {
	ret := _m.Called(operation)
	// 实现代码
}

生成的结构体还包含一个嵌套的 mock.Mock 类型的结构体。这为在 mock 上断言活动提供了功能。这一功能在验证 mocks 时非常重要,我们将在接下来的内容中进行探讨。

重新生成 mocks

在工程团队中,通常会将 mock 生成添加到 Docker 文件的规范中。这可以让 mocks 作为 CI/CD 流水线的一部分生成,并在构建过程中使用它们。

验证 Mock

现在我们已经准备好开始为 Parser 结构体编写测试,并使用我们之前创建的生成的 mocks。图 3.4 描述了编写测试的步骤:

我们编写测试的粗略过程如下:

  1. 创建 mocks:在测试的 Arrange 步骤中创建 mock 结构体。mock 会与任何传递的依赖项分开,因此它易于初始化。此时,我们应该为每个 UUT 的直接依赖项创建 mocks

  2. 将 mocks 注入 UUT:在测试的 Arrange 步骤中,我们在创建 UUT 时注入 mock。由于这些 mocks 满足真实依赖项的接口,因此 UUT 并不关心它是接收到真实依赖项还是 mock

  3. 使用 On 方法设置期望:我们调用 mockOn 方法来设置对 mock 行为的期望。我们还设置任何期望的参数调用和返回值。这完成了测试的 Arrange 步骤。

  4. 调用 UUT 的方法:我们像正常的测试一样编写 Act 部分。UUT 并不知道它将使用一个 mock,因此任何方法调用都会像正常一样进行。

  5. 调用 AssertExpectations 方法:最后,在测试的 Assert 部分,我们调用所有 mocksAssertExpectations 方法,确保验证所有之前声明的期望。

mock 的使用非常简单,并且与测试库的集成良好。让我们来看一个简单的 Parser 类型的测试:

func TestProcessExpression(t *testing.T) {
	t.Run("valid input", func(t *testing.T) {
		// Arrange
		expr := "2 + 3"
		operator := "+"
		operands := []float64{2.0, 3.0}
		expectedResult := "2 + 3 = 5.5"
		engine := mocks.NewOperationProcessor(t)
		validator := mocks.NewValidationHelper(t)
		parser := input.NewParser(engine, validator)
		validator.On("CheckInput", operator, operands).Return(nil).Once()
		engine.On("ProcessOperation", &calculator.Operation{
			Expression: expr,
			Operator:   operator,
			Operands:   operands,
		}).Return(expectedResult).Once()
		// Act
		result, err := parser.ProcessExpression(expr)
		// Assert

		// 其他断言
		validator.AssertExpectations(t)
		engine.AssertExpectations(t)
	})
}

这个简单的 TestProcessExpression 测试展示了在编写测试时如何使用 mocksOn 方法的使用使我们能够轻松地为所有 mocked 直接依赖项配置期望的行为。如示范所示,On 方法可以用来指定详细的期望。以下是一些你经常会遇到的配置选项:

  • 函数名:在调用 On 方法时,函数名作为第一个参数指定。这个函数名表示要 mock 的函数。

  • 函数参数:参数也作为 On 方法的参数来指定。参数可以是特定的值,或者我们可以使用 mock.AnythingOfType 来断言它们的类型。如果我们不关心给定参数的验证,也可以使用 mock.Anything,但这种方式应尽量避免使用,因为它可能会使测试的意图变得难以理解。

  • 返回值:返回值通过链式调用的 Return 方法来指定,Return 方法在 On 方法后调用。它允许我们指定在使用配置的参数调用指定方法时返回的具体值。

  • 调用次数:调用次数也通过链式方法来指定。可以使用 OnceTwice 的简写方法;否则,可以使用 Times 方法来指定自定义的调用次数。Unset 方法可以用来指定 mock 处理程序不应被调用。

验证期望

记得在每个 mock 上调用 AssertExpectations 方法,以验证它们是否按照 On 方法中设定的期望被调用。这使得我们可以对 UUT 与其依赖项之间的交互进行细粒度控制。

想一想,如果不使用 mock,我们需要编写多少代码来设置自定义类型的前置条件,并验证依赖项是否按照期望被调用。testify/mock 库让我们能够以统一的方式在所有项目中充分利用 mocks 的强大功能。