每个月,GoCenter 都会向表现最佳的模块颁发 Gopher 徽章作为成就标志。 我们正在撰写一些介绍这些顶级模块及如何在 Go 中使用这些模块的文章。

开发人员在各种场景中都接触过这些模块,其中不乏在结构良好的 Golang 程序中使用: 注释建议您避开某些代码行,因为它们的运作方式非常神奇,牵一发动全身。 这些警告可以让我们提高警惕,以免造成破坏。 但是应用程序需要不断升级,以实现改进和创新。

正因为如此,单元测试成为软件开发的重要组成部分。 它们帮助开发人员了解其软件的各个小部分是否正确执行预期功能。 通过安排适量的单元测试,确保覆盖率,开发人员可以更有信心对其实现进行改动,甚至从头重构,因为他们知道可以轻松地检查新版本是否仍然按预期运行。

随着软件复杂度的提高,单元测试以及一套采用相同语言的可靠工具的重要性也随之提高。 具有良好覆盖率的测试代码可以发挥巨大作用,因此它需要像产品代码一样,可读且可维护,这样可以鼓励开发人员放手使用,从中获利。

针对我们的 Go 社区项目(如 GoCenter),我们广泛使用流行的 Testify 模块,该模块提供一组 Golang 程序包来执行基本的单元测试函数。

本文介绍如何使用 Testify 的主要功能在 Go 中编写易于阅读和维护的单元测试。 文中展示了当使用纯 Go 时单元测试是什么样的,以及可以帮助执行任务的 Testify 包,然后显示了采用 Testify 后生成的代码。 我们将展示一些有关如何执行断言和为依赖项编写模拟的最佳实践。

Testify: 一个顶级 Gopher

测试

简单的 GoLang 单元

要开始编写单元测试,首先要有需测试的组件。 在本练习中,我们使用以下服务定义:

 

针对此服务定义,我们有一个实现,并有兴趣测试一下。 该实现具有一些业务逻辑,用于判断产品是否可保留。 该实现还依赖数据访问对象组件来提供有关产品的信息。 该实现需要通过以下简化的测试用例:

  • 服务实现需要遵守服务定义
  • 添加到目录中超过1年的产品可保留
  • 其他产品不可保留
  • 不在目录中的产品应导致“找不到产品”错误

该服务的实现如下所示:

使用 Testify

现在我们有了一个简单的服务,可以使用 Testify 来创建单元测试,以确保它按预期运行。

执行断言

单元测试执行的最基本任务就是断言。 断言通常用于验证使用确定输入的测试所执行的操作是否产生预期的输出。 它们也可以用于检查组件是否遵循所需的设计规则。

使用纯 Go 运行所需断言,以检查第一个测试用例是否得到遵守,以及是否对我们的服务实现进行正确的初始化,然后我们得到以下代码:

github.com/stretchr/testify/assert

除了帮助执行断言,Testify 程序包还可以在其中一项操作失败时更好地传递消息。 例如,如果我们忘记在服务实现构造函数中设置 productDAO 字段,则会出现以下测试故障:

=== RUN   TestNewProductServiceImpl
    TestNewProductServiceImpl: product_service_impl_test.go:22:
        	Error Trace:	product_service_impl_test.go:22
        	Error:      	Expected value not to be nil.
        	Test:       	TestNewProductServiceImpl
        	Messages:   	Product Service dependency not initialized
    TestNewProductServiceImpl: product_service_impl_test.go:23: Product Service dependency not initialized
--- FAIL: TestNewProductServiceImpl (0.00s)
github.com/stretchr/testify/require

 

模拟依赖项

测试组件时,理想的情况是将其完全隔离,避免其他故障影响我们的测试。 如果要测试的组件依赖于软件中不同层的其他组件,要想隔离该组件则尤其困难。 在我们此处使用的场景中,我们的服务实现依赖数据访问对象 (DAO) 层中的组件来访问有关产品的信息。

为推动进行所需隔离,开发人员通常会为这些依赖项编写简化的假实现,以便在测试期间使用。 这些假实现称为模拟。

我们可以创建 ProductDAO 的模拟实现,并将其注入到服务实现中以执行测试。 我们的模拟需要实现的 ProductDAO 接口如下所示:

为了启用测试执行功能,模拟必须提供与我们要验证的所有测试用例兼容的行为,否则我们将无法达到所需的测试覆盖率。 使用纯 Go,具有模拟实现的测试用例将如下所示:

 

上述方法的主要问题在于,现在我们的测试用例逻辑是分布式的。 一部分逻辑在测试用例本身中实现,我们将事件发送到被测试的组件并使用测试结果运行断言,而另一部分逻辑在模拟中实现,需要提供与测试用例正在测试的内容兼容的行为。 现在,我们很容易就能看出我们的测试用例是如何中断的,不是因为测试本身的问题,而是因为模拟没有返回所需的数据。

另一个更令人沮丧的问题是,我们还在多个测试用例之间共享模拟。 为满足一个测试用例需求而进行的模拟更改有可能无法满足其他测试用例需求。 在我们的场景中,我们只关心 3 个测试用例,而如果我们有更多复杂的测试用例,则不难想象会有多么混乱。 将模拟拆分成多块并不一定能够解决问题,而且随着复杂性的扩散,情况会变得更糟。 另外,如果我们的模拟接口改变,则需要更新多个模拟来确保它们彼此兼容。

github.com/stretchr/testify/mock

使用 Testify mock 程序包创建我们的 DAO 模拟,将模拟行为初始化移到测试用例,并添加 Testify require 程序包来运行断言,则测试代码如下所示:

请注意在上面的实现中,模拟行为和测试逻辑如何集中反映在测试用例中。 此外,请注意注册的模拟行为如何专属于放置它的测试用例,因为它属于一个不在多个测试之间共享的模拟实例。 测试甚至为产品 id 1 注册了不同的行为,而且完全没问题。 ProductDaoTestifyMock 可以在多个测试用例之间安全地重用,因为它没有具体行为。

结语

我希望您从本文中获得有用的信息,帮助您在项目中编写更出色的单元测试。 要使用 Go 模块将 Testify 添加到您的项目中并开始使用它,只需运行以下命令:

$ export GOPROXY=https://gocenter.io
$ go get github.com/stretchr/testify

请在 GoCenter 上查找 Testify,或搜索更多出色的 Go 模块。