美文网首页Web前端之路
测试驱动开发(TDD)入门

测试驱动开发(TDD)入门

作者: 谢小呆呆呆 | 来源:发表于2019-09-14 10:09 被阅读0次

    测试驱动开发,英文全称 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

    《测试驱动开发的艺术》

    本文代码

    相关文章

      网友评论

        本文标题:测试驱动开发(TDD)入门

        本文链接:https://www.haomeiwen.com/subject/lfsryctx.html