测试驱动开发(TDD,Test Driven Development)是什么?
测试驱动开发是一套开发方法论,有经验的开发人员都会对自己的代码编写测试,而测试驱动试图将这一过程做到极致,“如果测试被证明是有价值的,那么,我们为什么不能更频繁的去做测试,如果将测试时间提前有益于提高应用质量,为什么不先做测试,再编写应用.”
测试驱动开发要求在编写某个功能之前先编写测试代码,然后编写使测试通过的代码,通过测试来推动整个开发的进行.
测试驱动开发最早由Kent Beck提出,他还出过一本书,叫测试驱动开发,感兴趣的同学,可以买(download)来看看.
本文主要介绍海外的一系列技术实践,不打算做TDD的科普,感兴趣的同学请自行google. 另外,既然是技术实践,就不会纠结在方法论上,所以,没有做过TDD,没有写过单元测试的同学无需过分担忧,你还是能看懂的.
实践一: TDD的核心是任务拆分.
咦,不是写测试,是任务拆分. 是的,跟我合作过的同学,尤其是新人同学大概都有印象,我一般会要求(我自己也常常这么干)把要做的事情写在一张卡片上,写成多行,每行代表一件事. 每完成一件,划掉一行,或者在前面打钩.
为什么要写出来?
帮助你把事情想清楚,每个人也许都曾感到,想得东西很多,结果却不知从何处下笔,因为开始写了,你才会真正得考虑上下文管理得严谨性,才会推敲每一个字的对错.
完成一件事,花掉一行会给人以making progress的正向反馈,你会觉得有成就感. 不信你试试. 当你一堆的卡片都是打钩状态时,你会觉得自己效率很高,从而自信满满. 工作效率提升也是有可能的.一定要写出来么?
是的,没有比这更好的办法,用手机,绝对不超过十分钟,你就会被微信,QQ,邮箱,各种推送打扰. 用大脑,当你对某个事情非常有经验的时候,可以,但是相信我,让你的宝贵脑细胞既负责思考问题的解决方案(这是有价值的),又负责记忆下一步要做什么,是一个很奢侈的浪费. 把这些琐碎而又low level的的记忆工作交给纸片吧,写下来,你只需要定时回顾,确保自己在正确的事情上花费了合理的时间,就够了.怎么写?
这其实是今天的重点,当你决定开始任务拆分了,并且已经准备了纸和笔,let’s begin.
A. 从业务出发.
从业务出发强调了,你应该首先关注的是要解决的问题,而不是工具,语言,架构设计和框架. 就我的经验,很多人,特别是初入职场的同学,最先暴露出来的问题就是不能清楚得定义问题.
比如,我们要做一个病历同步的功能,上传的时候要不要考虑http请求因为网络连接问题导致失败后的重试,于是我们花大量时间研究怎么优雅得实现retry. 但是,回过头来再看一下需求,我们要对病历同步,所有的病历数据是本地存储的,同步检查每两分钟就会自动触发一次,换句话说,我们不需要考虑retry,因为,没有成功上传的病历,再两分钟后会再次被同步. 因此,我们要解决的问题变成了,确保上传失败的病历,其同步状态不会被改变. (该案例供参考,不代表现在的同步逻辑)
此刻的任务列表
B,面向架构细分任务.
经过上面的思考,我们得到了一个额外的任务,然而,上述两个任务都是无法进行任何编码工作的,它们还是太抽象了,下一步该如何拆分,这时,我们的架构终于起到了作用,对于一个应用,我们一般都会有一个架构模式,如MVC&MVP,如Redux/Reflux,如Rx,再比如网络层或者中间件,根据以上,我们可以编写进一步得拆分了,如果是MVP,那么应该有对Model层的数据读写,Presenter对UI的更新及Viewer的响应,此刻,任务列表应该有所更新了.
- 上传病历
- Model(更可以细分成读和写)
- Network(Url,params,payload,response)
- Viewer.showLoading/success/failure
- 上传失败的病历不更新同步状态
- 定时器?
C,考虑边界条件,像QA一样思考
- 网络失败
- 用户数据不合法(对于同步,这其实是一个不合理的任务,数据验证应该在界面提交时就完成了,所以删除它)
D,不断细化你的任务列表,
坚持. 当你看到一个需求就像庖丁看到牛,也许,你可以考虑不写任务列表了.
任务列表是测试驱动开发的核心,继续谈一下测试实现过程中的考量.
1,测试即文档,可读性优先.
1)测试代码是像生产代码一样,需要持续维护的,因此,它必须写好. 由于测试代码并不会在高并发的环境下运行,因此不用过早地在意性能的问题,而是优先考虑测试代码的可读性.
在早期的基于JUnit的单元测试中,技术人员通常喜欢在方法名上做文章. 如以下几种形式都广为使用.
public void test_email_can_not_duplicate() {...}
@Test
public void should_throw_error_when_email_has_already_been_used() {...}
后来,社区中有人提出了BDD的概念,而后,测试框架也在逐步演进,有了cucumber和JBehave,于是有了下面的写法
@Given('I have 10 dollars')
public void i_have_10_dollars() {...}
将对测试的描述以更为自然语言的方式表达,当你的测试量逐渐增加,到几千个的时候,有种写小说的感脚. 然而太啰嗦,我不太喜欢,我们在海外组使用的是mocha,支持上述几种风格的描述.
describe('POST /patients',() => {
it('should respond 200 after creating patients',(done) => {...})
it('should respond 401 when create time is not set',(done) => {...})
...
})
写完就是一篇华丽丽的接口文档,后来我们还引入了supersamples,感兴趣的同学到这里看demo,碉堡了,有没有. 类似的工具很多,以前还用过http://concordion.org/,也是无比华丽.
2) 测试三段论Given/When/Then
测试也是代码,既然是代码,就需要良好的设计,Given/When/Then被称为测试三段论,是一种常见的用于测试的模式,很多同学做了任务拆分,但是测试还是写不好,往往是三段论没考虑清楚.
Given,前提条件,When,测试点或者说被测方法,Then,结果验证. 比如,我们要测试删除功能. 那么,前提就是你要有一条数据可删.
// Given
db('accounts').insert({id: 'xxx',lastName: 'xxxx'})
// When
const result = await Account.deleteById(id)
// Then
assertTrue(result)
当你不知道从何开始写测试的时候,首先搞清楚你要测什么,它的前提是什么,如何验证被测试的函数执行成功了.
多说一句,经常写单元测试的人,尤其是通过TDD的方式,写出来的代码可读性和可维护性都不会差,单元测试鼓励你写出纯函数,写出简洁的代码.
3) 测试方法必须是幂等的,即可重复执行的,测试之间不能有依赖.
说实话,这其实很难,很多人半途而废,觉得测试越写越复杂,甚至互相影响,一个挂了,红掉一片,就是这条没坚持好.
比如,有一些集成测试,会往数据库里写数据,就像上面的例子,很重要的一点就是,测试完成要还原数据库.
感谢极限编程社区,现在做这些事已经不那么麻烦. 哦,应该说很容易. 以海外为例.
我们使用knex来简化对数据库的操作,与之相配合的,有一个叫knex-cleaner的工具,专门负责对数据库的操作进行重置,感兴趣的同学可以来海外观摩或者参考文档.
借助mocha的beforeEach和afterEach hook,有了下面的代码,很简单吧,测试代码就不需要关注数据库环境了,当做全新的即可.
beforeEach(() => knexCleaner.clean(db)) afterEach(() => knexCleaner.clean(db))
但是,有同学可能会问,难道我做测试每次都要准备数据么,那多麻烦. 答案如下
- 如果你每次要准备多复杂的数据,考虑你的测试/程序/数据库设计是否合理,复杂的数据已经暗示你的测试可能关注的不是一件事,那么应该果断考虑拆分.
- 如果确认不需要拆分,那么没别的办法,写一个数据的factory吧,用于生成测试数据,这并不麻烦,另外,factory还能复用,你肯定不会只用一次.
- 还有一种情况,就是代码会依赖于第三方服务的时候,比如海外的代码就会依赖aws的api,那怎么办?
答案,把第三方的api mock或者stub掉,这里就涉及另外的技术和工具了,篇幅所限,本篇暂不展开
4) 测试是演进的,需要持续重构.
5) 代码评审(code review)时,先review测试.
好了,关于测试代码的注意事项,先写到这里. 单元测试会导致你的代码量double,这个成本值得么,我来告诉你,非常值得.
短期内,它强迫你关注在业务/架构/问题本身,而不是想入非非的”可扩展的设计”,即更聚焦.
长期来看,它会让你对代码重构更有信心,你可以更加放心的优化你的代码,替换第三方库到新版本,甚至某种程度上做底层架构改变(比如升级babel6).
杏树林研发 秦汉