TDD是一个迭代的开发过程,他包括下面的步骤:1.编写测试;2.运行测试,观察失败;3.确保测试通过;4.重构,减少重复。
每次迭代中,测试就是规范。在我们完成开发之后,测试就可以通过了。之后我们就需要进行减少重复代码和提高设计的重构工作,然后再次运行测试,并保证其通过。
虽然TDD不主张预期的大设计,但是我们在TDD开始之前还是需要做个简单的设计。我们要如何写自己的第一个设计呢?当我们获得了足够信息可以制定测试的时候,测试代码的编写,本身就是设计。我们指定在特定情况下特定代码的行为,系统之间组件如何响应,以及他们之间如何组合。下面我们会举例,以便大家更好的学习。
TDD中的迭代时间很短,我们需要非常清楚我们所处的阶段。无论何时我们对代码进行了修改,或者移出了某些功能,我们需要把他们记录在todo列表中,加以观察。这个列表可以是一张纸,或者记事本之类的东西,只要方便你快速的查找和记录既可。在处理这些新的修改点之前,我们需要首先结束本次迭代。本次迭代结束之后,我们再从todo列表中取出一个任务,开始下一次迭代工作。
步骤1:编写测试
每次TDD迭代,首先要做的事情是选择一个你要做的功能,为它编写单元测试。单元测试需要简短,测试聚焦在function上的某一个功能点上。比较好的编写测试的规则是,编写尽量少的测试代码就能让测试失败。当然,测试断言不能和之前的测试重复。如果一个测试涉及到系统的两个或两个以上的方面,就说明要么是这个测试太大了,要么是里面包含了重复的测试点。
测试需要能够描述我们实现的功能,我们的功能代码没做修改,就不需要修改测试代码。
假设我们要完成一个String.prototype.trim函数的开发,用以去除字符串前后空格。对该方法好的测试,第一步应该是测试前空格是否删除了。
testCase("String trim test", { "test trim should remove leading white-space": function () { assert("should remove leading white-space", "a string" === " a string".trim()); } });
严谨起见,我们需要先判断字符串包含trim方法。这是因为我们添加的全局函数可能会和第三方代码发生冲突,在代码之前添加 typeof "".trim == "function",可以帮助我们在运行测试之前发现问题。
为单元测试提供输入条件,运行之后他会判断输出条件是否和预期一致。输入条件不仅仅是函数的参数,任何函数的依赖项,例如全局作用域、某些对象的特殊状态,这些都是输入条件。与此类似,输出结果包括返回值、全局作用域或者相关对象。我们通常把输入输出项分为直接和间接两种。直接项:函数的参数和返回值;间接项:不是以参数形式传入的参数和被修改的外部对象。
步骤2:观察失败的测试
测试准备好之后就可以运行了。有很多原因促使我们在编写实现代码之前运行测试,最主要的一点是我们可以用它来确定代码的状态。在写测试的时候,我们会有一个比较明确的期望,测试如何会失败。单元测试虽然不存在逻辑的分支,代码也比较简单,但他也像其他代码一样存在bug。但是运行它,比较期望结果,我们会很快发现这些bug。
理想情况下,当我们添加了新的测试的时候,我们应该可以运行所有测试用例。这样我们就可以很容易的抓取到干扰测试,例如一个测试依赖于另外一个测试。
编写实现代码之前运行测试,可以告诉我们一些新的事情。有时候会有这样的经历,在我们写任何实现代码之前测试可以正常通过。一般情况下,这样的事情不应该发生,TDD教导我们编写不能通过的测试。因为我们是先写测试代码,功能代码还没有开发,这时测试代码能跑通就说明存在问题。我们需要确定问题的来源,是不是运行环境已经提供了该方法的实现,或者我们有没有必要保留这条测试用例。
步骤3:确保测试通过
准备好运行失败的测试代码之后,我们要做的就是编写实现代码,并保证测试代码可以运行通过。有时候我们甚至需要硬编码,不必担心在这个步骤中我们的代码是多么糟糕,在后面的重构环节我们可以优化他。编写实现代码的时候,我们要寻找最明显的简洁实现方式,如果没有我们可以伪造他,而把具体的实现拖延到后面。
1.你不需要他
在极限编程中,TDD的精髓是“你不需要他”,意思是直到需要的时候才需要添加相关功能。基于假设添加一些以后可能会用到的代码,会让我们的代码变得很膨胀。对于动态语言,特别是javascript,违反这一原则有时候对我们是有诱惑的,他可以增加代码的灵活性。一个例子是,为函数添加过多的参数。除非有那样的需求,否则不要这样做。
2.通过String.prototype.trim测试
下面的代码是为满足之前的测试开发的。
String.prototype.trim = function () { return this.replace(/^\s+/, ""); };
可以看到,这个实现还不完善,只去除了左空格。但是TDD就是这样的,每一步都很小,只要能让测试通过就可。发现新的需求点后,编写测试代码,然后完善实现代码并通过测试。
3.能够工作的最简单的方案
最简单的解决方案有时候意味着,可能要往产品中添加硬编码的代码。因为有时候一般的解决方案可能不是那么明显,我们可以用硬编码的方式推进我们的项目,等到有了解决方案的时候再替换。虽然硬编码可以推进我们的项目,代码质量是我们的最终目标。
步骤4:移除重复的重构
最后,最重要也是最有趣的工作就是使代码变得整洁。当实现代码开发完毕,测试顺利跑通之后,我们就可以考虑重构的工作了,把一些重复的代码移除。这期间只有一条准则:测试必须能跑通。关于重构好的建议是,每次只对一个操作进行修改,并保证测试能够通过。重构是对已有代码的维护修改,所以测试不能失败。
重复的代码可能会出现在不同位置,有时候他是为了解决硬编码的解决方案。如果我们有一个硬编码的假冒响应,我们需要给他添加另外的测试,让他在不同输入的条件下失败。或许我们一时还想不到替换硬编码的方案,但至少我们知道问题的存在,他为我们提供了足够的信息,方便我们找到最终解决方案。
重复的代码同样可能存在于测试代码中,例如setup中的请求对象和假冒的依赖。测试代码也是代码,同样需要维护,移除重复内容。如果测试代码和系统过于耦合,我们需要抽取帮助方法和对结构进行重构。setup和teardown可以用来集中设置对象的创建和销毁。
我们在重构的过程中不能让测试失败。如果在重构的过程中我们没有用更少的代码完成工作,我们就需要考虑把工作托到以后再做。
步骤5:重复工作
一旦所有工作都完成了,没有重复代码了,也没有重构工作需要做了,这时候就从todo列表中找一个新任务,重复上面的步骤。根据需要重复这样的工作。你熟悉了这一过程之后,可以放大脚步,但是确保你的周期很短,这样可以得到及时的反馈。
功能满足需求之后,我们可以考虑提高测试的覆盖率,可以添加对边界值的测试、对依赖项的测试、不同输入类型的测试、不同组件之间的整合测试等。下面是我们为String.prototype.trim添加的第二条测试:
"test trim should remove trailing white-space": function () { assert("should remove trailing white-space", "a string" === "a string ".trim()); }