`

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

相关推荐

    TDD相关测试源代码,学习测试的好资源

    首先,编写一个失败的测试(红色),然后编写刚好使测试通过的最小量的生产代码(绿色),最后对代码进行重构以保持简洁和可读性。 2. **最小化生产代码**:在TDD中,目标是仅写出完成当前测试所需的功能,避免过度...

    学习了spring.net后写的小例子

    总结来说,这个"学习了spring.net后写的小例子"展示了作者如何运用Spring.NET的关键特性,如依赖注入、面向切面编程以及数据访问和事务管理,来构建一个Winform应用。通过这样的实践,作者不仅加深了对Spring.NET的...

    TDD-learn-demo1

    在这个名为"TDD-learn-demo1"的项目中,我们看到的是一个学习TDD的具体实例,特别是针对`ProtoStuffUtil`类的测试。 `ProtoStuffUtil`类可能是一个处理序列化和反序列化任务的工具类,它可能与Google的Protocol ...

    oop_tdd:使用Mocha的OOP和TDD的一个小例子

    "oop_tdd:使用Mocha的OOP和TDD的一个小例子" 这个标题表明,这是一个关于面向对象编程(Object-Oriented Programming, OOP)和测试驱动开发(Test-Driven Development, TDD)的示例项目,其中使用了Mocha作为测试...

    C#.net类图一些编程的小例子,你可以使用它们来开发很多的软件

    3. "ConsoleApplication1":这是一个典型的C#控制台应用程序项目文件名,通常用于初学者学习基础编程概念。开发者可能会在这里找到如何创建、运行和调试C#程序的基本示例,包括输入/输出、控制流程语句(如if-else、...

    spring-tdd:一个简单的Spring Boot应用程序,学习如何编写TDD应用程序

    还有一个附加的枚举-等级,这是Firefighter的属性。该项目的主要目标是根据TDD方法(特别是BDD)(给定/何时/然后)进行构建。我还尝试编写三种不同类型的测试-单元测试,集成测试,端到端测试。核心项目是用Spring...

    一个小项目的cunit单元测试例子

    **单元测试**是软件开发过程中不可或缺的一个环节,它主要用于验证程序中的...总结来说,通过这个"一个小项目的cunit单元测试例子",我们可以深入理解和实践CUnit在C语言项目中的应用,从而提高软件的质量和可靠性。

    .net mvc小例子

    在本案例中,我们聚焦于".NET MVC小例子",这是一个适合初学者快速上手的教程,通过简单的项目实践帮助学习者理解MVC3.0的基本概念和工作流程。 1. **MVC模式介绍** Model-View-Controller模式是一种软件设计模式...

    RubyOnRails的一个入门小例子

    标题 "RubyOnRails的一个入门小例子" 暗示了我们将探讨的是关于Ruby on Rails框架的基础知识,这是一个用于构建Web应用程序的开源工具。Ruby on Rails(简称Rails)是基于Ruby编程语言的,它遵循MVC(Model-View-...

    NUnitForms2.0,另带了个小例子

    在“NUnitForms2.0,另带了个小例子”这个标题中,我们可以推断出这是一个包含NUnitForms2.0库的示例项目,可能是一个压缩包,其中包含了使用NUnitForms2.0进行UI测试的代码和相关资源。这个小例子名为"MyFormsTest...

    tddworkshop:学习 TDD 的教学应用

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

    ruby+selenium-webdriver测试--第一个例子源代码

    在这个“ruby+selenium-webdriver测试--第一个例子源代码”中,我们将探讨如何使用Ruby和Selenium-Webdriver实现自动化测试的初步步骤。 首先,我们需要安装必要的库。确保已经安装了Ruby,并通过RubyGems来安装...

    单元测试 很小的例子,大家可以学习学习!

    在这个"单元测试 很小的例子"中,我们可以预想这是一个针对初学者设计的教学资源,旨在帮助他们理解和掌握单元测试的基本概念和实践。下面我们将深入探讨单元测试的相关知识点: 1. **单元测试的目的**:单元测试的...

    tdd:测试驱动开发的例子 作者:Kent Beck

    - **保持测试小而专注**:每个测试用例应针对一个特定的功能点,避免大而全的测试。 - **避免测试逻辑**:测试用例应专注于验证代码的行为,而不是实现具体的业务逻辑。 - **隔离测试**:确保每个测试用例独立运行,...

    测试驱动开发源代码 java的例子

    测试驱动开发(Test-Driven Development,简称TDD)是一种软件开发方法,强调在编写实际功能代码之前先编写测试用例。这种方法有助于确保代码的质量,因为每个功能都必须...这将是一个学习TDD和Java编程实践的好资源。

    array-tdd:使用数组学习 TDD

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

    newstore:火星探测器TDD kata进入

    总之,"newstore:火星探测器TDD kata进入"是一个使用Golang进行TDD的实例,它提供了学习和实践的机会。通过参与这个kata,你可以提升在Golang中的TDD技能,更好地理解如何利用测试来指导你的编码过程,同时增强代码...

    tsfcase例子源码

    【TSFcase例子源码】是一个与编程相关的资源,主要用于展示如何使用TSFcase这一特定的框架或库。TSFcase可能是一个测试框架或者一种设计模式的实例,它被设计用于VC2005(Visual Studio 2005)环境,并且在该环境中...

    tdd-by-example:受肯特贝克经典启发的一些例子

    首先,开发者编写一个失败的测试("红"),然后编写最小的可能代码使测试通过("绿"),最后对代码进行重构以改善设计,同时保持所有测试继续通过。这种方法旨在提高代码的可读性、可维护性和降低缺陷率。 描述中...

Global site tag (gtag.js) - Google Analytics