测试驱动开发(TDD)
📋 概述
测试驱动开发(Test-Driven Development, TDD)是一种软件开发实践,强调在编写功能代码之前先编写测试。通过"红-绿-重构"的循环,TDD确保代码质量、设计简洁性和需求完整性。
🎯 学习目标
- 理解TDD的核心原理和价值
- 掌握TDD的开发流程和实践技巧
- 学会在Node.js项目中应用TDD
- 了解TDD的优势和挑战
🔄 TDD核心流程
红-绿-重构循环
mermaid
graph LR
A[🔴 Red<br/>编写失败测试] --> B[🟢 Green<br/>编写最少代码让测试通过]
B --> C[🔵 Refactor<br/>重构代码提高质量]
C --> A
style A fill:#ff6b6b
style B fill:#51cf66
style C fill:#339af0
TDD三法则
javascript
const TDDLaws = {
FIRST_LAW: '在编写任何生产代码之前,必须先编写一个失败的单元测试',
SECOND_LAW: '只能编写刚好足够失败的单元测试,编译失败也算失败',
THIRD_LAW: '只能编写刚好足够让当前失败测试通过的生产代码'
};
🛠 TDD实践示例
计算器功能开发
javascript
// 第1步:🔴 Red - 编写第一个失败测试
// tests/unit/calculator.test.js
const Calculator = require('@/utils/calculator');
describe('Calculator', () => {
let calculator;
beforeEach(() => {
calculator = new Calculator();
});
describe('add方法', () => {
it('应该返回两个数的和', () => {
// 这个测试会失败,因为Calculator类还不存在
const result = calculator.add(2, 3);
expect(result).toBe(5);
});
});
});
// 运行测试 - 应该失败(红色)
// ❌ Error: Cannot find module '@/utils/calculator'
javascript
// 第2步:🟢 Green - 编写最少代码让测试通过
// src/utils/calculator.js
class Calculator {
add(a, b) {
return 5; // 硬编码让测试通过
}
}
module.exports = Calculator;
// 运行测试 - 应该通过(绿色)
// ✅ Calculator add方法 应该返回两个数的和
javascript
// 第3步:添加更多测试来驱动真正的实现
// tests/unit/calculator.test.js
describe('Calculator', () => {
let calculator;
beforeEach(() => {
calculator = new Calculator();
});
describe('add方法', () => {
it('应该返回两个数的和', () => {
expect(calculator.add(2, 3)).toBe(5);
});
it('应该正确处理不同的数字', () => {
expect(calculator.add(1, 4)).toBe(5); // 这会让硬编码失败
expect(calculator.add(10, 15)).toBe(25);
});
it('应该处理负数', () => {
expect(calculator.add(-2, 3)).toBe(1);
expect(calculator.add(-5, -3)).toBe(-8);
});
it('应该处理零', () => {
expect(calculator.add(0, 5)).toBe(5);
expect(calculator.add(5, 0)).toBe(5);
expect(calculator.add(0, 0)).toBe(0);
});
});
});
javascript
// 第4步:🟢 实现真正的加法逻辑
// src/utils/calculator.js
class Calculator {
add(a, b) {
// 验证输入
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('参数必须是数字');
}
return a + b;
}
}
module.exports = Calculator;
javascript
// 第5步:🔴 添加边界情况测试
it('应该在参数不是数字时抛出错误', () => {
expect(() => calculator.add('2', 3)).toThrow('参数必须是数字');
expect(() => calculator.add(2, null)).toThrow('参数必须是数字');
expect(() => calculator.add(undefined, 3)).toThrow('参数必须是数字');
});
it('应该处理浮点数', () => {
expect(calculator.add(0.1, 0.2)).toBeCloseTo(0.3);
expect(calculator.add(1.5, 2.7)).toBeCloseTo(4.2);
});
用户服务TDD开发
javascript
// 第1步:🔴 定义用户服务的需求和测试
// tests/unit/services/user-service.test.js
const UserService = require('@/services/user-service');
const UserRepository = require('@/repositories/user-repository');
// Mock依赖
jest.mock('@/repositories/user-repository');
describe('UserService', () => {
let userService;
let mockUserRepository;
beforeEach(() => {
mockUserRepository = new UserRepository();
userService = new UserService(mockUserRepository);
jest.clearAllMocks();
});
describe('createUser', () => {
it('应该成功创建有效用户', async () => {
// 准备测试数据
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'securePassword123'
};
const expectedUser = {
id: '123',
name: userData.name,
email: userData.email,
createdAt: new Date()
};
// 配置mock
mockUserRepository.findByEmail.mockResolvedValue(null);
mockUserRepository.create.mockResolvedValue(expectedUser);
// 执行测试
const result = await userService.createUser(userData);
// 验证结果
expect(result).toEqual(expectedUser);
expect(mockUserRepository.findByEmail).toHaveBeenCalledWith(userData.email);
expect(mockUserRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
name: userData.name,
email: userData.email,
password: expect.any(String) // 密码应该被哈希
})
);
});
});
});
javascript
// 第2步:🟢 实现基本的UserService
// src/services/user-service.js
const bcrypt = require('bcrypt');
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
async createUser(userData) {
// 检查邮箱是否已存在
const existingUser = await this.userRepository.findByEmail(userData.email);
if (existingUser) {
throw new Error('邮箱已存在');
}
// 哈希密码
const hashedPassword = await bcrypt.hash(userData.password, 10);
// 创建用户
const userToCreate = {
name: userData.name,
email: userData.email,
password: hashedPassword
};
return await this.userRepository.create(userToCreate);
}
}
module.exports = UserService;
javascript
// 第3步:🔴 添加验证逻辑的测试
describe('createUser验证', () => {
it('应该拒绝空邮箱', async () => {
const userData = {
name: 'John Doe',
email: '',
password: 'password123'
};
await expect(userService.createUser(userData))
.rejects
.toThrow('邮箱不能为空');
});
it('应该拒绝无效邮箱格式', async () => {
const userData = {
name: 'John Doe',
email: 'invalid-email',
password: 'password123'
};
await expect(userService.createUser(userData))
.rejects
.toThrow('邮箱格式无效');
});
it('应该拒绝弱密码', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: '123'
};
await expect(userService.createUser(userData))
.rejects
.toThrow('密码至少需要8个字符');
});
it('应该拒绝已存在的邮箱', async () => {
const userData = {
name: 'John Doe',
email: 'existing@example.com',
password: 'password123'
};
mockUserRepository.findByEmail.mockResolvedValue({ id: '456' });
await expect(userService.createUser(userData))
.rejects
.toThrow('邮箱已存在');
});
});
javascript
// 第4步:🟢 实现验证逻辑
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
async createUser(userData) {
// 验证输入
this.validateUserData(userData);
// 检查邮箱是否已存在
const existingUser = await this.userRepository.findByEmail(userData.email);
if (existingUser) {
throw new Error('邮箱已存在');
}
// 哈希密码
const hashedPassword = await bcrypt.hash(userData.password, 10);
// 创建用户
const userToCreate = {
name: userData.name,
email: userData.email,
password: hashedPassword
};
return await this.userRepository.create(userToCreate);
}
validateUserData(userData) {
if (!userData.email || userData.email.trim() === '') {
throw new Error('邮箱不能为空');
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(userData.email)) {
throw new Error('邮箱格式无效');
}
if (!userData.password || userData.password.length < 8) {
throw new Error('密码至少需要8个字符');
}
if (!userData.name || userData.name.trim() === '') {
throw new Error('姓名不能为空');
}
}
}
javascript
// 第5步:🔵 重构 - 提取验证器
// src/validators/user-validator.js
class UserValidator {
static validateUserData(userData) {
const errors = [];
if (!userData.name || userData.name.trim() === '') {
errors.push({ field: 'name', message: '姓名不能为空' });
}
if (!userData.email || userData.email.trim() === '') {
errors.push({ field: 'email', message: '邮箱不能为空' });
} else {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(userData.email)) {
errors.push({ field: 'email', message: '邮箱格式无效' });
}
}
if (!userData.password) {
errors.push({ field: 'password', message: '密码不能为空' });
} else if (userData.password.length < 8) {
errors.push({ field: 'password', message: '密码至少需要8个字符' });
}
return errors;
}
}
module.exports = UserValidator;
// 重构UserService使用验证器
const UserValidator = require('@/validators/user-validator');
class UserService {
async createUser(userData) {
// 验证输入
const validationErrors = UserValidator.validateUserData(userData);
if (validationErrors.length > 0) {
throw new Error(`验证失败: ${validationErrors.map(e => e.message).join(', ')}`);
}
// ... 其余逻辑保持不变
}
}
🎯 TDD最佳实践
测试命名和组织
javascript
// 好的测试命名和组织
describe('OrderService', () => {
describe('calculateTotal', () => {
describe('当订单包含商品时', () => {
it('应该返回所有商品价格的总和', () => {});
it('应该正确应用折扣', () => {});
it('应该包含税费计算', () => {});
});
describe('当订单为空时', () => {
it('应该返回0', () => {});
});
describe('当商品价格无效时', () => {
it('应该抛出验证错误', () => {});
});
});
});
测试数据管理
javascript
// 使用工厂函数创建测试数据
// tests/factories/user-factory.js
class UserFactory {
static create(overrides = {}) {
return {
id: '123',
name: 'Test User',
email: 'test@example.com',
password: 'securePassword123',
createdAt: new Date(),
...overrides
};
}
static createMany(count, overrides = {}) {
return Array.from({ length: count }, (_, index) =>
this.create({
id: String(index + 1),
email: `test${index + 1}@example.com`,
...overrides
})
);
}
static withoutEmail() {
return this.create({ email: undefined });
}
static withInvalidEmail() {
return this.create({ email: 'invalid-email' });
}
}
module.exports = UserFactory;
// 在测试中使用
const UserFactory = require('@tests/factories/user-factory');
it('应该创建有效用户', async () => {
const userData = UserFactory.create();
const result = await userService.createUser(userData);
expect(result.email).toBe(userData.email);
});
it('应该拒绝无效邮箱', async () => {
const userData = UserFactory.withInvalidEmail();
await expect(userService.createUser(userData))
.rejects
.toThrow('邮箱格式无效');
});
参数化测试
javascript
// 使用test.each进行参数化测试
describe('密码验证', () => {
test.each([
['', '密码不能为空'],
['123', '密码至少需要8个字符'],
['12345678', null], // 有效密码
['longValidPassword123', null]
])('密码 "%s" 应该 %s', (password, expectedError) => {
const userData = UserFactory.create({ password });
if (expectedError) {
expect(() => UserValidator.validatePassword(password))
.toThrow(expectedError);
} else {
expect(() => UserValidator.validatePassword(password))
.not.toThrow();
}
});
});
🔧 TDD在不同场景中的应用
API端点TDD开发
javascript
// 1. 🔴 先写API测试
// tests/integration/api/users.test.js
const request = require('supertest');
const app = require('@/app');
describe('POST /api/users', () => {
it('应该创建新用户并返回201状态', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'securePassword123'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body).toEqual(
expect.objectContaining({
id: expect.any(String),
name: userData.name,
email: userData.email
})
);
// 密码不应该在响应中
expect(response.body.password).toBeUndefined();
});
});
javascript
// 2. 🟢 实现路由和控制器
// src/routes/users.js
const express = require('express');
const UserController = require('@/controllers/user-controller');
const router = express.Router();
router.post('/', UserController.createUser);
module.exports = router;
// src/controllers/user-controller.js
const UserService = require('@/services/user-service');
class UserController {
static async createUser(req, res) {
try {
const user = await UserService.createUser(req.body);
// 移除密码字段
const { password, ...userResponse } = user;
res.status(201).json(userResponse);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
}
module.exports = UserController;
数据库模型TDD
javascript
// 1. 🔴 先写模型测试
// tests/unit/models/user.test.js
const User = require('@/models/user');
describe('User模型', () => {
it('应该创建有效用户', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'hashedPassword'
};
const user = new User(userData);
await user.save();
expect(user.id).toBeDefined();
expect(user.email).toBe(userData.email);
expect(user.createdAt).toBeInstanceOf(Date);
});
it('应该自动哈希密码', async () => {
const user = new User({
name: 'John Doe',
email: 'john@example.com',
password: 'plainPassword'
});
await user.save();
expect(user.password).not.toBe('plainPassword');
expect(user.password.length).toBeGreaterThan(50);
});
it('应该验证邮箱唯一性', async () => {
const userData = {
name: 'John Doe',
email: 'duplicate@example.com',
password: 'password123'
};
await User.create(userData);
await expect(User.create(userData))
.rejects
.toThrow(/email.*unique/i);
});
});
📊 TDD度量和改进
TDD指标监控
javascript
// TDD度量收集器
class TDDMetrics {
constructor() {
this.redPhaseTime = [];
this.greenPhaseTime = [];
this.refactorPhaseTime = [];
this.cycleCount = 0;
}
startRedPhase() {
this.currentPhaseStart = Date.now();
}
endRedPhase() {
const duration = Date.now() - this.currentPhaseStart;
this.redPhaseTime.push(duration);
}
startGreenPhase() {
this.currentPhaseStart = Date.now();
}
endGreenPhase() {
const duration = Date.now() - this.currentPhaseStart;
this.greenPhaseTime.push(duration);
}
startRefactorPhase() {
this.currentPhaseStart = Date.now();
}
endRefactorPhase() {
const duration = Date.now() - this.currentPhaseStart;
this.refactorPhaseTime.push(duration);
this.cycleCount++;
}
getReport() {
const avgRed = this.average(this.redPhaseTime);
const avgGreen = this.average(this.greenPhaseTime);
const avgRefactor = this.average(this.refactorPhaseTime);
return {
totalCycles: this.cycleCount,
averageRedPhase: avgRed,
averageGreenPhase: avgGreen,
averageRefactorPhase: avgRefactor,
totalTime: avgRed + avgGreen + avgRefactor,
recommendations: this.generateRecommendations(avgRed, avgGreen, avgRefactor)
};
}
average(array) {
return array.length > 0 ? array.reduce((a, b) => a + b) / array.length : 0;
}
generateRecommendations(avgRed, avgGreen, avgRefactor) {
const recommendations = [];
if (avgRed > avgGreen * 3) {
recommendations.push('红色阶段时间过长,考虑简化测试');
}
if (avgGreen > avgRed * 5) {
recommendations.push('绿色阶段时间过长,可能过度实现');
}
if (avgRefactor < (avgRed + avgGreen) * 0.1) {
recommendations.push('重构时间不足,可能积累技术债务');
}
return recommendations;
}
}
TDD质量评估
javascript
// TDD质量检查器
class TDDQualityChecker {
static analyzeTestSuite(testFiles) {
const metrics = {
testToCodeRatio: this.calculateTestToCodeRatio(testFiles),
testCoverage: this.calculateCoverage(testFiles),
testSmells: this.detectTestSmells(testFiles),
tddCompliance: this.checkTDDCompliance(testFiles)
};
return {
...metrics,
overallScore: this.calculateOverallScore(metrics),
recommendations: this.generateRecommendations(metrics)
};
}
static detectTestSmells(testFiles) {
const smells = [];
testFiles.forEach(file => {
// 检测测试异味
if (this.hasLongTests(file)) {
smells.push({ type: 'LONG_TEST', file: file.path });
}
if (this.hasMultipleAssertions(file)) {
smells.push({ type: 'MULTIPLE_ASSERTIONS', file: file.path });
}
if (this.hasMagicNumbers(file)) {
smells.push({ type: 'MAGIC_NUMBERS', file: file.path });
}
});
return smells;
}
static checkTDDCompliance(testFiles) {
// 检查是否遵循TDD实践
return {
hasFailingTestsFirst: this.checkFailingTestsFirst(testFiles),
hasMinimalImplementation: this.checkMinimalImplementation(testFiles),
hasRefactoringEvidence: this.checkRefactoringEvidence(testFiles)
};
}
}
🚫 TDD常见误区和解决方案
常见误区
javascript
const TDDMisconceptions = {
WRITING_TOO_MANY_TESTS: {
problem: '一次写太多测试',
solution: '一次只写一个失败测试',
example: '不要写整个类的所有测试,只写当前功能的一个测试'
},
OVER_ENGINEERING: {
problem: '过度工程化',
solution: '只写刚好让测试通过的代码',
example: '如果测试只需要返回固定值,先硬编码,后续测试会驱动真正实现'
},
SKIPPING_REFACTOR: {
problem: '跳过重构阶段',
solution: '每个绿色阶段后都要考虑重构',
example: '消除重复代码、提高可读性、优化设计'
},
TESTING_IMPLEMENTATION: {
problem: '测试实现细节而非行为',
solution: '专注于测试公共API和行为',
example: '测试方法的输入输出,而不是内部变量'
}
};
解决方案示例
javascript
// ❌ 错误:测试实现细节
it('应该调用userRepository.findByEmail', async () => {
await userService.createUser(userData);
expect(mockUserRepository.findByEmail).toHaveBeenCalled();
});
// ✅ 正确:测试行为和结果
it('应该在邮箱已存在时抛出错误', async () => {
mockUserRepository.findByEmail.mockResolvedValue({ id: '123' });
await expect(userService.createUser(userData))
.rejects
.toThrow('邮箱已存在');
});
// ❌ 错误:一次写太多测试
describe('Calculator', () => {
it('should add numbers', () => {});
it('should subtract numbers', () => {});
it('should multiply numbers', () => {});
it('should divide numbers', () => {}); // 一次写了所有测试
});
// ✅ 正确:逐步添加测试
describe('Calculator', () => {
it('should add two positive numbers', () => {
// 先只写一个简单测试
});
// 测试通过后,再添加下一个测试
});
📝 总结
TDD为Node.js开发提供了系统化的质量保证方法:
- 核心流程:红-绿-重构循环确保质量和设计
- 实践技巧:从简单测试开始,逐步驱动复杂实现
- 质量保证:测试先行确保需求理解和功能正确性
- 设计改进:重构阶段持续优化代码结构
- 团队协作:测试作为活文档促进团队理解
TDD需要练习和坚持,但能显著提高代码质量和开发效率。