TDD 测试驱动开发:红-绿-重构循环

FreeGuideOnline 最新 2026-07-01

bash npm init -y npm install --save-dev jest


在 `package.json` 中添加测试脚本:

```json
"scripts": {
  "test": "jest"
}

我们将创建 StringCalculator.jsStringCalculator.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;