Test-Driven Development(TDD) in Go

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

本篇中的代码的完整示例能够在这里找到:tdd-examplegit

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的开发过程描述如图:github

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

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

示例

需求背景

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

  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方法即是待测方法。less

// 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?

测试便是文档

相关文章
相关标签/搜索