每日一博 | 测试驱动开发(TDD)入门
测试驱动开发(TDD)入门
测试驱动开发,英文全称 Test-Driven Development(简称 TDD),是由Kent Beck 先生在极限编程(XP)中倡导的开发方法。以其倡导先写测试程序,然后编码实现其功能得名。
本文不打算扯过多的理论,而是通过操练的方式,带着大家去操练一下,让同学们切身感受一下 TDD,究竟是怎么玩的。开始之前先说一下 TDD 的基本步骤。
TDD 的步骤
- 写一个失败的测试
- 写一个刚好让测试通过的代码
- 重构上面的代码
简单设计原则
重构可以遵循简单设计原则:
简单设计原则,优先级从上至下降低,也就是说 「通过测试」的优先级最高,其次是代码能够「揭示意图」和「没有重复」,「最少元素」则是让我们使用最少的代码完成这个功能。
操练
Balanced Parentheses 是我在 cyber-dojo 上最喜欢的一道练习题之一,非常适合作为 TDD 入门练习。
先来看一下题目:
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; } };
这个实现更傻,傻到我都不好意思往上贴,回顾一下 TDD 的步骤「通过测试」,可以重构了。
其中 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 这项技能。
资料
低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
腾讯游戏部门开源 DevOps 系列项目“蓝鲸”
蓝鲸智云(蓝鲸)是腾讯游戏运营部“腾讯智营”下的一个子品牌,它是一套基于 PaaS 的技术解决方案,提供了完善的前后台开发框架、调度引擎与公共组件等模块,可以帮助业务的产品和技术人员快速构建低成本、免运维的支撑工具和运营系统。 蓝鲸团队近期开源了其部分项目,系列项目包括: 蓝鲸智云 PaaS 平台(BlueKing PaaS) 蓝鲸智云配置平台(BlueKing CMDB) 蓝鲸智云标准运维(SOPS) 蓝鲸智云容器管理平台(BlueKing Container Service) 蓝鲸智云容器管理平台 SaaS(Blueking Container Service) 蓝鲸 CI 平台(BlueKing CI) 主页:https://gitee.com/Tencent-BlueKing 腾讯蓝鲸智云 PaaS 平台(BlueKing PaaS) 本次开源的是蓝鲸智云 PaaS 平台社区版(BlueKing PaaS Community Edition),它提供了应用引擎、前后台开发框架、API 网关、调度引擎、统一登录与公共组件等模块,帮助用户快速、低成本、免运维地构建支撑工具和运营系统...
- 下一篇
OSChina 周二乱弹 —— 富婆没一个好东西
Osc乱弹歌单(2019)请戳(这里) 【今日歌曲】 @花间小酌 :#今日歌曲推荐# 分享毛不易的单曲《像我这样的人》像我这样平凡的人。。。。 《像我这样的人》- 毛不易 手机党少年们想听歌,请使劲儿戳(这里) @Gelink :根本无心工作学习,只想尽快为祖国母亲庆生! 现在已经没法继续工作了。 吃点东西补充能量吧! @红薯 :灵魂拷问,晚饭吃啥 ? 吃肉! 夏目(@夏目Jane )做饭就好吃! 看起来就好吃! @夏目Jane :照旧是一个人的午餐,吃的好开心 我就不一样了, 我只会自己找饭吃, “让我看看都剩下什么吃的了。” 剩下的只有方便面, @雲霏霏 :穷到吃泡面了,呜呜呜~~ 缺个会做饭的妹子么? 别总吃方便面了, 会做饭的妹子最性感了。 @莱布妮子 :神奇的大自然 @小小编辑 :母狮子:看着干嘛……快上啊…… 公狮子 :好的…… 这个充满沙雕的世界真的挺酷的, @双手插口袋 :优酷天天说这世界很酷。世界真的酷吗? @_吟游诗人 :有钱人的世界很酷,没钱人的世界很苦 。 这个世界充满了各种意外啊, “所以才精彩不是么?” 但我们搞IT实在太累了。 @canye :实在累了!去...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
-
Docker使用Oracle官方镜像安装(12C,18C,19C)
- Springboot2将连接池hikari替换为druid,体验最强大的数据库连接池
- CentOS8编译安装MySQL8.0.19
- Docker快速安装Oracle11G,搭建oracle11g学习环境
- SpringBoot2配置默认Tomcat设置,开启更多高级功能
- MySQL8.0.19开启GTID主从同步CentOS8
- CentOS7,8上快速安装Gitea,搭建Git服务器
- Jdk安装(Linux,MacOS,Windows),包含三大操作系统的最全安装
- SpringBoot2编写第一个Controller,响应你的http请求并返回结果
推荐阅读
最新文章
- Springboot2将连接池hikari替换为druid,体验最强大的数据库连接池
- Docker使用Oracle官方镜像安装(12C,18C,19C)
- CentOS6,CentOS7官方镜像安装Oracle11G
- SpringBoot2整合Redis,开启缓存,提高访问速度
- Jdk安装(Linux,MacOS,Windows),包含三大操作系统的最全安装
- SpringBoot2配置默认Tomcat设置,开启更多高级功能
- SpringBoot2整合MyBatis,连接MySql数据库做增删改查操作
- Hadoop3单机部署,实现最简伪集群
- MySQL8.0.19开启GTID主从同步CentOS8
- SpringBoot2编写第一个Controller,响应你的http请求并返回结果