1 测试驱动开发模式
1.1 重新定义“测试”
这是一张影响图:
- 普通箭头表示当第一个节点增长时,第二个节点也会做相应的增长。
- 带圆圈的箭头表示当第一个节点增长时,第二个节点也会做相应的减少。
当压力越大时,所做的测试就会越少。测试越少,犯的错就会越多,就会感到更大的压力。这是一个会造成情境越来越糟的循环。
我们用事先编写的测试来驱动开发,因为测试先于开发,所以我们在感到压力时,就运行这些测试,它们会马上给我们一种系统良好的感觉,而且会减少开发出错的次数,进而减少我们的压力,从而跳出上面的循环。
1.2 测试的要求
- 让测试尽可能快地运行。
- 尽量在小范围内进行。
- 测试之间互不干扰,这意味着所有的测试都是不依赖于顺序的。这样就不会因为测试顺序的不同而出现的许多古怪问题。这对开发人员提出了更高的要求,因为必须把问题分解为彼此正交的小问题,这样做的好处是,每个测试会变得更加简单而且可以快速运行,对象也会变成漂亮的“高内聚、低耦合”的类对象。
1.3 测试列表
写一张包含所有要编写的测试清单,可以记录在一张纸上。这张表记录的就是我们要去实现的测试。
1.4 测试优先
在编写要被测试的代码之前也编写测试代码。
1.5 断言优先
从测试完成时能够通过的断言(形如 assertEquals()
)开始编写测试代码。
1.6 测试数据
使用让人容易理解的数据,记住,你写的代码以后会有人阅读、有人维护的!
在以下的情况下,使用真实世界中的数据很有效:
- 将目前系统的输出与以前系统的数据进行比对时。
- 对旧系统进行重构而期望在完成时得到完全相同的结果时。
1.7 可阅读的数据
让测试包含预期和实际的结果,努力让它们容易理解,因为还要让其他人阅读的,所以要留下尽可能多的线索。
1.8 学习测试
使用一个公开的 API 接口或者包时,可以编写一个测试来验证这个 API 工作是否符合我们的期望。这样做有两点好处:
* 可以让我们更好地理解它。
* 如果测试无法运行,可能是使用的问题,也可能是 API 本身的问题,测试会让问题更快地暴露出来。
1.9 休息
当你感觉到累的时候,休息一下。适当的放松会让大脑的思维得到解放。
2 测试代码的编码模式
2.1 子测试
保持不可运行-》可运行-》重构的节奏对可持续的成功非常重要。付出额外的努力来保持这种节奏是值得的。当之前的测试代码突然要求几处变化时,可能的原因是我们之前所写的测试代码太大了,这就需要重构把这些测试代码,尝试把它变成几个子测试代码咯。
2.2 模拟对象
如果一个测试依赖于昂贵而且复制的资源对象,那么就创建一个这些资源的模拟对象。典型的例子是数据库,建立它很耗时间,而且它也是开发过程中错误产生的温床。
解决办法是在大多数时间里不使用真正的数据库,而是写一个像数据库一样的对象,或者采用一个第三方开源的数据库模拟 API,让它仅仅驻留在内存中。这样做,不仅带来了高效的性能,而且还很可靠,可读性也很好。
如果担心模拟对象与现实对象的行为不一致,那么可以使用对实际对象适用的一系列测试来测试模拟对象,从而减少这种风险。
2.3 清理测试死角
抛出一个异常对象来测试代码中处理错误的逻辑。因为我们的安全假设是,没有被测试过的代码是不会正常工作的,处理错误的逻辑也是代码的一部分,所以我们也要进行测试。
假设要测试当文件系统满了以后,系统会发生什么。可以花费很多时间创建许多大文件来填满整个文件系统,这种方法费时费力。另一种方法是采用伪实现,即抛出一个异常:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
2.4 提交代码前确保所有的测试都运行通过
如果在试图提交时,发现有许多测试没有通过。最可能的原因是你可能对刚刚编码的东西没有完全理解,因此最简单的办法就是把之前做过的工作推倒重来。
因为许多测试没有通过,注释掉一些测试代码是严格禁止的,这是一种不负责任的、偷懒的表现。
3 可运行模式
3.1 伪实现
测试不能通过的时候,先返回一个常量,让测试运行通过。然后通过重构把这个常量逐步转换为用变量表示的表达式。伪实现就像登山时在头顶上方钉一个登山用的钢锥,它会让你安心,让你觉得继续爬上去是安全的。
使用伪实现有两个主要原因:
- 当状态是可运行的时候,我们就能够从这里充满自信地开始重构。
- 从一个具体的测试用例开始,聚焦于一点,避免其他干扰,我们就能更好地解决当下问题。
3.2 三角法
三角法的含义是:只有当我们有有了两个或两个以上的测试用例时,才能对功能进行抽象。
只有在不能确定是否需要对要实现的功能是否已经进行了正确的抽象时,才使用这个方法。
3.3 显明实现
显明实现含义是:如果我们知道要些什么,并且能够很快地完成,那就直接实现吧。
显明实现只是第二选择。因为你在追求自身的完美,而这是不可能的!所以记住时刻保持不可运行-》可运行-》重构的编码习惯!
4 xUnit 测试框架模式
4.1 断言
写一个布尔表达式传递给一个断言,来判断我们的工作是否运行正常。形如assertEquals(...)
在 JUnit 中,可以为断言传递更详细的信息,形如 assertTrue("测试用例 1",false)
。
4.2 初始化固定设施(脚手架)
如果几个测试都存在一些通用对象,这种重复是不好的,因为:
- 它需要花费一些时间去编写(复制、粘贴),而我们是期望能够实现快速编写。
- 如果要修改对这个通用对象进行修改,那么这些测试中的相应的代码都需要修改。这时一种重复工作,应该避免。
所以我们把几个测试都需要的通用对象,转变为实例变量,然后在 setUp()
中初始化这些对象。
4.3 释放固定设施(脚手架)
使用 tearDown
来释放资源,这样能避免重复,原因和初始化固定设施是一样的!
不论测试代码中发生了什么,甚至是抛出一个异常,xUnit 也能保证最后调用 tearDown
。
4.4 异常测试
我们需要测试期望的异常,只有捕获到这些异常,异常测试才算通过。
我们只关心捕获我们所期望的异常,因此,如果抛出了一个非期望的异常,我们也会从 xUnit 框架中得到通知。
4.5 全部测试
可以把所有的测试合成一个测试套件,每个包一个套件,一次性执行所有的测试。
5 实践
5.1 实践的步伐
一般经过一段时间的适应,开发人员都会倾向于采用小步骤进行开发。可以利用 IDE 的自动重构功能来提高重构的速度。
5.2 测试的内容
应该测试这些东西:
- 条件部分
- 循环部分
- 操作部分
- 多态性
记住,只测试我们编写的代码,除非有足够的理由,否则不要测试其他来源的代码。
5.3 设计存在的缺陷
这些是设计存在的缺陷的特征:
- 过长的 setUp 代码:一个简单的断言,需要花费上百行的代码创建对象。肯定是对象太大了,需要分割。
- 冗余的 setUp 代码:无法为这些相同的公共设施的代码找一个存放它的一个统一的地方,这说明有太多的对象紧密地联系在一起咯。
- 过长的测试运行时间。
- 意外中断的测试:说明测试的一部分对另一部分产生了影响,这是一个脆弱的测试。我们需要修改设计,要么打破联系,要么合并它们,消除这种影响。
5.4 编写测试的数量
- 通过自己的经验和判断来决定要编写多少个测试。
- 一个测试是否值得编写,取决于对功能的平均无故障时间的理解。如果一个功能可能用上 100 年,那么针对那些极不可能发生条件和条件组合编写测试就是一件有意义的事。
- 注重实效,我们编写测试是为了充满自信地编写代码。
5.5 删除测试
如果存在两个测试互为冗余,根据以下原则进行删除:
- 如果删除一个测试降低了我们对于整个系统的信心,那么就保留它。
- 如果这两个测试对用户来说,是两种不同的情形,那么就保留它。
- 如果上述两种情况都不存在,那么就删除其中用处最少的那个。
5.6 开发大型系统案例
LifeWare 的一个跟保险有关项目就是采用 TDD 进行开发的。共历时 4 年,有 40 人参与了这个项目。编写了大约 500,000 行代码(其中有一半是测试代码),有 4000 个能够在 20 分钟内能够执行完毕的测试。所以现在有信心了吧 O(∩_∩)O~
5.7 项目中途采用 TDD
- 这些代码是在没有考虑测试的情况下写出来的,所以它们的可测试性不强。所以我们必须严格限定修改的范围,一步一步来。
- 如果发现系统中的某一部分可以显著地进行简化,但目前还没有这样的要求时,就不要动它。
5.8 TDD 有效性
- 我们越早发现并处理一个错误,我们所付出的成本就越低。
- 很多应用了 TDD 的团队,开发人员的精神变得更加放松,团队成员之间都提高了信任度,用户也开始期待新的项目版本咯。
- TDD 缩短了设计决策的反馈循环。