Test-Driven Development(TDD) in Go

4,897 阅读10分钟

TDD,也就是测试驱动开发(Test-Driven development),是一种“测试先行”的程序设计方法论,其基本流程围绕着测试->编码(重构)->测试的循环展开。TDD的概念已不新鲜,但似乎并没有得到大范围的推广应用,或许是因为其成本太高,亦或许是因为开发人员的排斥,但这并不能掩盖TDD自身的优点和独到之处。在尝试用Go语言实践TDD开发一段时间后,我发现Go程序很适合使用TDD来构建——Go语言对测试的原生支持以及完善的测试类库框架使得TDD的实施成本相对较低,这相当于放大了TDD的收益。在此向广大gopher们安利一波,说不定你也会爱上它。本篇将从实际业务视角触发,通过一个示例来演示如何运用TDD来构建我们的Go程序。

本篇中的代码的完整示例可以在这里找到:tdd-example

TDD三原则

  1. 除非为了通过一个单元测试,否则不允许编写任何产品代码。
  2. 在一个单元测试中只允许编写刚好能够导致失败的内容。
  3. 一次只能写通过一项单元测试的产品代码,不能多写。
  1. You are not allowed to write any production code unless it is to make a failing unit test pass.
  2. You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
  3. You are not allowed to write any more production code than is sufficient to pass the one failing unit test.

根据三原则,TDD的开发过程描述如图:

在下面的示例中,将遵循上述的三原则,围绕着这五个步骤,展示如何使用TDD来开发我们的Go程序。

软件设计没有银弹,三原则是TDD思想的一种体现,并不是不可打破的教条。当你使用TDD已有些时日,或已领略到更好的方法,完全可以另辟蹊径。但当我们在刚开始熟悉一项新技术时,遵循原则往往才是最快的上手办法。

示例

需求背景

某外卖平台为了提供更优质的配送服务,决定在外卖小哥主动抢单的基础上增加主动派单,功能概述如下:接收订单派送请求,按调度规则,为用户选择一名外卖小哥进行配送,并通知外卖小哥取餐。在需求研讨会后,产品经理给出了第一版需求:

  1. 仅从距离商户5公里内的外卖小哥当中选择配送员。
  2. 对于购买了准时宝的用户,优先选择订单配送数量最少的小哥配送。
  3. 对于其他用户,随机分配一名小哥服务。
  4. 当外卖小哥当前配送订单数>=10后,将不再分配新订单。

整理测试用例

根据TDD的三原则,我们需要先写测试方法,所以首先我们需要整理出测试用例。这部分工作可以由开发与测试共同协作完成。在对需求梳理了一番后,整理出如下测试用例:

  1. 商户5公里内没有外卖小哥存在时,返回错误,不执行后续派单操作。
  2. 商户5公里内所有的外卖小哥配送订单数全部>=10时,返回错误,不执行后续派单操作。
  3. 如下单用户购买了准时宝,选择一个订单最少的小哥,通知小哥取餐。
  4. 如下单用户未购买准时宝,随机分配一名小哥服务,通知小哥取餐。

识别依赖,抽象成接口

分析需求和测试用例,识别其中的依赖,并将其抽象成接口。通常来说我们可以先将最容易抽象的依赖——网络,I/O以及中间件等外部依赖抽象成接口。假设该订单配送模块使用MongoDB(支持地理位置索引)来存储外卖小哥的实时位置;使用消息队列来通知外卖小哥取餐。即该功能包含数据库与消息队列两个依赖,我们将上述依赖定义成如下接口:

// DeliverBoyRepository 外卖小哥仓储接口
type DeliverBoyRepository interface {
	// GetNearBy 获取指定shopID内distance公里范围内的外卖小哥列表
	GetNearBy(shopID int, distance int) ([]*DeliveryBoy, error)
}
// DeliveryBoy 表示一个外卖小哥
type DeliveryBoy struct {
	ID int
	OrderNum int // 正在配送的订单数
}

// Notifier 消息队列接口
type Notifier interface {
	// 通知指定外卖小哥取餐
	NotifyDeliveryBoy(boyID int, orderID int)
}

使用结构体包裹所有依赖

使用一个结构体将刚才定义的接口封装起来,下面的代码片段将上述接口包裹在Handler结构体中,通过NewHandler注入依赖(构造函数注入)。其中的Handle方法便是待测方法。

// Handler 主动派单业务处理结构体
type Handler struct {
	boyRepo DeliverBoyRepository
	notifier Notifier
}
// NewHandler 使用构造函数将依赖注入
func NewHandler(d DeliverBoyRepository, n Notifier) *Handler {
	return &Handler{
		boyRepo:d,
		notifier:n,
	}
}

// Request 表示一个要处理的请求
type Request struct {
	// OrderID 订单ID
	OrderID int
	// ShopID 商户ID
	ShopID int
	// Insured 是否购买“准时宝”
	Insured bool
}

// Handle 订单配送逻辑处理
func (h *Handler) Handle(req *Request) (err error) { // <--- 待测方法
	return nil
}

至此我们的待测方法已经准备好,下面开始正式进入TDD的编码循环。

测试->编码(重构)->测试循环

为每一个用例编写测试方法,然后再编写业务代码使测试通过。我们从第一个用例开始:商户5公里内没有外卖小哥存在时,返回错误,不执行后续派单操作。在此我们使用gomocktestify来帮助我们快速编写测试代码。对它们不熟悉的小伙伴也不用担心,并不会对理解本示例造成太大困扰。

搞定Go单元测试(二)—— mock框架(gomock)
搞定Go单元测试(三)—— 断言(testify)

1. 写测试

// 1. 商户5公里内没有外卖小哥存在时,返回错误,不执行后续派单操作
func TestHandler_Handle_NoBoy(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer  ctrl.Finish()
	a := assert.New(t)

	// 使用gomock 来mock依赖
	d := NewMockDeliveryBoyRepository(ctrl)
	n := NewMockNotifier(ctrl)
	h := NewHandler(d,n)
	
	req := &Request{
		OrderID:1,
		ShopID:2,
	}
	// 5公里内没有外卖小哥
	d.EXPECT().GetNearBy(req.ShopID, 5).Return(nil, errors.New("o no..5公里内没有外卖小哥"))
	err := h.Handle(req)
	a.Error(err)
}

2. 执行测试,得到失败结果

=== RUN   TestHandler_Handle_NoBoy
--- FAIL: TestHandler_Handle_NoBoy (0.00s)
    handler_test.go:29: 
        	Error Trace:	handler_test.go:29
        	Error:      	An error is expected but got nil.
        	Test:       	TestHandler_Handle_NoBoy
    handler_test.go:30: missing call(s) to *handler.MockDeliveryBoyRepository.GetNearBy(is equal to 2, is equal to 5) D:/yushen/gopath/src/github.com/DrmagicE/tdd-example/handler/handler_test.go:27
    handler_test.go:30: aborting test due to missing call(s)
FAIL

失败结果告诉我们两个信息:

  1. 期望Handle方法应该返回一个error但是返回了nil
  2. 缺少了对GetNearBy(2,5)的方法调用

3. 写业务代码

接下来,我们编写业务代码通过上面的测试方法,切记不要写多,只写对应测试用例的代码:

// Handle 订单配送逻辑处理
func (h *Handler) Handle(req *Request) (error) {
	 _, err := h.boyRepo.GetNearBy(req.ShopID, 5)
	return err
}

4. 测试通过

再次执行测试,确保测试用例通过:

=== RUN   TestHandler_Handle_NoBoy
--- PASS: TestHandler_Handle_NoBoy (0.00s)
PASS

测试通过后,再开始写第二个测试用例,然后紧接着相应的业务代码,如此往复循环,直至所有的测试用例都测试通过。完整的测试方法请参看示例源码,就不在此展开。

5. 重构

随着测试代码->业务代码循环的增加,业务代码也不断的增加,如有必要,我们需要对业务代码进行重构。单元测试能保障旧有逻辑不被重构破坏。刚开始的重构可能只是涉及到if...else,for ... range改动,或是将可复用代码封装成函数等。但随着业务发展,上述的Handle()方法的代码越来越多,在适当的时候,我们需要抽象出新的接口层。举个栗子,随着上述外卖平台发展壮大,订单调度规则会越来越复杂,比如将订单配送路线和订单用户的集中程度作为订单调度的依据时,我们可以抽象出新的接口层:

// FactorsCalculator 计算各种配送因子
type FactorsCalculator interface {
	// GetDirectionFactor 分析小哥订配送路线,得到路线因子
	GetDirectionFactor(boyID int, orderID int) int
	//  GetUserLocationFactor 分析小哥订单的用户集中度,得到用户集中度因子
	GetUserLocationFactor(boyID int, orderID int)  int
}

对于Handle而言,其测试用例无需理解具体如何计算路线因子和用户集中度因子,只需mock其接口即可。对于因子计算正不正确,这是实现FactorsCalculator接口的模块所需要考虑的问题。(分层,分模块测试)

TDD能带给我们哪些好处

1. 加深对需求的理解
从上面的示例中可以发现,先写测试要求我们需要先整理测试用例,这就意味着开发必须将需求消化后才能编写业务代码。不仅开发对需求的理解更深,TDD还能促进测试,产品以及其他团队角色对需求达成共识,避免出现以下尴尬的场面:

测试:开发同学,我发现了一个BUG!
开发:No...No...No,你用例有问题,这是正常情况,不信我们去问产品
产品:emm..好像我当初不是这样设计的

2.提高编码效率和程序质量
我们每天都在写BUG,虽然无法杜绝BUG的产生,但使用TDD我们可以将大部分BUG扼杀在开发编码阶段。众所周知,在软件开发生命周期中,BUG发现的越晚,其代价也就越高。TDD能显著提高我们的程序质量,为我们节省大量成本。虽然短期内使用TDD可能会导致开发效率小幅度下降,但这点小损失相比因为BUG而引起的损失可以忽略不计。而且一旦熟悉TDD后,编码效率可谓是只增不减。

3. 有助程序设计
好的设计应当是逐步演进而来的,没有谁能一开始就将程序的结构和层次设计清楚,设计的越多就越容易“过度设计”。如果使用TDD,程序的设计随着不断的重构而不断的演进的,可以避免“过度设计”,且让程序一直保持其松耦合度和灵活性。

4. 有一定的文档价值
开发最讨厌的两件事:

  1. 阅读没有文档的代码
  2. 为自己的代码写文档

不可否认文档是不可或缺的,但同时维护一份文档并保持其准确性和实时性的代价是相当高的。如果有一种文档会自己随着程序的更新而更新,而且准确性也有保障,岂不美哉?你还在为写文档而烦恼吗?那就让TDD来帮你吧!

文档是软件不可或缺的一部分。正如软件的其它部分一样,它也得经常进行测试,这样才能保证它是准确的并且是最新的。实现这个最有效的方法就是将这个可执行的文档能够集成到你的持续集成系统里面。TDD是这个方向的不二选择。从较低层面来看的话,单元测试就非常适合作为这个文档。另一方面来说的话,在功能层面来说BDD是一个很好的方式,它可以使用自然语言来进行描述,这保证了文档的可读性。
www.51testing.com/html/54/n-8…

其他参考

测试驱动开发实践 - Test-Driven Development

为什么你无法说服你的同事使用TDD?

测试即是文档