`

从一个小例子学习TDD

 
阅读更多
来源 http://it.taocms.org/11/5959.htm

摘要: 示例的需求描述 今天我们需要完成的需求是这样的: 对于一个给定的字符串,如果其中元音字母数目在整个字符串中的比例超过了30%,则将该元音字母替换成字符串mommy,额外的,在替换时,如果有连续的元音出现,则仅替换一次。 如果用实例化需求(Specif...

示例的需求描述
今天我们需要完成的需求是这样的:

对于一个给定的字符串,如果其中元音字母数目在整个字符串中的比例超过了30%,则将该元音字母替换成字符串mommy,额外的,在替换时,如果有连续的元音出现,则仅替换一次。

如果用实例化需求(Specification by Example)的方式来描述的话,需求可以转换成这样几条实例:

hmm经过处理之后,应该保持原状
she经过处理之后,应该被替换为shmommy
hear经过处理之后,应该被替换为hmommyr
当然,也可以加入一些边界值的检测,比如包含数字,大小写混杂的场景来验证,不过我们暂时可以将这些场景抛开,而仅仅关注与TDD本身。

为什么选择这个奇怪的例子
我记得在学校的时候,最害怕看到的就是书上举的各种离生活很远的例子,比如国外的书籍经常举汽车的例子,有引擎,有面板,但是作为一个只是能看到街上跑的车的穷学生,实际无法理解其中的关联关系。

其实,另外一种令人不那么舒服的例子是那种纯粹为了示例而编写的例子,现实世界中可能永远都不可能见到这样的代码,比如我们今天用到的例子。

当然,这种纯粹的例子也有其存在的价值:在脱离开复杂的细节之后,尽量的让读者专注于某个方面,从而达到对某方面练习的目的。因为跟现实完全相关的例子往往会变得复杂,很容易让读者转而去考虑复杂性本身,而忽略了对实践/练习的思考。

TDD步骤
通常的描述中,TDD有三个步骤:

先编写一个测试,由于此时没有任何实现,因此测试会失败
编写实现,以最快,最简单的方式,此时测试会通过
查看实现/测试,有没有改进的余地,如果有的话就用重构的方式来优化,并在重构之后保证测试通过
tdd

它的好处显而易见:

时时关注于实现功能,这样不会跑偏
每个功能都有测试覆盖,一旦改错,就会有测试失败
重构时更有信心,不用怕破坏掉已有的功能
测试即文档,而且是不会过期的文档,因为一旦实现变化,相关测试就会失败
使用TDD,一个重要的实践是测试先行。其实在编写任何测试之前,更重要的一个步骤是任务分解(Tasking)。只有当任务分解到恰当的粒度,整个过程才可能变得比较顺畅。

回到我们的例子,我们在知道整个需求的前提下,如何进行任务分解呢?作为实现优先的程序员,很可能会考虑诸如空字符串,元音比例是否到达30%等功能。这当然没有孰是孰非的问题,不过当需求本身就很复杂的情况下,这种直接面向实现的方式可能会导致越走越偏,考虑的越来越复杂,而耗费了几个小时的设计之后发现没有任何的实际进度。

如果是采用TDD的方式,下面的方式是一种可能的任务分解:

输入一个非元音字符,并预期返回字符本身
输入一个元音,并预期返回mommy
输入一个元音超过30%的字符串,并预期元音被替换
输入一个元音超过30%,并且存在连续元音的字符串,并预期只被替换一次
当然,这个任务分解可能并不是最好的,但是是一个比较清晰的分解。

实践
第一个任务
在本文中,我们将使用JavaScript来完成该功能的编写,测试框架采用了Jasmine,这里有一个模板项目,使用它你可以快速的启动,并跟着本教程一起实践。

根据任务分解,我们编写的第一个测试是:

1
2
3
4
5
6
7
8
9
describe("mommify", function() {
  it("should return h when given h", function() {
      var expected = "h";

      var result = mommify("h");

      expect(result).toEqual(expected);
  });
});
这个测试中有三行代码,这也是一般测试的标准写法,简称3A:

组织数据(Arrange)
执行需要被测的函数(Action)
验证结果(Assertion)
运行这个测试,此时由于还没有实现代码,因此Jasmine会报告失败。接下来我们用最快速的方法来编写实现,就目前来看,最简单的方式就是:

1
2
3
function mommify() {
  return "h";
}
可能有人觉得这种实现太过狡猾,但是从TDD的角度来说,它确实能够令测试通过。这时候,我们需要编写另外一个测试来驱动出正确的行为:

1
2
3
4
5
6
7
it("should return m when given m", function() {
    var expected = "m";

    var result = mommify("m");

    expect(result).toEqual(expected);
});
这样,我们的实现就不能仅仅返回一个”h”了,就现在来看,最简单的方式是输入什么就返回什么:

1
2
3
function mommify(word) {
  return word;
}
很好,这样我们的第一个任务已经完成了!我们已经经历了失败-成功的循环,这时候需要review一下代码,以保证代码是干净的:实现上来说,并没有可以优化的地方,但是我们发现两个测试用例其实测试的是同一件事情,因此可以删掉一个。

是的,测试代码也是代码,我们需要小心的维护它,以保证所有的代码都是干净的。

第二个任务
我们可以开始元音字母的子任务了,很容易想到的一个测试用例为:

1
2
3
4
5
6
7
it("should return mommy when given a", function() {
    var expected = "mommy";

    var result = mommify("a");

    expect(result).toEqual(expected);
});
测试失败之后,能想到的最快速的方式是做一个简单的判断:

1
2
3
4
5
6
function mommify(word) {
  if(word == "a") {
      return "mommy";
    }
  return word;
}
这样测试又会通过,接下来就是重复5个元音的场景,不过使用JavaScript可以很容易的将这5个场景归为一组:

1
2
3
4
5
6
7
8
it("should return mommy when given a vowel", function() {
    var expected = "mommy";

  ["a", "e", "i", "o", "u"].forEach(function(word) {
      var result = mommify(word);
      expect(result).toEqual(expected);
    });
});
而实现则对一个的会变成(记住,用最简单的方式):

1
2
3
4
5
6
function mommify(word) {
  if(word == "a" || word == "e" || word == "i" || word == "o" || word == "u") {
      return "mommy";
    }
  return word;
}
好了,测试通过了。又是进行重构的时间了,现在看看实现,简直不忍卒读,我们使用JavaScript的字符串的indexOf方法可以简化这段代码:

1
2
3
4
5
6
function mommify(word) {
  if("aeiou".indedOf(word) >= 0) {
      return "mommy";
    }
  return word;
}
好多了!我想你现在已经或多或少的体会到了TDD中任务分解的好处了:进度可以掌握,而且目标非常明确,每一步都有相应的产出。

第三个任务
和之前一样,我们还是从测试开始:

1
2
3
4
5
6
it("should mommify if the vowels greater than 30%", function() {
    var expected = "shmommy";
    var result = mommify("she");

    expect(result).toEqual(expected);
});
现在有一点点挑战了,因为我们的实现上一直都是单一的字符串,现在有多个了,不过没有关系,我们先按照最简单的方式来实现就对了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function mommify(word) {
  var count = 0;
  for(var i = 0; i < word.length; i++) {
      if("aeiou".indexOf(word[i]) >= 0) {
          count += 1;
      }
  }

  var str = "";

  if(count/word.length >= 0.30) {
      for(var i = 0; i < word.length; i++) {
          if("aeiou".indexOf(word[i]) >= 0) {
              str += "mommy";
          } else {
              str += word[i];
          }
      }
  } else {
      str = word;
  }

  return str;
}
无论如何,测试通过了,我们首先计算了元音所占的比重,如果超过30%,则替换对应的字符,否则直接返回传入的字符串。

从现在来看,函数mommify中已经有了较多的逻辑,而且有一些重复的判断出现了("aeuio".indedOf),是时候做一些重构了。

首先将相对独立的计算元音比重的部分抽取成一个函数:

1
2
3
4
5
6
7
8
9
10
11
function countVowels(word) {
  var count = 0;

  for(var i = 0; i < word.length; i++) {
      if("aeiou".indexOf(word[i]) >= 0) {
          count += 1;
      }
  }

  return count;
}
然后,将重复的"aeiou".indexOf部分抽取为一个独立函数:

1
2
3
function isVowel(character) {
  return "aeiou".indexOf(character) >= 0;
}
这样本来的代码就被简化成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function mommify(word) {
  var count = countVowels(word);
  var str = "";

  if(count/word.length >= 0.30) {
      for(var i = 0; i < word.length; i++) {
          if(isVowel(word[i])) {
              str += "mommy";
          } else {
              str += word[i];
          }
      }
  } else {
      str = word;
  }

  return str;
}
如果细细读下来,就会发现发现对于元音是否超过30%的判断比较突兀,这里确实了一个业务概念,就是说,此处的if判断并不表意,更好的写法是讲它抽取为一个函数:

1
2
3
4
function shouldBeMommify(word) {
  var count = countVowels(word);
  return count/word.length >= 0.30;
}
并且,替换元音的部分,我们也可以从主函数中挪出来,得到一个小函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
function replace(word) {
  var str = "";

  for(var i = 0; i < word.length; i++) {
      if(isVowel(word[i])) {
          str += "mommy";
      } else {
          str += word[i];
      }
  }

  return str;
}
这样,主函数得到了进一步的简化:

1
2
3
4
5
6
7
function mommify(word) {
  if(shouldBeMommify(word)) {
      return replace(word);
  } else {
      return word;
  }
}
太好了,现在mommify就更加清晰了,并且每个抽取出来的函数都有了更具意义的名字,更清晰的职责。

第四个任务
经过了第三步,相信你已经对如何进行TDD有了很好的认识,而且也更有信心进行下一个任务了。同样,我们需要先编写测试用例:

1
2
3
4
5
6
it("should not mommify if there are vowels sequences", function() {
    var expected = "shmommyr";
    var result = mommify("shear");

    expect(result).toEqual(expected);
});
现在的问题关键是需要判断一个字符串中的前一个是否元音,由于我们之前已经做了足够的重构,现在需要修改的函数就变成了replace子函数,而不是主入口mommify了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function replace(word) {
  var str = "";

  for(var i = 0; i < word.length; i++) {
      if(isVowel(word[i])) {
          if(!isVowel(word[i-1])) {
              str += "mommy";
          } else {
              str += "";
          }
      } else {
          str += word[i];
      }
  }

  return str;
}
测试通过之后,我们可以大胆的进行重构,抽取新的函数next:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function next(current, previous) {
  var next = "";

  if(isVowel(current)) {
      if(!isVowel(previous)) {
          next = "mommy";
      }
  } else {
      next = current;
  }

  return next;
}

function replace(word) {
  var str = "";

  for(var i = 0; i < word.length; i++) {
      str += next(word[i], word[i-1]);
  }

  return str;
}
最后,如果你想看完整的/最新的代码,可以在github上找到。

结束(?)
重构是一个永无止境的实践,你可以不断的抽取,简化,重组。比如上例中对于常量的使用,对于JavaScript中的for的使用等,都可以更进一步。但是你需要权衡,适可而止,如果不小心做的太过,则可能引起过渡设计:引入太过的概念,过于简化的接口等。

TDD是一种容易付诸实践的开发方式,在小的,简单的例子上如此,在大的,复杂的场景下也是如此。它优美且高效的地方在于:不假设任何人可以一次就写出完善的应用,而是鼓励小步前进,快速反馈,快速迭代。而演化到最后,得到的往往就是孜孜以求的优美设计。
分享到:
评论

相关推荐

    tddworkshop:学习 TDD 的教学应用

    TDD工作坊 学习 TDD 的教学应用。动机很难理解测试驱动开发的重要性并将其作为工作流程的一个组成部分灌输到您的工作流程中。只有亲自实践并体验测试在面对大量开发问题时避免问题的事实,您才能了解测试驱动开发的...

    array-tdd:使用数组学习 TDD

    第一个(数组,[n]) 参数数组(Array):要查询的...必须有一个名为 first() 的函数 first() 函数必须返回第一个参数(数组)的第一个元素 first() 函数必须返回一个由第一个参数(数组)的前 n 个元素填充的新数组

    测试驱动开发的艺术.pdf (非扫描版本)

    本书作者作为软件开发人员,希望降低其他人学习TDD的难度。鉴于刚接触TDD的开发人员最头疼的是一些技术性问题,本书主要采用了“手把手”的教学方式。不仅会通过动手做例子来解释TDD,而且还将用几章的篇幅专门讲解...

    Mommify_TDD_TW:TW TDD 课程示例

    A sample for TDD learning通过一个字符串替换的小例子,学习TDD的基本过程需求对于一个给定的字符串,如果其中元音字母数目超过整个字符串字符数目的30%,则将该元音字母替换为字符串mommy;另外,如果有连续的元音...

    测试驱动开发 by Example

    你可以找一个周末的下午,一边看,一边照做,一个下午就把书看完,这本书的所有例子跑完了。这本书的作用是通过实战让你培养TDD的思路。Very Tiny的一本书,看起来很带劲,通俗易懂。是你学习TDD的简易途径。

    彩虹岛Java源码-tyno-lrs:TypeScriptNodeJS学习记录存储

    这个项目提供了一个很好的例子,说明如何在 TDD 开发周期中将 TypeScript 与 NodeJS & Friends (Express) 一起使用。 它包含一个服务器应用程序,它演示了使用 TypeScript 开发 API 的最佳实践。 这意味着具有导入和...

    learn-tape:了解如何将磁带用于JavaScriptNode.js测试驱动开发(TDD)-十分钟测试教程

    如果您希望使用更扩展的“真实世界”示例应用程序,请参见: 我们强烈建议您先在这里学习基础知识,然后再研究更大的例子。 一旦您熟悉了Tape / Tap语法,便有了明确的“下一步”。 :memo: :check_mark_button:为...

    领域驱动设计与模式实战

    1.5.1 有关何时需要运行机制的一个例子 1.5.2 运行机制的一些例子 1.5.3 它不仅仅是我们的过错 1.6 小结 第2章 模式起步 2.1 模式概述 2.1.1 为什么要学习模式 2.1.2 在模式方面要注意哪些事情 2.2 设计模式 2.3 ...

    走向ASP.NET架构设计---第二章:设计&测试&代码

    前言:本篇之所以选择TDD作为例子,主要是由两个原因:1.TDD确实呈现了设计的思路;2.相对于DDD来说,TDD更加容易上手,学习的曲线没有那么陡峭。 再次申明一下:本系列不是讲述TDD的,只是用TDD来建立设计的思想。...

    MVC5+EF6之巧租房系统

    项目采用的是b/s模式的架构,包括一个后台管理和一个前端的可以自适应于手机端的页面,项目全程采用TDD开发模式,用到如下的技术:   前端技术:前端MVC引擎(artTemplate)、HUI、MUI(手机端自适应)、ValidForm、...

    es6-pluralsight:使用Pluralsight学习ES6

    使用Pluralsight学习ES6 ES6的JavaScript基础知识中用ES6编写的注释和代码示例 设置 该项目设置为使用Babel,JSPM和SystemJS,以及Karma和Gulp来运行测试。 要运行代码示例,请克隆此仓库,然后将其cd进入项目...

    journey-towards-property-based-testing:学习基于属性的测试的练习和资源。 非常欢迎评论和参与。

    问题的关键是探索基于测试从几年例如基于测试的到来财产,并学会写属性,而不是例子,以了解其对“主流TDD”的影响。 对于“主流 TDD”,我指的是带有基于示例的测试的 TDD,对我而言,这两件事(TDD 和基于示例的...

    软件测试系列培训之单元测试培训幻灯片2

    主要内容: 背景 感性认识 基本理论 核心内容 三个例子分析 CppUnit源码解读 大部分资料来源于网上学习,感谢那些被我参考过的朋友^_^

    express-boillerplate-restfullapi:使用expressjs和mongodb构建的illererapi

    粗俗的例子 错误处理 数据库不使用sql mongodb mongoose 用摩卡和柴测试 覆盖范围使用istanbull 与码头工人的例子 验证,使用Express-Valdator 分页示例 自定义消息API响应 埃斯林特爱彼迎基地 单元测试 怎么跑 ...

Global site tag (gtag.js) - Google Analytics