探索 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 中使用的外部功能的接口。在我们的案例中,UUT 是
input.Parser
,它需要两个依赖项:-
OperationProcessor
接口封装了ProcessOperation
方法。该功能将由calculator.Engine
满足,它将计算解析出的操作符和操作数的数学结果。 -
ValidationHelper
接口封装了CheckInput
方法。该功能将由input.Validator
满足,确保用户提供的输入可以被正确处理。
-
导出的依赖接口
请注意,封装依赖项的接口已被导出,如接口名称的首字母大写所示。通常的做法是将接口导出,而将对应的结构体保持在包作用域内。这使我们能够对外部暴露的功能进行细粒度的控制。 |
接下来,我们将 input.Parser
类型的依赖项包装成刚才定义的接口:
// Parser 负责将输入转换为数学操作
type Parser struct {
engine OperationProcessor
validator ValidationHelper
}
正如我们在前一节中讨论的那样,Go 中的依赖通常表示为接口,而不是结构体类型。这使得我们能够注入任何满足给定接口的类型,而不仅仅是具体的结构体。这是一个非常强大的机制。
使用接口表示依赖的另一个大优点是,它们允许我们打破包之间的依赖关系,编写松耦合的代码。
图 3.3 展示了我们如何打破硬依赖关系:

正如我们所看到的,通过使用内部定义的接口表示依赖关系,使得我们能够打破模块之间的硬依赖关系。满足这些接口的外部结构体可以在包外部创建并注入到 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
从输出中可以看出,我们的两个接口 OperationProcessor
和 ValidationHelper
被检测到,并且已经为它们生成了 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
现在我们已经准备好开始为 Parser
结构体编写测试,并使用我们之前创建的生成的 mocks
。图 3.4 描述了编写测试的步骤:
我们编写测试的粗略过程如下:
-
创建 mocks:在测试的
Arrange
步骤中创建mock
结构体。mock
会与任何传递的依赖项分开,因此它易于初始化。此时,我们应该为每个 UUT 的直接依赖项创建mocks
。 -
将 mocks 注入 UUT:在测试的
Arrange
步骤中,我们在创建 UUT 时注入mock
。由于这些mocks
满足真实依赖项的接口,因此 UUT 并不关心它是接收到真实依赖项还是mock
。 -
使用 On 方法设置期望:我们调用
mock
的On
方法来设置对mock
行为的期望。我们还设置任何期望的参数调用和返回值。这完成了测试的Arrange
步骤。 -
调用 UUT 的方法:我们像正常的测试一样编写
Act
部分。UUT 并不知道它将使用一个mock
,因此任何方法调用都会像正常一样进行。 -
调用 AssertExpectations 方法:最后,在测试的
Assert
部分,我们调用所有mocks
的AssertExpectations
方法,确保验证所有之前声明的期望。
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
测试展示了在编写测试时如何使用 mocks
。On
方法的使用使我们能够轻松地为所有 mocked
直接依赖项配置期望的行为。如示范所示,On
方法可以用来指定详细的期望。以下是一些你经常会遇到的配置选项:
-
函数名:在调用
On
方法时,函数名作为第一个参数指定。这个函数名表示要mock
的函数。 -
函数参数:参数也作为
On
方法的参数来指定。参数可以是特定的值,或者我们可以使用mock.AnythingOfType
来断言它们的类型。如果我们不关心给定参数的验证,也可以使用mock.Anything
,但这种方式应尽量避免使用,因为它可能会使测试的意图变得难以理解。 -
返回值:返回值通过链式调用的
Return
方法来指定,Return
方法在On
方法后调用。它允许我们指定在使用配置的参数调用指定方法时返回的具体值。 -
调用次数:调用次数也通过链式方法来指定。可以使用
Once
和Twice
的简写方法;否则,可以使用Times
方法来指定自定义的调用次数。Unset
方法可以用来指定mock
处理程序不应被调用。
验证期望
记得在每个 |
想一想,如果不使用 mock
,我们需要编写多少代码来设置自定义类型的前置条件,并验证依赖项是否按照期望被调用。testify/mock
库让我们能够以统一的方式在所有项目中充分利用 mocks
的强大功能。