测试驱动开发(TDD)入门

测试驱动开发,英文全称 Test-Driven Development(简称 TDD),是由Kent Beck 先生在极限编程(XP)中倡导的开发方法。以其倡导先写测试程序,然后编码实现其功能得名。

本文不打算扯过多的理论,而是通过一个操练的方式,带着大家去操练一下,让同学们切身感受一下 TDD,究竟是怎么玩的。开始之前先说一下 TDD 的基本步骤。

TDD 的步骤

TDD 步骤
  1. 写一个失败的测试
  2. 写一个刚好让测试通过的代码
  3. 重构上面的代码

简单设计原则

重构可以遵循简单设计原则:

简单设计原则

简单设计原则,优先级从上至下降低,也就是说 「通过测试」的优先级最高,其次是代码能够「揭示意图」和「没有重复」,「最少元素」则是让我们使用最少的代码完成这个功能。

操练

Balanced Parentheses 是我在 cyber-dojo 上最喜欢的一道练习题之一,非常适合作为 TDD 入门练习。

image

先来看一下题目:

Write a program to determine if the the parentheses (),
the brackets [], and the braces {}, in a string are balanced.

For example:

{{)(}} is not balanced because ) comes before (

({)} is not balanced because ) is not balanced between {}
and similarly the { is not balanced between ()

[({})] is balanced

{}([]) is balanced

{()}[[{}]] is balanced

我来翻译一下:

写一段程序来判断字符串中的小括号 () ,中括号 [] 和大括号 {} 是否是平衡的(正确闭合)。

例如:

{{)(}} 是没有闭合的,因为 ) 在 ( 之前。

({)} 是没有闭合的,因为 ) 在 {} 之间没有正确闭合,同样 { 在 () 中间没有正确闭合。

[({})] 是平衡的。

{}([]) 是平衡的。

{()}[[{}]] 是平衡的。

需求清楚了,按照一个普通程序员的思维需要先思考一下,把需求理解透彻而且思路要完整,在没思路的情况下完全不能动手。

而使用 TDD 首先要将需求拆分成很小的任务,每个任务足够简单、独立,通过完成一个个小任务,最终交付一个完整的功能。

这个题目起码有两种技术方案,我们先来尝试第一种。

先来拆分第一步:

输入一个空字符串,期望是平衡的,所以返回 true

我们来先写测试:

import assert from 'assert';

describe('Parentheses', function() {
  it('如果 输入字符串为 "" ,当调用 Parentheses.execute(),则结果返回 true', () => {
    assert.equal(Parentheses.execute(''), true);
  });
});

此时运行测试:

  1. Parentheses
    如果 输入字符串为 "" ,当调用 Parentheses.execute(),则结果返回 true:
    ReferenceError: Parentheses is not defined
    at Context.Parentheses (test/parentheses.spec.js:5:18)

接下来写这个 case 的实现:


export default {
  execute(str) {
    if (str === '') {
      return true;
    }
  }
};

运行:

Parentheses
✓ 如果 输入字符串为 "" ,当调用 Parentheses.execute(),则结果返回 true

1 passing (1ms)

第二步:

输入符串为 (),期望的结果是 true

先写测试:

it('如果 输入字符串为 () ,当调用 Parentheses.execute(),则结果返回 true', () => {
  assert.equal(Parentheses.execute('()'), true);
});

运行、失败!因为篇幅原因这里就不再贴报错结果。

然后继续写实现:

export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    if (str === '()') {
      return true;
    }

    return false;
  }
};

这个实现虽然有点傻,但的确是通过了测试,回顾一下 “简单设计原则” ,以上两步代码都过于简单,没有值得重构的地方。

第三步:

输入符串为 ()(),期望的结果是 true

测试:

it('如果 输入字符串为 ()() ,当调用 Parentheses.execute(),则结果返回 true', () => {
  assert.equal(Parentheses.execute('()()'), true);
});

运行、失败!

实现:


export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    if (str === '()') {
      return true;
    }

    if (str === '()()') {
      return true;
    }

    return false;
  }
};

这个实现更傻,傻到我都不好意思往上贴,再看看 “简单设计原则” 通过测试,然后可以重构了。

其中 if (str === '()')if (str === '()()') 看起来有些重复,来看看是否可以这样重构一下:


export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    const replacedResult = str.replace(/\(\)/gi, '');
    if (replacedResult === '') {
      return true;
    }

    return false;
  }
};

将字符串中的 () 全部替换掉,如果替换后的字符串结果等于 '' 则是正确闭合的。

运行,通过!

我们再来增加一个case :

it('如果 输入字符串为 ()()( ,当调用 Parentheses.execute(),则结果返回 false', () => {
  assert.equal(Parentheses.execute('()()('), false);
});

运行,通过!

第四步

输入符串为 [],期望的结果是 true

测试:

it('如果 输入字符串为 [] ,当调用 Parentheses.execute(),则结果返回 true', () => {
  assert.equal(Parentheses.execute('[]'), true);
});

运行、失败!

实现:

export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    let replacedResult = str.replace(/\(\)/gi, '');
    replacedResult = replacedResult.replace(/\[\]/gi, '');
    if (replacedResult === '') {
      return true;
    }

    return false;
  }
};

运行,通过!

正则表达式可以将两条语句合并成一条,但是合并成一条语句的可读性较差,所以这里写成了两句。

第五步:

输入符串为 {},期望的结果是 true

测试:

it('如果 输入字符串为 {} ,当调用 Parentheses.execute(),则结果返回 true', () => {
  assert.equal(Parentheses.execute('{}'), true);
});

实现:

export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    let replacedResult = str.replace(/\(\)/gi, '');
    replacedResult = replacedResult.replace(/\[\]/gi, '');
    replacedResult = replacedResult.replace(/\{\}/gi, '');
    if (replacedResult === '') {
      return true;
    }

    return false;
  }
};

运行、通过!

第六步:

输入符串为 [({})],期望的结果是 true

写测试:

it('如果 输入字符串为 [({})] ,当调用 Parentheses.execute(),则结果返回 true', () => {
  assert.equal(Parentheses.execute('[({})]'), true);
});

运行、失败!

原因是我们的替换逻辑是有顺序的,当替换完成的结果有值,如果等于输入值则返回 false,如果不等于输入值则继续替换, 这里用到了递归。

来修改一下实现代码:

export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    let replacedResult = str.replace(/\(\)/gi, '');
    replacedResult = replacedResult.replace(/\[\]/gi, '');
    replacedResult = replacedResult.replace(/\{\}/gi, '');

    if (replacedResult === '') {
      return true;
    }

    if (replacedResult === str) {
      return false;
    }

    return this.execute(replacedResult);
  }
};

运行、通过!

再添加一些测试用例:

it('如果 输入字符串为 {}([]) ,当调用 Parentheses.execute(),则结果返回 true', () => {
  assert.equal(Parentheses.execute('{}([])'), true);
});

it('如果 输入字符串为 {()}[[{}]] ,当调用 Parentheses.execute(),则结果返回 true', () => {
  assert.equal(Parentheses.execute('{()}[[{}]]'), true);
});

it('如果 输入字符串为 {{)(}} ,当调用 Parentheses.execute(),则结果返回 false', () => {
  assert.equal(Parentheses.execute('{{)(}}'), false);
});

it('如果 输入字符串为 ({)} ,当调用 Parentheses.execute(),则结果返回 false', () => {
  assert.equal(Parentheses.execute('({)}'), false);
});

运行、通过!

这个功能我们就这样简单的实现了,因为需求如此所以这个方案有些简陋,甚至我们都没有做错误处理。在这里我们不花太多时间进行重构,直接进入方案二。

方案二

我们将需求扩展一下:

输入字符串为:

const fn = () => {
    const arr = [1, 2, 3];
    if (arr.length) {
      alert('success!');
    }
};

判断这个字符串的括号是否正确闭合。

通过刚刚 git 提交的记录找到第二步重新拉出一个分支:

git log
git checkout <第二步的版本号> -b plan-b

运行、通过!

测试已经有了,我们直接修改实现:


export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    const pipe = [];
    for (let char of str) {
      if (char === '(') {
        pipe.push(chart);
      }

      if (char === ')') {
        pipe.pop();
      }
    }

    if (!pipe.length) return true;
    return false;
  }
};

这个括号的闭合规则是先进后出的,使用数组就 ok。

运行、通过!

第三步:

上面的实现满足这个任务,但是有一个明显的漏洞,当输入只有一个 ) 时,期望得到返回 false ,我们增加一个 case:

it('如果 输入字符串为 ) ,当调用 Parentheses.execute(),则结果返回 false', () => {
  assert.equal(Parentheses.execute(')'), false);
});

运行、失败!

再修改实现:


export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    const pipe = [];
    for (let char of str) {
      if (char === '(') {
        pipe.push(char);
      }

      if (char === ')') {
        if (pipe.pop() !== '(')
          return false;
      }
    }

    if (!pipe.length) return true;
    return false;
  }
};

运行、通过!如果 pop() 的结果不是我们放进去管道里的值,则认为没有正确闭合。

重构一下,if 语句嵌套的没有意义:


export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    const pipe = [];
    for (let char of str) {
      if (char === '(') {
        pipe.push(char);
      }

      if (char === ')' && pipe.pop() !== '(') {
        return false;
      }
    }

    if (!pipe.length) return true;
    return false;
  }
};

( ) 在程序中应该是一组常量,不应当写成字符串,所以继续重构:


const PARENTHESES = {
  OPEN: '(',
  CLOSE: ')'
};

export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    const pipe = [];
    for (let char of str) {
      if (char === PARENTHESES.OPEN) {
        pipe.push(char);
      }

      if (char === PARENTHESES.CLOSE
          && pipe.pop() !== PARENTHESES.OPEN) {
        return false;
      }
    }

    if (!pipe.length) return true;
    return false;
  }
};

运行、通过!

再增加几个case:

it('如果 输入字符串为 ()() ,当调用 Parentheses.execute(),则结果返回 true', () => {
  assert.equal(Parentheses.execute('()()'), true);
});

it('如果 输入字符串为 ()()( ,当调用 Parentheses.execute(),则结果返回 false', () => {
  assert.equal(Parentheses.execute('()()('), false);
});

第四步:

如果输入字符串为 ] ,这结果返回 false

测试:

it('如果 输入字符串为 ] ,当调用 Parentheses.execute(),则结果返回 false', () => {
  assert.equal(Parentheses.execute(']'), false);
});

运行、失败!

这个逻辑很简单,只要复制上面的逻辑就ok。

实现:


const PARENTHESES = {
  OPEN: '(',
  CLOSE: ')'
};

const BRACKETS = {
  OPEN: '[',
  CLOSE: ']'
};

export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    const pipe = [];
    for (let char of str) {
      if (char === PARENTHESES.OPEN) {
        pipe.push(char);
      }

      if (char === PARENTHESES.CLOSE
          && pipe.pop() !== PARENTHESES.OPEN) {
        return false;
      }

      if (char === BRACKETS.OPEN) {
        pipe.push(char);
      }

      if (char === BRACKETS.CLOSE
          && pipe.pop() !== BRACKETS.OPEN) {
        return false;
      }
    }

    if (!pipe.length) return true;
    return false;
  }
};

运行、通过!

接下来我们开始重构,这两段代码完全重复,只是判断条件不同,如果后面增加 } 逻辑也是相同,所以这里我们将重复的代码抽成函数。


const PARENTHESES = {
  OPEN: '(',
  CLOSE: ')'
};

const BRACKETS = {
  OPEN: '[',
  CLOSE: ']'
};

const holderMap = {
  '(': PARENTHESES,
  ')': PARENTHESES,
  '[': BRACKETS,
  ']': BRACKETS,
};

const compare = (char, pipe) => {
  const holder = holderMap[char];
  if (char === holder.OPEN) {
    pipe.push(char);
  }

  if (char === holder.CLOSE
      && pipe.pop() !== holder.OPEN) {
    return false;
  }

  return true;
};

export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    const pipe = [];
    for (let char of str) {
      if (!compare(char, pipe)) {
        return false;
      }
    }

    if (!pipe.length) return true;
    return false;
  }
};

运行、通过!

第五步

输入符串为 },期望的结果是 false

测试:

it('如果 输入字符串为 } ,当调用 Parentheses.execute(),则结果返回 false', () => {
  assert.equal(Parentheses.execute('}'), false);
});

运行、失败!

  1. Parentheses
    如果 输入字符串为 } ,当调用 Parentheses.execute(),则结果返回 false:
    TypeError: Cannot read property 'OPEN' of undefined
    at compare (src/parentheses.js:22:4)
    at Object.execute (src/parentheses.js:45:12)
    at Context.it (test/parentheses.spec.js:29:48)

报错信息和我们期望的不符,原来是 } 字符串没有找到对应的 holder 会报错,来修复一下:


const PARENTHESES = {
  OPEN: '(',
  CLOSE: ')'
};

const BRACKETS = {
  OPEN: '[',
  CLOSE: ']'
};

const holderMap = {
  '(': PARENTHESES,
  ')': PARENTHESES,
  '[': BRACKETS,
  ']': BRACKETS,
};

const compare = (char, pipe) => {
  const holder = holderMap[char];
  if (!holder) return true;
  if (char === holder.OPEN) {
    pipe.push(char);
  }

  if (char === holder.CLOSE
      && pipe.pop() !== holder.OPEN) {
    return false;
  }

  return true;
};

export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    const pipe = [];
    for (let char of str) {
      if (!compare(char, pipe)) {
        return false;
      }
    }

    if (!pipe.length) return true;
    return false;
  }
};

运行、失败!这次失败的结果与我们期望是相同的,然后再修改逻辑。


const PARENTHESES = {
  OPEN: '(',
  CLOSE: ')'
};

const BRACKETS = {
  OPEN: '[',
  CLOSE: ']'
};

const BRACES = {
  OPEN: '{',
  CLOSE: '}'
};

const holderMap = {
  '(': PARENTHESES,
  ')': PARENTHESES,
  '[': BRACKETS,
  ']': BRACKETS,
  '{': BRACES,
  '}': BRACES
};

const compare = (char, pipe) => {
  const holder = holderMap[char];
  if (!holder) return true;
  if (char === holder.OPEN) {
    pipe.push(char);
  }

  if (char === holder.CLOSE
      && pipe.pop() !== holder.OPEN) {
    return false;
  }

  return true;
};

export default {
  execute(str) {
    if (str === '') {
      return true;
    }

    const pipe = [];
    for (let char of str) {
      if (!compare(char, pipe)) {
        return false;
      }
    }

    if (!pipe.length) return true;
    return false;
  }
};

因为前面的重构,增加 {} 的支持只是增加一些常量的配置。

运行、通过!

再增加些 case:

it('如果 输入字符串为 [({})] ,当调用 Parentheses.execute(),则结果返回 true', () => {
  assert.equal(Parentheses.execute('[({})]'), true);
});

it('如果 输入字符串为 {}([]) ,当调用 Parentheses.execute(),则结果返回 true', () => {
  assert.equal(Parentheses.execute('{}([])'), true);
});

it('如果 输入字符串为 {()}[[{}]] ,当调用 Parentheses.execute(),则结果返回 true', () => {
  assert.equal(Parentheses.execute('{()}[[{}]]'), true);
});

it('如果 输入字符串为 {{)(}} ,当调用 Parentheses.execute(),则结果返回 false', () => {
  assert.equal(Parentheses.execute('{{)(}}'), false);
});

it('如果 输入字符串为 ({)} ,当调用 Parentheses.execute(),则结果返回 false', () => {
  assert.equal(Parentheses.execute('({)}'), false);
});

运行、通过!

再加最后一个 case:

const inputStr = `
    const fn = () => {
      const arr = [1, 2, 3];
      if (arr.length) {
        alert('success!');
      }
    };
  `;

it(`如果 输入字符串为 ${inputStr} ,当调用 Parentheses.execute(),则结果返回 false`, () => {
  assert.equal(Parentheses.execute(inputStr), true);
});

完成!

总结

通过上面的练习,相信大家应该能够感受到 TDD 的威力,有兴趣的同学可以不使用 TDD 将上面的功能重新实现一遍,对比一下两次实现的时间和质量就知道要不要学习 TDD 这项技能。

资料

https://martinfowler.com/bliki/BeckDesignRules.html

《测试驱动开发的艺术》

本文代码

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,755评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,305评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,138评论 0 355
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,791评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,794评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,631评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,362评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,264评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,724评论 1 315
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,900评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,040评论 1 350
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,742评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,364评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,944评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,060评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,247评论 3 371
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,979评论 2 355

推荐阅读更多精彩内容