测试驱动开发 TDD
约 1724 字大约 6 分钟
测试TDD测试驱动开发
2026-04-16
TDD 简介
TDD(Test-Driven Development,测试驱动开发)是一种软件开发方法论,其核心思想是先编写测试用例,再编写功能代码。与传统的"先开发后测试"模式相反,TDD 将测试作为开发的起点和驱动力。
Red-Green-Refactor 循环
TDD 的核心开发循环分为三个阶段:
Red(红)→ Green(绿)→ Refactor(重构)→ 重复- Red:编写一个会失败的测试,明确需要实现的功能
- Green:编写最少的代码让测试通过
- Refactor:在保持测试通过的前提下,优化代码结构
TDD 的优势
更清晰的代码设计
TDD 强迫开发者从使用者角度思考 API 设计。当先写测试时,你会自然思考:
- 这个函数应该叫什么名字?
- 参数应该是什么类型?
- 返回值应该是什么格式?
这种"由外而内"的思考方式促使我们设计出更直观、更易用的接口。
更好的测试覆盖率
由于每个功能都是在测试的驱动下开发的,测试覆盖率自然很高。TDD 模式下:
- 测试不是事后补的,而是开发的起点
- 每个功能都有对应的测试
- 边界情况和异常场景更容易被覆盖
更安全的重构
有了完整的测试用例作为安全网,重构变得更有信心。当代码出现错误时,测试会立即发现,而不是等到用户反馈。
文档化
测试代码本身就是最好的文档。一个新加入团队的开发者可以通过阅读测试来理解:
- 每个函数应该如何使用
- 各种输入对应的输出是什么
- 边界条件和异常情况有哪些
TDD 的工作流
第一步:Red(写一个失败的测试)
在编写任何功能代码之前,先写一个会失败的测试。这个测试应该:
- 定义你需要实现的功能
- 描述期望的行为
- 在当前代码下失败(因为功能还不存在)
// math.js
import { add } from './math';
describe('add', () => {
test('should return 5 when adding 2 and 3', () => {
expect(add(2, 3)).toBe(5);
});
test('should return negative number when adding negative values', () => {
expect(add(-1, -2)).toBe(-3);
});
});运行测试后会失败,因为 add 函数还不存在。
第二步:Green(写最少的代码让测试通过)
编写最简单的代码让测试通过。不要考虑优化,只需让测试变绿。
// math.js
export const add = (a, b) => 5;是的,这个实现是错误的,但所有测试都通过了。下一步我们会在重构阶段修正它。
第三步:Refactor(重构代码)
现在测试已经通过了,可以安全地重构代码,使其更优雅、更高效。
// math.js
export const add = (a, b) => a + b;重构后再次运行测试,确保一切仍然正常工作。
TDD 实践
前端场景下的 TDD
工具函数测试
// formatCurrency.js
export const formatCurrency = (amount, currency = 'USD') => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
};
// formatCurrency.test.js
import { formatCurrency } from './formatCurrency';
describe('formatCurrency', () => {
test('formats USD correctly', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56');
});
test('formats EUR correctly', () => {
expect(formatCurrency(1234.56, 'EUR')).toBe('€1,234.56');
});
test('formats with zero', () => {
expect(formatCurrency(0)).toBe('$0.00');
});
});React/Vue 组件测试
// Button.test.jsx (React)
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button', () => {
test('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByText('Click me')).toBeDisabled();
});
});// Button.spec.js (Vue with Vitest)
import { mount } from '@vue/test-utils';
import Button from './Button.vue';
describe('Button', () => {
test('renders with text', () => {
const wrapper = mount(Button, {
slots: { default: 'Click me' },
});
expect(wrapper.text()).toBe('Click me');
});
test('emits click event', async () => {
const wrapper = mount(Button);
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toHaveLength(1);
});
});Jest / Vitest 基本使用
Jest 配置与运行
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
transform: {
'^.+\\.vue$': '@vue/vue3-jest',
'^.+\\.jsx$': 'babel-jest',
},
moduleFileExtensions: ['js', 'jsx', 'vue'],
testMatch: ['**/*.test.js', '**/*.spec.js'],
collectCoverage: true,
coverageReporters: ['html', 'text'],
};# 运行所有测试
npm test
# 运行指定文件
npm test -- Button.test.js
# 监听模式
npm test -- --watch
# 生成覆盖率报告
npm test -- --coverageVitest 配置与运行
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
coverage: {
provider: 'v8',
reporter: ['html', 'text'],
},
},
});# 运行所有测试
vitest
# 监听模式
vitest --watch
# UI 模式
vitest --ui
# 指定文件
vitest run Button.test.jsMock 技术
// Mock 函数
const mockFn = jest.fn();
mockFn.mockReturnValue('result');
mockFn.mockResolvedValue({ id: 1 });
mockFn.mockRejectedValue(new Error('failed'));
// Mock 模块
jest.mock('./api', () => ({
fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'John' }),
}));
// Mock 定时器
jest.useFakeTimers();
jest.advanceTimersByTime(1000);
jest.runAllTimers();TDD 的常见误解与注意事项
误解 1:TDD 不需要写文档
TDD 测试确实可以作为文档,但这不意味着可以忽略其他形式的文档。对于复杂的业务逻辑、架构决策,仍然需要代码注释和设计文档。
误解 2:TDD 会降低开发速度
短期内,TDD 看起来需要写更多代码。但从长远来看:
- Bug 减少,调试时间减少
- 重构更安全,节省时间
- 代码质量更高,维护成本降低
误解 3:测试可以替代所有其他测试类型
TDD 驱动的是单元测试。集成测试、E2E 测试等仍然需要,它们各有其价值和适用场景。
注意事项
- 测试应该是确定性的:避免依赖随机数、时间戳、网络请求等不确定因素
- 测试应该相互独立:每个测试应该能独立运行,不依赖其他测试的执行顺序
- 测试命名要清晰:使用描述性的测试名称,让人一眼看出测试的目的
- 避免过度测试:不要为简单的 getter/setter 编写复杂的测试
- 保持测试快速:单元测试应该毫秒级完成,这样才能快速反馈
与 BDD(行为驱动开发)的区别
| 方面 | TDD | BDD |
|---|---|---|
| 关注点 | 实现是否正确 | 行为是否符合预期 |
| 测试语言 | 技术导向 | 业务导向 |
| 测试命名 | testAdd() | shouldReturnSumOfTwoNumbers() |
| 目标用户 | 开发团队 | 开发团队 + 产品/业务方 |
| 工具 | Jest, Mocha, Vitest | Cucumber, SpecFlow, Jest (BDD风格) |
BDD 示例
BDD 使用更接近自然语言的描述,强调业务行为而非技术实现:
# login.feature
Feature: User login
Scenario: User logs in with valid credentials
Given I am on the login page
When I enter "user@example.com" as email
And I enter "password123" as password
And I click the login button
Then I should be redirected to the dashboard// step definitions
import { defineStep } from '@cucumber/cucumber';
defineStep('I am on the login page', () => {
cy.visit('/login');
});
defineStep('I enter {string} as email', (email) => {
cy.get('[data-testid="email"]').type(email);
});选择建议
- TDD:适合底层库、工具函数、复杂算法等需要精确定义接口的场景
- BDD:适合业务流程复杂、需要与产品/业务方沟通的功能
两者并不互斥,可以根据项目需求和团队情况灵活选择和组合使用。
