顶级模块:编写单元测试与证明

更新:截至2021年5月1日- GoCenter中央存储库已经日落,所有功能将被弃用。有关中心日落的更多信息,请阅读弃用博客文章

每个月GoCenter都会奖励表现最好的模块a金花鼠徽章作为成就的标志。我们正在写其中的一些高层模块以及它们在Go中的用法。

所有开发人员都见过它们,即使是在结构良好的Golang程序中:注释建议您远离代码行,因为它们似乎以一种神奇的方式工作。这些警告使我们胆怯,担心我们可能会打破一些东西。但是应用程序需要改变、改进和创新。

这就是为什么单元测试是软件开发的重要组成部分。它们帮助开发人员了解软件的一小部分是否正确地执行了预期的功能。有了适当数量的单元测试覆盖率,开发人员就更有信心改变他们的实现,甚至从头开始重构它,因为他们知道他们可以很容易地检查新版本是否仍按预期工作。

随着软件复杂性的增长,单元测试和一套可靠的工具在同一种语言中的重要性也在增加。并且由于具有良好覆盖率的测试代码可能是实质性的,因此它需要像产品代码一样具有可读性和可维护性,以鼓励开发人员使用它并获得它的好处。

对于我们的Go社区项目,如GoCenter,我们广泛使用流行的作证模块,它提供了一组用于执行基本单元测试功能的Golang包。

本文展示了如何使用witness的主要特性在Go中编写易于阅读和维护的单元测试。它通过展示使用纯Go时单元测试的样子,介绍可以帮助执行任务的作证的包,然后展示采用作证后的结果代码来实现这一点。我们将展示一些关于如何执行断言和为依赖项编写模拟的最佳实践。

证物:顶级地鼠

作证是一套开发人员友好的软件包,在GitHub上有超过11,000颗星星,并有很大的社区支持。证物扩展了轻量级测试框架来执行断言和模拟依赖关系。

这些特性,以及我们的Go社区团队对它的日常依赖,是证言模块被评为顶级的重要原因GoCenter的“最佳地鼠”.当您查看GoCenter关于作证模块的丰富元数据时,你可以看到原因:

一个简单的GoLang单位

要开始编写单元测试,我们首先需要测试一个组件。对于这个练习,我们将使用以下服务定义:

类型Prohth华体会最新官方网站ductService接口{IsProductReservable(id int) (bool,错误)}

对于这个服务定义,我们有一个实现,我们有兴趣测试它。该实现有一些业务逻辑来确定产品是否可保留。该实现还依赖于数据访问对象组件来提供有关产品的信息。hth华体会最新官方网站实现需要通过以下简化的测试用例:

  • 服务实现需要尊重服务定义
  • hth华体会最新官方网站1年前加入目录的产品可以预订
  • 其他产品不可hth华体会最新官方网站预订
  • hth华体会最新官方网站不在目录中的产品会导致产品未找到错误

服务实现看起来是这样的:

type hth华体会最新官方网站ProductServiceImpl struct {productDAO持久化。ProductDAO} //构造函数func NewProductSerhth华体会最新官方网站viceImpl(dao persist.ProductDAO) *ProductServiceImpl {return &ProductServiceImpl{ProductDAO: dao,}} func (s *ProductServiceImpl) IsProductReservable(id int) (bool, error){//从数据库product中获取产品信息,err:= s.productDAO.GetProduct(id)如果err != nil{返回false, fmt。错误("未能获取产品详细信息:%w", err)}如果产品== nil{返回false, fmt。Errorf("product not found for id %v", id)} //只有添加到目录hth华体会最新官方网站中超过1年的产品可以保留,返回product. createdat . before (time.Now())。AddDate(-1, 0, 0)), nil}

使用证明

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

执行断言

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

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

import ("service" "testing") func TestNewPrhth华体会最新官方网站oductServiceImpl(t *testing. t) {productDaoMock:= productDaoMock{} //暂时忽略模拟productServiceImpl:= NewProductServiceImpl(&productDaoMock) //断言productServiceImpl实现了ProductService。如果没有,将破坏编译器。Var服务。hth华体会最新官方网站如果productServiceImpl == nil {t.Fatal("Product Service not initialized")}如果productServiceImpl。productDAO == nil {t.Fatal("产品服务依赖项未初始化")}}

为了帮助断言,作证提供了一个包github.com/stretchr/testify/assert.这个包有几个方法可以帮助将值与预期结果进行比较。如果用这些方法替换比较,可以得到以下结果:

import (" github.com/stretchr/testify/assert" "service" "testing") func TestNewPhth华体会最新官方网站roductServiceImpl(t *testing. t){断言:= assert.New(t) productDaoMock:= productDaoMock{} //暂时忽略模拟productServiceImpl:= NewProductServiceImpl(&productDaoMock) if !assertions.Implements((*service. productservice)(nil), new(productServiceImpl)) {t. fatal("产品服务实现不遵守服务定义")}if !断言。NotNil(hth华体会最新官方网站productServiceImpl, "产品服务未初始化"){t.Fatal("产品服务未初始化")}productDAO, "产品服务依赖未初始化"){t.Fatal("产品服务依赖未初始化")}}

除了帮助断言之外,当其中一个操作失败时,作证包还提供更好的消息传递。例如,如果我们忘记在服务实现构造函数中设置productDAO字段,我们将得到以下测试失败:

===运行TestNewProhth华体会最新官方网站ductServiceImpl TestNewProductServiceImpl: product_service_impl_test。go:22:错误跟踪:product_service_impl_test。go:22错误:期望值不是nil。测试:TestNewProhth华体会最新官方网站ductServiceImpl消息:产品服务依赖未初始化TestNewProductServiceImpl: product_service_impl_test。go:23:产品服务依赖未初始化——FAIL: TestNewProductServiceImpl (0.00s)hth华体会最新官方网站

到目前为止,即使我们有更好的消息传递和更方便的方法来运行断言,我们也无法减少测试的大小。我们仍然有一个重复的if-not- assertionbreak模式,这会使我们的测试代码更难阅读。为了帮助解决这个问题,作证提供了软件包github.com/stretchr/testify/require.此包具有与assert包提供的相同的断言方法,但是当断言失败时,它将立即中断测试。引入这个包后,我们得到了以下更短、更容易阅读的测试代码:

import ("github.com/stretchr/testify/require" "service" "testing") func TestNewPrhth华体会最新官方网站oductServiceImpl(t *testing. t){断言:= require.New(t) productDaoMock:= productDaoMock{} //暂时忽略模拟productServiceImpl:= NewProductServiceImpl(&productDaoMock) asserations . implements ((*service. productservice)(nil), new(productServiceImpl), "产品服务实现不尊重服务定义")断言。notil (hth华体会最新官方网站productServiceImpl, "产品服务未初始化")断言。productDAO, "产品服务依赖项未初始化")}

嘲笑的依赖性

在测试组件时,理想情况下,我们希望将其完全隔离,以避免在其他地方出现故障,从而影响我们的测试。当我们想要测试的组件依赖于软件中不同层的其他组件时,这尤其困难。在我们这里使用的场景中,我们的服务实现依赖于来自数据访问对象(DAO)层的组件来访问有关产品的信息。hth华体会最新官方网站

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

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

类型ProductDAO接口{GetProduct(id int)(*模型。Product, error)}

为了启用测试执行,模拟提供与我们想要验证的所有测试用例兼容的行为是必要的,否则我们无法实现期望的测试覆盖率。使用纯Go,我们的模拟测试用例如下所示:

import ("errors" "model" "persist" "testing" "time") type ProductDaoMock struct {} function (m *ProductDaoMock) GetProduct(id int) (*model. int)产品,错误){开关id{情况1:返回&型号。产品{Id: 1,描述:"产品创建于2年前",CreatedAt: time.Now()。AddDate(- 2,0,0),}, nil情况2:返回&模型。Product{Id: 2, Description: "Product recently created", CreatedAt: time.Now(),}, nil case 999:返回nil,持久化。ErrProductNotFound} return nil, nil} func TestPrhth华体会最新官方网站oductServiceImpl_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)如果err != nil {t.Fatalf("检查产品%v是否可保留失败:= expectedResult {t.Fatalf("获得了错误的产品id %v的可保留信息。预期:% v。{}} func TestProductServiceImpl_IsProductReservable_NotFound(t *hth华体会最新官方网站testing.T) {productDaoMock:= productDaoMock {} productServiceImpl:= NewProductServiceImpl(&productDaoMock) _, err:= productServiceImpl. isproductreservable (999) if !Is(err, persistent . errproductnotfound) {t.Fatalf("得到意外错误结果:%s", err)}}

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

另一个更令人沮丧的问题是,我们还在多个测试用例之间共享模拟。为了满足一个测试用例的需要,应用到模拟上的更改可能会破坏其他测试用例。在我们的场景中,我们只关心3个测试用例,但是您可以想象,如果我们有更复杂的测试用例,它会变得多么混乱。将模拟拆分为多个模拟不一定也会有帮助,而且随着复杂性的进一步扩展,可能会使情况变得更糟。另外,如果模拟接口发生变化,我们需要更新几个模拟以保持它们的兼容性。

我们需要的是保持测试用例逻辑的集中和独立。为了帮助解决这个问题,作证提供了软件包github.com/stretchr/testify/mock.这个包提供了创建模拟的工具,这些模拟允许在运行时中注入行为,这允许测试用例本身执行模拟,使模拟逻辑接近测试逻辑。

使用作证模拟包来创建DAO模拟,并将模拟行为初始化移动到测试用例中,并添加作证要求包来运行断言,我们的测试代码如下所示:

import ("github.com/stretchr/testify/require" "github.com/stretchr/testify/mock" "errors" "model" "persist" "testing" "time") type productdaotestfymock struct {mock。function (m * productdaotestfymock) GetProduct(id int) (*model。Product, error) {args:= m.s edcalled (id) return args. get (0).(*model.Product), args. error (1)} funhth华体会最新官方网站c TestProductServiceImpl_IsProductReservable(t *testing.T){断言:= require.New(t) //注册测试mock productDaoMock:= productdaotestfymock {} productDaoMock。(“GetProduct”,1).Return(模型。产品{Id: 1,描述:"产品创建于2年前",CreatedAt: time.Now()。AddDate(- 2,0,0),}, nil) productDaoMock。(“GetProduct”,2).Return(模型。产品{Id: 2,描述:"产品最近创建",CreatedAt: time.Now(),}, nil) testDataSet:= map[int]bool {1: true, 2: false,} productServiceImpl:= NewProductServiceImhth华体会最新官方网站pl(&productDaoMock) for productId, expectedResult:= range testDataSet {reservable, err:= productServiceImpl. isproductreservable (productId)断言。NoErrorf(err,"Failed to check if product %v is reserved: %s", productId, err)断言。{} func TestProductServiceImpl_IsProductReservable_NotFound(t *testing.T){断言:= require.New(t) //注册测试mock hth华体会最新官方网站productDaoMock:= productdaotestfymock {} productDaoMock。On("GetProduct", 1). return ((*model.Product)(nil), persist.ErrProductNotFoundhth华体会最新官方网站) productServiceImpl:= NewProductServiceImpl(&productDaoMock) _, err:= productServiceImpl. isproductreservable (1) if !Is(err, persistent . errproductnotfound){断言。Failf("获得意外错误结果","获得意外错误结果:%s", err)}}

在上面的实现中,请注意模拟行为和测试逻辑是如何集中在测试用例中的。另外,请注意所注册的模拟行为是如何与放置它的测试用例独占的,因为它属于单个模拟实例,而不是在多个测试之间共享。测试甚至为产品id 1登记了不同的行为,完全没有问题。productdaotestfymock可以安全地在多个测试用例之间重用,因为它没有行为。

结论

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

$ export GOPROXY=https://gocenter。IO $去github.com/stretchr/testify

查看GoCenter上的证言或者搜索以发现更多优秀的围棋模块。