测试驱动开发, 英文全称 Test-Driven Development(简称 TDD), 是由 Kent Beck 先生在极限编程 (XP) 中倡导的开发方法. 以其倡导先写测试程序, 然后编码实现其功能得名.
本文不打算扯过多的理论, 而是通过一个操练的方式, 带着大家去操练一下, 让同学们切身感受一下 TDD, 究竟是怎么玩的. 开始之前先说一下 TDD 的基本步骤.
TDD 的步骤
TDD 步骤
写一个失败的测试
写一个刚好让测试通过的代码
重构上面的代码
简单设计原则
重构可以遵循简单设计原则:
简单设计原则
简单设计原则, 优先级从上至下降低, 也就是说 「通过测试」的优先级最高, 其次是代码能够「揭示意图」和「没有重复」,「最少元素」则是让我们使用最少的代码完成这个功能.
操练
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);
- });
- });
此时运行测试:
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);
- });
运行, 失败!
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
《测试驱动开发的艺术》
本文代码
来源: http://www.jianshu.com/p/51a9db5a24d0