顶级 Go 模块: 使用 Testify 编写单元测试

更新: 截至 2021 年 5 月 1 日 – GoCenter 中央制品库已关闭,所有功能均被停用。 如需了解更多有关中心被关闭的信息,请阅读停用博客文章

 

 

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

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

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

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

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

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

Testify: 一个顶级 Gopher

Testify 是一套对开发人员友好的程序包,拥有强大的社区支持,在 GitHub 上获得了超过 11,000 颗星。 Testify 扩展了 Go 的轻量级 测试 框架,可执行断言和模拟依赖项。

这些功能,加上我们的 Go 社区团队对它的日常依赖,是 Testify 模块荣膺 GoCenter 中“顶级 Gopher”称号的重要原因。 当您查看 GoCenter 关于 Testify 模块的丰富元数据时,可以了解到为什么:

  • 该模块的自述文件能够引导您全面访问各种文档。 我们可以通过 GoDoc 选项卡了解有关该模块代码的更多详细信息,该选项卡中显示了自动生成的函数文档等。
  • GoCenter 的使用者指标选项卡显示该模块广受欢迎和信任,有大量的下载、复制、贡献者,以及其他 Go 模块对它的使用。
  • GoCenter 的“安全”选项卡也指示,该模块的当前版本及其依赖项不存在已知的 NVD 漏洞,这一点可通过 JFrog Xray 深度扫描验证。

简单的 GoLang 单元

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

type ProductService interface {
	IsProductReservable(id int) (bool, error)
}

 

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

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

该服务的实现如下所示:

type ProductServiceImpl struct {
	productDAO persist.ProductDAO
}

// Constructor
func NewProductServiceImpl(dao persist.ProductDAO) *ProductServiceImpl {
	return &ProductServiceImpl{
		productDAO: dao,
	}
}

func (s *ProductServiceImpl) IsProductReservable(id int) (bool, error) {
	// Get product information from database
	product, err := s.productDAO.GetProduct(id)
	if err != nil {
		return false, fmt.Errorf("failed to get product details: %w", err)
	}

	if product == nil {
		return false, fmt.Errorf("product not found for id %v", id)
	}

	// Only products added more than 1 year ago to the catalog can be reserved
	return product.CreatedAt.Before(time.Now().AddDate(-1, 0, 0)), nil
}

使用 Testify

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

执行断言

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

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

import (	
	"service"
	"testing"
)

func TestNewProductServiceImpl(t *testing.T) {
	productDaoMock := ProductDaoMock{} // Ignore the mock for now
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	// Asserts ProductServiceImpl implements ProductService. Will break the compiler if it does not.
	var _ service.ProductService = productServiceImpl

	if productServiceImpl == nil {
		t.Fatal("Product Service not initialized")
	}

	if productServiceImpl.productDAO == nil {
		t.Fatal("Product Service dependency not initialized")
	}
}

为帮助执行断言,Testify 提供了程序包 github.com/stretchr/testify/assert. 此程序包具有多种方法,可帮助将值与预期结果进行比较。 如果我们用这些方法替换我们的比较,则得到以下结果:

import (
	"github.com/stretchr/testify/assert"
	"service"
	"testing"
)

func TestNewProductServiceImpl(t *testing.T) {
	assertions := assert.New(t)
	productDaoMock := ProductDaoMock{} // Ignore the mock for now
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	if !assertions.Implements((*service.ProductService)(nil), new(ProductServiceImpl)) {
		t.Fatal("Product Service Implementation does not honor service definition")
	}

	if !assertions.NotNil(productServiceImpl, "Product Service not initialized") {
		t.Fatal("Product Service not initialized")
	}

	if !assertions.NotNil(productServiceImpl.productDAO, "Product Service dependency not initialized") {
		t.Fatal("Product Service dependency not initialized")
	}
}

除了帮助执行断言,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)

到目前为止,虽然我们有更好的消息传递功能和更方便的方法来运行断言,但仍无法减小测试规模。 我们仍然要重复地采用 if-not-assertion-break 模式,但这会降低我们的测试代码的可读性。 为帮助解决这个问题,Testify 提供了程序包 github.com/stretchr/testify/require. 该程序包虽然采用与 assert 包相同的断言方法,但是当断言失败时,它会立即中断测试。 通过引入该程序包,我们得到以下更简短易读的测试代码:

import (
	"github.com/stretchr/testify/require"
	"service"
	"testing"
)

func TestNewProductServiceImpl(t *testing.T) {
	assertions := require.New(t)

	productDaoMock := ProductDaoMock{} // Ignore the mock for now
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	assertions.Implements((*service.ProductService)(nil), new(ProductServiceImpl), "Product Service Implementation does not honor service definition")
	assertions.NotNil(productServiceImpl, "Product Service not initialized")
	assertions.NotNil(productServiceImpl.productDAO, "Product Service dependency not initialized")
}

 

模拟依赖项

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

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

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

type ProductDAO interface {
	GetProduct(id int) (*model.Product, error)
}

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

import (
	"errors"
	"model"
	"persist"
	"testing"
	"time"
)

type ProductDaoMock struct {
}

func (m *ProductDaoMock) GetProduct(id int) (*model.Product, error) {
	switch id {
	case 1:
		return &model.Product{
			Id:          1,
			Description: "Product created 2 years ago",
			CreatedAt:   time.Now().AddDate(-2, 0, 0),
		}, nil
	case 2:
		return &model.Product{
			Id:          2,
			Description: "Product recently created",
			CreatedAt:   time.Now(),
		}, nil
	case 999:
		return nil, persist.ErrProductNotFound
	}
	return nil, nil
}

func TestProductServiceImpl_IsProductReservable(t *testing.T) {
	testDataSet := map[int]bool {
		1: true,
		2: false,
	}

	productDaoMock := ProductDaoMock{}
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	for productId, expectedResult := range testDataSet {
		reservable, err := productServiceImpl.IsProductReservable(productId)
		if err != nil {
			t.Fatalf("Failed to check if product %v is reservable: %s", productId, err)
		}

		if reservable != expectedResult {
			t.Fatalf("Got wrong reservable info for product id %v. Expected: %v. Got: %v", productId, expectedResult, reservable)
		}
	}
}

func TestProductServiceImpl_IsProductReservable_NotFound(t *testing.T) {
	productDaoMock := ProductDaoMock{}
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	_, err := productServiceImpl.IsProductReservable(999)
	if !errors.Is(err, persist.ErrProductNotFound) {
		t.Fatalf("Got unexpected error result: %s", err)
	}
}

 

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

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

我们需要保持测试用例逻辑的集中性和独立性。 为帮助解决这个问题,Testify 提供了程序包 github.com/stretchr/testify/mock. 此程序包提供用于创建模拟以便在运行时注入行为的工具,这样,测试用例本身可以在保持模拟逻辑接近测试逻辑的情况下完成测试。

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

import (
      "github.com/stretchr/testify/require"
      "github.com/stretchr/testify/mock"
	"errors"
	"model"
	"persist"	
	"testing"
	"time"
)

type ProductDaoTestifyMock struct {
	mock.Mock
}

func (m *ProductDaoTestifyMock) GetProduct(id int) (*model.Product, error) {
	args := m.Called(id)
	return args.Get(0).(*model.Product), args.Error(1)
}

func TestProductServiceImpl_IsProductReservable(t *testing.T) {
	assertions := require.New(t)

	// Register test mocks
	productDaoMock := ProductDaoTestifyMock{}
	productDaoMock.On("GetProduct", 1).Return(&model.Product{
		Id:          1,
		Description: "Product created 2 years ago",
		CreatedAt:   time.Now().AddDate(-2, 0, 0),		
	}, nil)
	productDaoMock.On("GetProduct", 2).Return(&model.Product{
		Id:          2,
		Description: "Product recently created",
		CreatedAt:   time.Now(),
	}, nil)

	testDataSet := map[int]bool {
		1: true,
		2: false,
	}

	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	for productId, expectedResult := range testDataSet {
		reservable, err := productServiceImpl.IsProductReservable(productId)
		assertions.NoErrorf(err,"Failed to check if product %v is reservable: %s", productId, err)
		assertions.Equalf(expectedResult, reservable,"Got wrong reservable info for product id %v", productId)
	}
}

func TestProductServiceImpl_IsProductReservable_NotFound(t *testing.T) {
	assertions := require.New(t)

	// Register test mocks
	productDaoMock := ProductDaoTestifyMock{}
	productDaoMock.On("GetProduct", 1).Return((*model.Product)(nil), persist.ErrProductNotFound)

	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	_, err := productServiceImpl.IsProductReservable(1)

	if !errors.Is(err, persist.ErrProductNotFound) {
		assertions.Failf("Got unexpected error result", "Got unexpected error result: %s", err)
	}
}

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

结语

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

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

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