中文

精通JavaScript中的测试驱动开发 (TDD)。本综合指南涵盖了红-绿-重构循环、使用Jest的实践,以及现代开发的最佳实践。

JavaScript中的测试驱动开发:面向全球开发者的综合指南

想象一下这样的场景:你接到任务,要去修改一个大型遗留系统中某个关键部分的代码。你感到一阵恐惧。你的修改会破坏其他功能吗?你如何确保系统仍能按预期工作?这种对变更的恐惧是软件开发中的一种常见病,常常导致进度缓慢和应用程序脆弱。但如果有一种方法可以让你充满信心地构建软件,创建一个能在错误进入生产环境之前就将其捕获的安全网呢?这就是测试驱动开发(TDD)所承诺的。

TDD不仅仅是一种测试技术;它是一种严谨的软件设计和开发方法。它颠覆了传统的“先写代码,后测试”模式。通过TDD,你会在编写生产代码之前,先编写一个会失败的测试。这个简单的颠覆对代码质量、设计和可维护性产生了深远的影响。本指南将全面、实用、并面向全球专业开发者,介绍如何在JavaScript中实施TDD。

什么是测试驱动开发(TDD)?

其核心在于,测试驱动开发是一个依赖于重复一个极短开发周期的开发过程。TDD坚持先编写测试,而不是先编写功能再进行测试。这个测试必然会失败,因为功能尚不存在。然后,开发者的工作就是编写最简单的代码来让这个特定的测试通过。一旦通过,再对代码进行清理和改进。这个基本循环被称为“红-绿-重构”循环。

TDD的节奏:红-绿-重构

这个三步循环是TDD的心跳。理解和实践这个节奏是掌握该技术的基础。

一旦一个小功能的循环完成,你就为下一个功能开始一个新的失败测试。

TDD的三大法则

Robert C. Martin(通常被称为“Uncle Bob”),敏捷软件运动中的一位关键人物,定义了三条简单的规则,将TDD的纪律性编纂成法:

  1. 除非是为了让一个失败的单元测试通过,否则你不能编写任何生产代码。
  2. 你不能编写超出足以使其失败的单元测试代码;编译失败也算失败。
  3. 你不能编写超出足以让那个失败的单元测试通过的生产代码。

遵循这些法则会迫使你进入红-绿-重构循环,并确保你的100%的生产代码都是为了满足一个经过测试的特定需求而编写的。

为什么要采用TDD?全球化的商业理由

虽然TDD为个人开发者带来了巨大好处,但其真正的威力在团队和业务层面得以实现,尤其是在全球分布式的环境中。

设置你的JavaScript TDD环境

要在JavaScript中开始使用TDD,你需要一些工具。现代JavaScript生态系统提供了极佳的选择。

测试技术栈的核心组件

由于其简单性和一体化的特性,我们将在示例中使用Jest。对于寻求“零配置”体验的团队来说,这是一个绝佳的选择。

使用Jest进行分步设置

让我们为一个TDD项目进行设置。

1. 初始化你的项目:打开你的终端并创建一个新的项目目录。

mkdir js-tdd-project
cd js-tdd-project
npm init -y

2. 安装Jest:将Jest作为开发依赖项添加到你的项目中。

npm install --save-dev jest

3. 配置测试脚本:打开你的 `package.json` 文件。找到 `"scripts"` 部分并修改 `"test"` 脚本。同时,强烈建议添加一个 `"test:watch"` 脚本,这对TDD工作流非常有价值。

"scripts": {
  "test": "jest",
  "test:watch": "jest --watchAll"
}

`--watchAll` 标志告诉Jest在任何文件保存时自动重新运行测试。这提供了即时反馈,非常适合红-绿-重构循环。

就是这样!你的环境已经准备好了。Jest会自动查找名为 `*.test.js`、`*.spec.js` 或位于 `__tests__` 目录中的测试文件。

TDD实践:构建一个`CurrencyConverter`模块

让我们将TDD循环应用于一个实际的、全球都能理解的问题:在不同货币之间转换金额。我们将一步步构建一个 `CurrencyConverter` 模块。

迭代1:简单的固定汇率转换

🔴 红色:编写第一个失败的测试

我们的第一个需求是使用固定汇率将特定金额从一种货币转换为另一种货币。创建一个名为 `CurrencyConverter.test.js` 的新文件。

// CurrencyConverter.test.js
const CurrencyConverter = require('./CurrencyConverter');

describe('CurrencyConverter', () => {
  it('should convert an amount from USD to EUR correctly', () => {
    // 准备 (Arrange)
    const amount = 10; // 10美元
    const expected = 9.2; // 假设固定汇率为 1 USD = 0.92 EUR

    // 执行 (Act)
    const result = CurrencyConverter.convert(amount, 'USD', 'EUR');

    // 断言 (Assert)
    expect(result).toBe(expected);
  });
});

现在,从你的终端运行测试观察器:

npm run test:watch

测试将会华丽地失败。Jest会报告类似 `TypeError: Cannot read properties of undefined (reading 'convert')` 的错误。这就是我们的红色状态。测试失败是因为 `CurrencyConverter` 不存在。

🟢 绿色:编写最简单的代码使其通过

现在,让我们让测试通过。创建 `CurrencyConverter.js`。

// CurrencyConverter.js
const rates = {
  USD: {
    EUR: 0.92
  }
};

const CurrencyConverter = {
  convert(amount, from, to) {
    return amount * rates[from][to];
  }
};

module.exports = CurrencyConverter;

一旦你保存这个文件,Jest就会重新运行测试,它将变为绿色。我们编写了满足测试需求的最少量的代码。

🔵 重构:改进代码

代码很简单,但我们已经可以考虑改进了。嵌套的 `rates` 对象有点僵硬。但就目前而言,它足够清晰。最重要的是,我们有一个受测试保护的可用功能。让我们进入下一个需求。

迭代2:处理未知货币

🔴 红色:为无效货币编写一个测试

如果我们尝试转换为一种我们不知道的货币,应该发生什么?它可能应该抛出一个错误。让我们在 `CurrencyConverter.test.js` 的一个新测试中定义这个行为。

// 在 CurrencyConverter.test.js 文件中,describe 块内部

it('should throw an error for unknown currencies', () => {
  // 准备 (Arrange)
  const amount = 10;

  // 执行与断言 (Act & Assert)
  // 我们将函数调用包装在一个箭头函数中,以便Jest的toThrow能够工作。
  expect(() => {
    CurrencyConverter.convert(amount, 'USD', 'XYZ');
  }).toThrow('Unknown currency: XYZ');
});

保存文件。测试运行器立即显示一个新的失败。它是红色的,因为我们的代码没有抛出错误;它试图访问 `rates['USD']['XYZ']`,导致了 `TypeError`。我们的新测试正确地识别出了这个缺陷。

🟢 绿色:让新测试通过

让我们修改 `CurrencyConverter.js` 来添加验证。

// CurrencyConverter.js
const rates = {
  USD: {
    EUR: 0.92,
    GBP: 0.80
  },
  EUR: {
    USD: 1.08
  }
};

const CurrencyConverter = {
  convert(amount, from, to) {
    if (!rates[from] || !rates[from][to]) {
      // 判断哪个货币是未知的,以便提供更好的错误消息
      const unknownCurrency = !rates[from] ? from : to;
      throw new Error(`Unknown currency: ${unknownCurrency}`);
    }
    return amount * rates[from][to];
  }
};

module.exports = CurrencyConverter;

保存文件。两个测试现在都通过了。我们回到了绿色状态。

🔵 重构:清理代码

我们的 `convert` 函数正在变大。验证逻辑与计算混合在一起。我们可以将验证提取到一个单独的私有函数中以提高可读性,但目前它仍然是可控的。关键在于我们有自由进行这些更改,因为我们的测试会告诉我们是否破坏了任何东西。

迭代3:异步获取汇率

硬编码汇率是不现实的。让我们重构我们的模块,从一个(模拟的)外部API获取汇率。

🔴 红色:编写一个模拟API调用的异步测试

首先,我们需要重构我们的转换器。它现在需要成为一个我们可以实例化的类,可能带有一个API客户端。我们还需要模拟 `fetch` API。Jest让这变得很容易。

让我们重写我们的测试文件以适应这个新的、异步的现实。我们将从再次测试正常路径开始。

// CurrencyConverter.test.js
const CurrencyConverter = require('./CurrencyConverter');

// 模拟外部依赖
global.fetch = jest.fn();

beforeEach(() => {
  // 在每个测试前清除模拟历史
  fetch.mockClear();
});

describe('CurrencyConverter', () => {
  it('should fetch rates and convert correctly', async () => {
    // 准备 (Arrange)
    // 模拟成功的API响应
    fetch.mockResolvedValueOnce({
      json: () => Promise.resolve({ rates: { EUR: 0.92 } })
    });

    const converter = new CurrencyConverter('https://api.exchangerates.com');
    const amount = 10; // 10美元

    // 执行 (Act)
    const result = await converter.convert(amount, 'USD', 'EUR');

    // 断言 (Assert)
    expect(result).toBe(9.2);
    expect(fetch).toHaveBeenCalledTimes(1);
    expect(fetch).toHaveBeenCalledWith('https://api.exchangerates.com/latest?base=USD');
  });

  // 我们之后也会为API失败等情况添加测试。
});

运行这将导致一片红色。我们旧的 `CurrencyConverter` 不是一个类,没有 `async` 方法,也不使用 `fetch`。

🟢 绿色:实现异步逻辑

现在,让我们重写 `CurrencyConverter.js` 以满足测试的要求。

// CurrencyConverter.js
class CurrencyConverter {
  constructor(apiUrl) {
    this.apiUrl = apiUrl;
  }

  async convert(amount, from, to) {
    const response = await fetch(`${this.apiUrl}/latest?base=${from}`);
    if (!response.ok) {
      throw new Error('Failed to fetch exchange rates.');
    }

    const data = await response.json();
    const rate = data.rates[to];

    if (!rate) {
      throw new Error(`Unknown currency: ${to}`);
    }

    // 简单四舍五入以避免测试中的浮点数问题
    const convertedAmount = amount * rate;
    return parseFloat(convertedAmount.toFixed(2));
  }
}

module.exports = CurrencyConverter;

保存后,测试应该变为绿色。注意,我们还添加了四舍五入逻辑来处理浮点数不精确的问题,这是金融计算中的常见问题。

🔵 重构:改进异步代码

这个 `convert` 方法做了很多事情:获取数据、错误处理、解析和计算。我们可以通过创建一个单独的 `RateFetcher` 类来重构它,该类只负责API通信。然后我们的 `CurrencyConverter` 将使用这个获取器。这遵循了单一职责原则,并使两个类都更容易测试和维护。TDD引导我们走向这种更清晰的设计。

常见的TDD模式与反模式

当你实践TDD时,你会发现一些行之有效的模式和一些会引起问题的反模式。

值得遵循的良好模式

需要避免的反模式

TDD在更广泛的开发生命周期中

TDD并非孤立存在。它与现代敏捷和DevOps实践完美结合,特别是对于全球团队。

结论:你的TDD之旅

测试驱动开发不仅仅是一种测试策略——它是我们进行软件开发方式上的一次范式转变。它培养了一种质量、信心和协作的文化。红-绿-重构循环提供了一个稳定的节奏,引导你走向整洁、健壮且可维护的代码。由此产生的测试套件成为一个保护你的团队免受回归影响的安全网,以及一个帮助新成员上手的活文档。

学习曲线可能感觉陡峭,最初的步伐可能看起来较慢。但在减少调试时间、改进软件设计和增强开发者信心方面,其长期回报是不可估量的。精通TDD的旅程是一条充满纪律和实践的道路。

从今天开始。在你的下一个项目中,选择一个小的、非关键的功能,并致力于这个过程。先写测试。看它失败。让它通过。然后,最重要的是,进行重构。体验一下来自绿色测试套件的信心,你很快就会想,以前没有它你是怎么开发软件的。