TDD 测试驱动开发:红-绿-重构循环
bash npm init -y npm install --save-dev jest
在 `package.json` 中添加测试脚本:
```json
"scripts": {
"test": "jest"
}
我们将创建 StringCalculator.js 和 StringCalculator.test.js 两个文件。
迭代 1:针对空字符串的红-绿-重构
第一步:红——编写一个失败的测试
在 StringCalculator.test.js 中写入第一个测试:
const StringCalculator = require('./StringCalculator');
test('空字符串应返回 0', () => {
const calculator = new StringCalculator();
expect(calculator.add('')).toBe(0);
});
现在运行 npm test,测试必定失败,因为我们还没有实现类和方法。这就是红的状态——意味着我们有一个明确的需求,但尚无满足需求的代码。
第二步:绿——让测试通过
在 StringCalculator.js 中创建最简单的代码:
class StringCalculator {
add() {
return 0;
}
}
module.exports = StringCalculator;
再次运行测试,测试通过变为绿色。注意,我们写的代码只是硬编码返回 0,但它完美地满足了当前唯一的需求。
第三步:重构
目前没有明显的坏味道,代码极其简单。我们直接进入下一个需求。这是 TDD 的关键思想:没有测试失败时绝不添加功能代码。
迭代 2:单个数字求和
红
添加一个新的测试:
test('单个数字字符串 "1" 应返回 1', () => {
const calculator = new StringCalculator();
expect(calculator.add('1')).toBe(1);
});
现在运行测试,这个新测试会失败(因为 add 还是返回 0)。而旧测试依然通过,这保证我们不会破坏原有行为。
绿
修改 add 方法,使其能处理传入的参数:
add(numbers) {
if (numbers === '') return 0;
return parseInt(numbers, 10);
}
两个测试都通过了。我们使用了最简单的条件逻辑。
重构
暂时无需重构。方法很短,意图清晰。
迭代 3:两个数字求和(逗号分隔)
红
添加测试:
test('"1,2" 应返回 3', () => {
const calculator = new StringCalculator();
expect(calculator.add('1,2')).toBe(3);
});
测试失败,因为 parseInt('1,2') 返回 1。
绿
实现分割字符串并求和:
add(numbers) {
if (numbers === '') return 0;
const nums = numbers.split(',');
return nums.reduce((sum, n) => sum + parseInt(n, 10), 0);
}
现在所有测试通过。
重构
观察代码,parseInt 重复逻辑可以提取,但先保持简单。我们可以将分割逻辑与求和逻辑清晰化。目前代码负担不大,暂不重构。
迭代 4:支持任意数量的数字
红
添加测试:
test('"1,2,3,4" 应返回 10', () => {
const calculator = new StringCalculator();
expect(calculator.add('1,2,3,4')).toBe(10);
});
这个测试直接通过!我们的 reduce 已经能处理任意数量的数字。因此无需写新功能代码。这验证了我们的设计已经足够通用。
迭代 5:处理换行符作为分隔符
需求升级:允许字符串包含换行符 \n 作为分隔符,例如 "1\n2,3" 应返回 6。
红
test('"1\\n2,3" 应返回 6', () => {
const calculator = new StringCalculator();
expect(calculator.add('1\n2,3')).toBe(6);
});
测试失败。
绿
修改分割逻辑,同时处理逗号和换行:
add(numbers) {
if (numbers === '') return 0;
const nums = numbers.split(/[,\n]/);
return nums.reduce((sum, n) => sum + parseInt(n, 10), 0);
}
通过。
重构
/[,\n]/ 是魔法字符串。我们可以将分隔符提取为一个常量,但此时作用域很小,不必过度设计。等需求进一步复杂时再考虑重构。
迭代 6:支持自定义分隔符
需求:字符串可以以 //[delimiter]\n 格式开头定义分隔符。例如 "//;\n1;2" 应返回 3。
红
test('使用自定义分隔符 "//;\\n1;2" 应返回 3', () => {
const calculator = new StringCalculator();
expect(calculator.add('//;\n1;2')).toBe(3);
});
失败。
绿
检测并解析自定义分隔符:
add(numbers) {
if (numbers === '') return 0;
let delimiter = /[,\n]/;
if (numbers.startsWith('//')) {
const delimiterEnd = numbers.indexOf('\n');
const customDelimiter = numbers.substring(2, delimiterEnd);
delimiter = new RegExp(`[${customDelimiter}]`);
numbers = numbers.substring(delimiterEnd + 1);
}
const nums = numbers.split(delimiter);
return nums.reduce((sum, n) => sum + parseInt(n, 10), 0);
}
所有测试通过。
重构
现在 add 方法开始变长,职责混合。我们可以进行重构,提取一个 parseInput 函数来处理分隔符解析和数字字符串提取。这一步就是典型的重构,且测试为我们保驾护航。
重构后的代码:
class StringCalculator {
add(numbers) {
if (numbers === '') return 0;
const { numbers: numString, delimiter } = this.parseInput(numbers);
const nums = numString.split(delimiter);
return nums.reduce((sum, n) => sum + parseInt(n, 10), 0);
}
parseInput(numbers) {
let delimiter = /[,\n]/;
if (numbers.startsWith('//')) {
const delimiterEnd = numbers.indexOf('\n');
const customDelimiter = numbers.substring(2, delimiterEnd);
delimiter = new RegExp(`[${customDelimiter}]`);
return { numbers: numbers.substring(delimiterEnd + 1), delimiter };
}
return { numbers, delimiter };
}
}
module.exports = StringCalculator;