测试驱动开发 -TDD

测试驱动开发TDD-Test Driven Development是敏捷开发中的一种实践开发模式,简单来说即:测试代码优先于产品代码编写,通过测试不断驱动编写完善的产品代码,实现所需的功能。
测试驱动开发与通常开发模式在角度和思考方式上都有着本质的区别,并且刚接触TDD会让人难以接受。
在通常模式下,在拿到一个需求后,经过简单的思考设计(通常是纸上画符)就直接进入功能实现的开发。在开发完成后再编写测试用例并运行测试,修复bug让代码能工作。而测试驱动开发则正好相反,在拿到需求后开始写的第一个代码时测试用例。本质上是通过测试用例来描述需求,并编写产品代码使得测试通过。

一、 TDD原则

测试驱动开发所遵循的三个基本原则如下:

  1. 除非能让失败的单元测试通过,否则不允许去编写任何的产品代码。
    对于任何功能需求,都是先从写测试用例入手,为满足测试用例才能去写产品代码。

  2. 只允许编写刚好能够导致失败的单元测试。 (编译失败也属于一种失败)
    通常在开发完成后写的测试用例都是希望能通过的测试用例,很可能因先入为主导致不能正确覆盖测试。相反,TDD编写新的测试用例是为了覆盖不同的需求,导致失败。

  3. 只允许编写刚好能够使一个失败的单元测试通过的产品代码。
    编写的生产代码只能是为了使一个失败的单元测试通过,不应编写多余的实现代码。如果过多编写了实现其他功能业务的代码,则违反了TDD的原则。

测试驱动开发的基本流程

  • 编写单元测试 --> 运行单元测试-失败
  • 编写生产代码 --> 运行单元测试
  • 重构代码 -->运行单元测试保证通过
    从流程还可以看出,测试驱动开发将持续的重构纳入流程之重,并通过测试保证了重构的正确性。因为单元测试运行失败为红色,通过为绿色,因此TDD流程也可以简单描述为:
    红、绿、重构。

二、 TDD实践

下面通过经典的Bob大叔关于保龄球计分的例子来进行说明,如何来实践TDD。
首先简单介绍下保龄球积分规则:

  • 每一局总共有十轮,每轮一开始会有十支球瓶,球手可以扔两次球,目标就是用尽量少的球把全部球瓶击倒。
  • 如果第一球就把全部的球瓶都击倒了,也就是STRIKE-全中,就算完成一轮了,本轮分数是10分再加奖励(bonus):即后面两球的倒瓶数,
  • 如果第一球没有全倒,就要再打一球,如果第二球将剩下的球瓶全都击倒,也就是SPARE-补中,也算完成一轮,本轮分数为10分再加奖励(bonus)即:下一球的倒瓶数,
  • 如果第二球也没有把球瓶全部击倒的话,那分数就是第一球加第二球倒的瓶数,没有奖励(bonus),再接着打下一轮。依此类推。
  • 如果在第十轮出现STRIKE或者SPARE,则球手可再加打第三球,最多只能打三球。
  • 全部十轮的得分相加就等于这一局的总得分。

我们的需求是
提供一个Game类,并且包含2个方法

  • roll(int pins):用于玩家每次扔球后击倒的瓶子数,参数即为击倒的瓶子数
  • score():每局结束后调用计算这一局的总分数。

根据TDD的原则,拿到需求后我们第一反应不是去设计并实现上述规则,而是应当先编写测试用例。

1. 第一个测试用例

先编写一个最简单的测试用例,即每次投球都没有击倒瓶子,很明显最终得分应该是0。


测试驱动开发 -TDD_第1张图片
image.png

很明显因为Game类不存在,测试是不会通过的,因此我们需要去创建Game类使测试通过(在这里不应当写更多实现代码)。


测试驱动开发 -TDD_第2张图片
image.png

接下来,我们增加投球的代码,假如每次都没击中,即倒瓶数都是0:因为roll方法不存在,因此需要我们在Game类中增加roll方法(此时不用关心具体的实现)。


测试驱动开发 -TDD_第3张图片
image.png

同样的增加一局结束后算分方法(同样不关心实现),此时测试不通过。


测试驱动开发 -TDD_第4张图片
image.png

需要修改产品代码使测试通过,此时我们不考虑具体实现,简单返回一个0使测试通过。


测试驱动开发 -TDD_第5张图片
image.png

2. 第二个测试用例

第一个测试用例仅为每轮都没击倒瓶子的情况,下面我们编写测试用例每次投球都只击倒1个瓶子。即没有全中也没有补中,最终得分应该是20。


测试驱动开发 -TDD_第6张图片
image.png

编写测试用例并运行,很显然测试不会通过,我们需要修改产品代码。很明显最简单的实现是需要我们累加每次击球后的分数。通过一个成员变量score记录分值并累加,测试通过。


测试驱动开发 -TDD_第7张图片
image.png

同时我们发现测试用例中的循环投球代码出现了重复,我们可以进行代码的提取重构。我们将投球代码提取为独立的方法给测试用例调用。并再次运行测试,保证测试通过。


测试驱动开发 -TDD_第8张图片
image.png

3. 第三个测试用例

现在我们来考虑下出现补中SPARE的情况,并编写对应的测试用例。为了简单,我们先只考虑出现一次全中:第一轮投球为5,5;第三球为3,其他全是没中-0。这样一局得分应该是:第一轮10分+(奖励-第三球得分+3)+第二轮得分3+其他得分0 =16。运行测试用例-失败。


测试驱动开发 -TDD_第9张图片
image.png

我们需要修改产品代码满足出现补中的情况。这时我们发现代码上有些问题:roll方法本来应当用来记录每次击倒的瓶子数,实际却用来计算分数,而score方法本来应用来计算分数,但却没有实现,代码需要重构。
这里我们回退一步,回到第二个测试用例通过的情况,并对代码进行重构。
因为要记录每次击倒的瓶子数,所以我们引入一个整型数组,并通过一个索引来指示当前是第几球。在roll方法中对击倒数进行记录。在score方法中根据击倒数进行分值计算。通过测试用例保证我们的重构是正确的。


image.png

现在我们回到第三个测试用例,它依然是未通过状态。因为补中为2球击倒10球,因此在计算得分时需要按照2球一轮来进行考虑,同时来处理补中的情况。修改产品代码使测试用例通过。


测试驱动开发 -TDD_第10张图片
image.png

现在来会看我们的代码,可以发现为了解释代码我们加了一些注释(spare),说明代码不能自解释。同时我们代码中有随意定义的变量i和很长的条件语句,我们对代码先进行一些重构,提取方法,并修改变量名为有意义的名字。测试用例保证了我们的修改不会破坏当前代码。


测试驱动开发 -TDD_第11张图片
image.png

进入下一个红、绿、重构循环,完成全中STRIKE的支持和代码优化

三、 总结

上面就是一个TDD的实践过程,从中我们可以看出这样几点:

  1. 单元测试保证了编码过程中持续重构的正确。在完成代码的编写后,我们会得到很多的测试代码,而这些测试代码可以用来保证对代码后续的维护修改的正确性。单元测试对代码的覆盖率同样可以增强我们对代码的信心。
  2. 丰富的单元测试完全可以替代接口文档,更能清楚的描述代码实现的功能。
  3. TDD的开发模式促使了代码的抽取和解耦,利于代码复用。

TDD在现实中的情况

  1. 在实际情况中真正应用TDD模式来进行开发的公司和团队少之又少,具体分析大概有以下原因:
  2. 公司或团队没有推动支持。部分公司和团队甚至不能很好的推行单元测试,TDD更无从谈起。另外虽然TDD能保证产出更高质量的代码,但会被认为可能需要耗费更多时间。
  3. TDD模式跟通常的思维习惯相反,让开发人员很难接受。
  4. 某些项目运行单元测试耗时太长。即使有很多辅助工具的使用,在一个复杂项目里运行单元测试仍然不是一个随手操作的事情。

参考文档:
Bobo大叔保龄球例子 可以下载讲解PPT
上一个更详细的文章

你可能感兴趣的:(测试驱动开发 -TDD)