測(cè)試驅(qū)動(dòng)開(kāi)發(fā),英文全稱 Test-Driven Development(簡(jiǎn)稱 TDD),是由Kent Beck 先生在極限編程(XP)中倡導(dǎo)的開(kāi)發(fā)方法。以其倡導(dǎo)先寫(xiě)測(cè)試程序,然后編碼實(shí)現(xiàn)其功能得名。
本文不打算扯過(guò)多的理論,而是通過(guò)一個(gè)操練的方式,帶著大家去操練一下,讓同學(xué)們切身感受一下 TDD,究竟是怎么玩的。開(kāi)始之前先說(shuō)一下 TDD 的基本步驟。
TDD 的步驟

- 寫(xiě)一個(gè)失敗的測(cè)試
- 寫(xiě)一個(gè)剛好讓測(cè)試通過(guò)的代碼
- 重構(gòu)上面的代碼
簡(jiǎn)單設(shè)計(jì)原則
重構(gòu)可以遵循簡(jiǎn)單設(shè)計(jì)原則:

簡(jiǎn)單設(shè)計(jì)原則,優(yōu)先級(jí)從上至下降低,也就是說(shuō) 「通過(guò)測(cè)試」的優(yōu)先級(jí)最高,其次是代碼能夠「揭示意圖」和「沒(méi)有重復(fù)」,「最少元素」則是讓我們使用最少的代碼完成這個(gè)功能。
操練
Balanced Parentheses 是我在 cyber-dojo 上最喜歡的一道練習(xí)題之一,非常適合作為 TDD 入門(mén)練習(xí)。

先來(lái)看一下題目:
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
我來(lái)翻譯一下:
寫(xiě)一段程序來(lái)判斷字符串中的小括號(hào) () ,中括號(hào) [] 和大括號(hào) {} 是否是平衡的(正確閉合)。
例如:
{{)(}} 是沒(méi)有閉合的,因?yàn)?) 在 ( 之前。
({)} 是沒(méi)有閉合的,因?yàn)?) 在 {} 之間沒(méi)有正確閉合,同樣 { 在 () 中間沒(méi)有正確閉合。
[({})] 是平衡的。
{}([]) 是平衡的。
{()}[[{}]] 是平衡的。
需求清楚了,按照一個(gè)普通程序員的思維需要先思考一下,把需求理解透徹而且思路要完整,在沒(méi)思路的情況下完全不能動(dòng)手。
而使用 TDD 首先要將需求拆分成很小的任務(wù),每個(gè)任務(wù)足夠簡(jiǎn)單、獨(dú)立,通過(guò)完成一個(gè)個(gè)小任務(wù),最終交付一個(gè)完整的功能。
這個(gè)題目起碼有兩種技術(shù)方案,我們先來(lái)嘗試第一種。
先來(lái)拆分第一步:
輸入一個(gè)空字符串,期望是平衡的,所以返回 true 。
我們來(lái)先寫(xiě)測(cè)試:
import assert from 'assert';
describe('Parentheses', function() {
it('如果 輸入字符串為 "" ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true', () => {
assert.equal(Parentheses.execute(''), true);
});
});
此時(shí)運(yùn)行測(cè)試:
- Parentheses
如果 輸入字符串為 "" ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true:
ReferenceError: Parentheses is not defined
at Context.Parentheses (test/parentheses.spec.js:5:18)
接下來(lái)寫(xiě)這個(gè) case 的實(shí)現(xiàn):
export default {
execute(str) {
if (str === '') {
return true;
}
}
};
運(yùn)行:
Parentheses
? 如果 輸入字符串為 "" ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true1 passing (1ms)
第二步:
輸入符串為 (),期望的結(jié)果是 true 。
先寫(xiě)測(cè)試:
it('如果 輸入字符串為 () ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true', () => {
assert.equal(Parentheses.execute('()'), true);
});
運(yùn)行、失??!因?yàn)槠蜻@里就不再貼報(bào)錯(cuò)結(jié)果。
然后繼續(xù)寫(xiě)實(shí)現(xiàn):
export default {
execute(str) {
if (str === '') {
return true;
}
if (str === '()') {
return true;
}
return false;
}
};
這個(gè)實(shí)現(xiàn)雖然有點(diǎn)傻,但的確是通過(guò)了測(cè)試,回顧一下 “簡(jiǎn)單設(shè)計(jì)原則” ,以上兩步代碼都過(guò)于簡(jiǎn)單,沒(méi)有值得重構(gòu)的地方。
第三步:
輸入符串為 ()(),期望的結(jié)果是 true 。
測(cè)試:
it('如果 輸入字符串為 ()() ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true', () => {
assert.equal(Parentheses.execute('()()'), true);
});
運(yùn)行、失??!
實(shí)現(xiàn):
export default {
execute(str) {
if (str === '') {
return true;
}
if (str === '()') {
return true;
}
if (str === '()()') {
return true;
}
return false;
}
};
這個(gè)實(shí)現(xiàn)更傻,傻到我都不好意思往上貼,再看看 “簡(jiǎn)單設(shè)計(jì)原則” 通過(guò)測(cè)試,然后可以重構(gòu)了。
其中 if (str === '()') 與 if (str === '()()') 看起來(lái)有些重復(fù),來(lái)看看是否可以這樣重構(gòu)一下:
export default {
execute(str) {
if (str === '') {
return true;
}
const replacedResult = str.replace(/\(\)/gi, '');
if (replacedResult === '') {
return true;
}
return false;
}
};
將字符串中的 () 全部替換掉,如果替換后的字符串結(jié)果等于 '' 則是正確閉合的。
運(yùn)行,通過(guò)!
我們?cè)賮?lái)增加一個(gè)case :
it('如果 輸入字符串為 ()()( ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 false', () => {
assert.equal(Parentheses.execute('()()('), false);
});
運(yùn)行,通過(guò)!
第四步
輸入符串為 [],期望的結(jié)果是 true 。
測(cè)試:
it('如果 輸入字符串為 [] ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true', () => {
assert.equal(Parentheses.execute('[]'), true);
});
運(yùn)行、失??!
實(shí)現(xiàn):
export default {
execute(str) {
if (str === '') {
return true;
}
let replacedResult = str.replace(/\(\)/gi, '');
replacedResult = replacedResult.replace(/\[\]/gi, '');
if (replacedResult === '') {
return true;
}
return false;
}
};
運(yùn)行,通過(guò)!
正則表達(dá)式可以將兩條語(yǔ)句合并成一條,但是合并成一條語(yǔ)句的可讀性較差,所以這里寫(xiě)成了兩句。
第五步:
輸入符串為 {},期望的結(jié)果是 true 。
測(cè)試:
it('如果 輸入字符串為 {} ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true', () => {
assert.equal(Parentheses.execute('{}'), true);
});
實(shí)現(xiàn):
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;
}
};
運(yùn)行、通過(guò)!
第六步:
輸入符串為 [({})],期望的結(jié)果是 true 。
寫(xiě)測(cè)試:
it('如果 輸入字符串為 [({})] ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true', () => {
assert.equal(Parentheses.execute('[({})]'), true);
});
運(yùn)行、失?。?/p>
原因是我們的替換邏輯是有順序的,當(dāng)替換完成的結(jié)果有值,如果等于輸入值則返回 false,如果不等于輸入值則繼續(xù)替換, 這里用到了遞歸。
來(lái)修改一下實(shí)現(xiàn)代碼:
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);
}
};
運(yùn)行、通過(guò)!
再添加一些測(cè)試用例:
it('如果 輸入字符串為 {}([]) ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true', () => {
assert.equal(Parentheses.execute('{}([])'), true);
});
it('如果 輸入字符串為 {()}[[{}]] ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true', () => {
assert.equal(Parentheses.execute('{()}[[{}]]'), true);
});
it('如果 輸入字符串為 {{)(}} ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 false', () => {
assert.equal(Parentheses.execute('{{)(}}'), false);
});
it('如果 輸入字符串為 ({)} ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 false', () => {
assert.equal(Parentheses.execute('({)}'), false);
});
運(yùn)行、通過(guò)!
這個(gè)功能我們就這樣簡(jiǎn)單的實(shí)現(xiàn)了,因?yàn)樾枨笕绱怂赃@個(gè)方案有些簡(jiǎn)陋,甚至我們都沒(méi)有做錯(cuò)誤處理。在這里我們不花太多時(shí)間進(jìn)行重構(gòu),直接進(jìn)入方案二。
方案二
我們將需求擴(kuò)展一下:
輸入字符串為:
const fn = () => { const arr = [1, 2, 3]; if (arr.length) { alert('success!'); } };判斷這個(gè)字符串的括號(hào)是否正確閉合。
通過(guò)剛剛 git 提交的記錄找到第二步重新拉出一個(gè)分支:
git log
git checkout <第二步的版本號(hào)> -b plan-b
運(yùn)行、通過(guò)!
測(cè)試已經(jīng)有了,我們直接修改實(shí)現(xiàn):
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;
}
};
這個(gè)括號(hào)的閉合規(guī)則是先進(jìn)后出的,使用數(shù)組就 ok。
運(yùn)行、通過(guò)!
第三步:
上面的實(shí)現(xiàn)滿足這個(gè)任務(wù),但是有一個(gè)明顯的漏洞,當(dāng)輸入只有一個(gè) ) 時(shí),期望得到返回 false ,我們?cè)黾右粋€(gè) case:
it('如果 輸入字符串為 ) ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 false', () => {
assert.equal(Parentheses.execute(')'), false);
});
運(yùn)行、失敗!
再修改實(shí)現(xiàn):
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;
}
};
運(yùn)行、通過(guò)!如果 pop() 的結(jié)果不是我們放進(jìn)去管道里的值,則認(rèn)為沒(méi)有正確閉合。
重構(gòu)一下,if 語(yǔ)句嵌套的沒(méi)有意義:
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;
}
};
( ) 在程序中應(yīng)該是一組常量,不應(yīng)當(dāng)寫(xiě)成字符串,所以繼續(xù)重構(gòu):
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;
}
};
運(yùn)行、通過(guò)!
再增加幾個(gè)case:
it('如果 輸入字符串為 ()() ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true', () => {
assert.equal(Parentheses.execute('()()'), true);
});
it('如果 輸入字符串為 ()()( ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 false', () => {
assert.equal(Parentheses.execute('()()('), false);
});
第四步:
如果輸入字符串為 ] ,這結(jié)果返回 false
測(cè)試:
it('如果 輸入字符串為 ] ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 false', () => {
assert.equal(Parentheses.execute(']'), false);
});
運(yùn)行、失?。?/p>
這個(gè)邏輯很簡(jiǎn)單,只要復(fù)制上面的邏輯就ok。
實(shí)現(xiàn):
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;
}
};
運(yùn)行、通過(guò)!
接下來(lái)我們開(kāi)始重構(gòu),這兩段代碼完全重復(fù),只是判斷條件不同,如果后面增加 } 邏輯也是相同,所以這里我們將重復(fù)的代碼抽成函數(shù)。
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;
}
};
運(yùn)行、通過(guò)!
第五步
輸入符串為 },期望的結(jié)果是 false 。
測(cè)試:
it('如果 輸入字符串為 } ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 false', () => {
assert.equal(Parentheses.execute('}'), false);
});
運(yùn)行、失??!
- Parentheses
如果 輸入字符串為 } ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 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)
報(bào)錯(cuò)信息和我們期望的不符,原來(lái)是 } 字符串沒(méi)有找到對(duì)應(yīng)的 holder 會(huì)報(bào)錯(cuò),來(lái)修復(fù)一下:
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;
}
};
運(yùn)行、失??!這次失敗的結(jié)果與我們期望是相同的,然后再修改邏輯。
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;
}
};
因?yàn)榍懊娴闹貥?gòu),增加 {} 的支持只是增加一些常量的配置。
運(yùn)行、通過(guò)!
再增加些 case:
it('如果 輸入字符串為 [({})] ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true', () => {
assert.equal(Parentheses.execute('[({})]'), true);
});
it('如果 輸入字符串為 {}([]) ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true', () => {
assert.equal(Parentheses.execute('{}([])'), true);
});
it('如果 輸入字符串為 {()}[[{}]] ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 true', () => {
assert.equal(Parentheses.execute('{()}[[{}]]'), true);
});
it('如果 輸入字符串為 {{)(}} ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 false', () => {
assert.equal(Parentheses.execute('{{)(}}'), false);
});
it('如果 輸入字符串為 ({)} ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 false', () => {
assert.equal(Parentheses.execute('({)}'), false);
});
運(yùn)行、通過(guò)!
再加最后一個(gè) case:
const inputStr = `
const fn = () => {
const arr = [1, 2, 3];
if (arr.length) {
alert('success!');
}
};
`;
it(`如果 輸入字符串為 ${inputStr} ,當(dāng)調(diào)用 Parentheses.execute(),則結(jié)果返回 false`, () => {
assert.equal(Parentheses.execute(inputStr), true);
});
完成!
總結(jié)
通過(guò)上面的練習(xí),相信大家應(yīng)該能夠感受到 TDD 的威力,有興趣的同學(xué)可以不使用 TDD 將上面的功能重新實(shí)現(xiàn)一遍,對(duì)比一下兩次實(shí)現(xiàn)的時(shí)間和質(zhì)量就知道要不要學(xué)習(xí) TDD 這項(xiàng)技能。
資料
https://martinfowler.com/bliki/BeckDesignRules.html