测试驱动开发
测试驱动开发的基本概念
测试驱动开发(Test-Driven Development,TDD)是一种软件开发方法,强调在编写实际功能代码之前先编写测试用例。开发过程遵循"红-绿-重构"的循环模式:先写一个失败的测试(红),然后编写最简单的代码使测试通过(绿),最后优化代码结构(重构)。这种方法在Node.js开发中尤其有价值,因为JavaScript的动态类型特性使得代码更容易出现运行时错误。
// 示例:一个简单的TDD循环
// 第一步:编写测试(会失败)
const assert = require('assert');
assert.strictEqual(add(1, 2), 3); // 此时add函数尚未实现
// 第二步:实现最简单功能
function add(a, b) {
return a + b;
}
// 第三步:重构(如果需要)
Node.js中的TDD工具链
在Node.js生态系统中,有多种测试框架支持TDD工作流。最流行的组合包括:
- Mocha:灵活的测试框架
- Chai:断言库
- Sinon:用于创建测试替身(stub/mock/spy)
- Istanbul/NYC:代码覆盖率工具
安装这些工具的典型命令:
npm install --save-dev mocha chai sinon nyc
基本测试文件结构示例:
// test/add.test.js
const chai = require('chai');
const expect = chai.expect;
const { add } = require('../src/math');
describe('加法函数测试', () => {
it('应该正确计算两个数的和', () => {
expect(add(2, 3)).to.equal(5);
});
it('应该处理负数相加', () => {
expect(add(-1, -1)).to.equal(-2);
});
});
TDD实践中的具体步骤
需求分析到测试用例
假设我们需要开发一个购物车的折扣计算功能。首先将需求分解为具体测试场景:
- 空购物车返回0
- 无折扣商品返回原价
- 单个折扣商品应用折扣
- 多个商品混合计算
- 折扣下限保护(不低于0)
对应的测试用例可能如下:
describe('购物车折扣计算', () => {
let cart;
beforeEach(() => {
cart = new ShoppingCart();
});
it('空购物车应返回0', () => {
expect(cart.calculateDiscount()).to.equal(0);
});
it('无折扣商品应返回原价', () => {
cart.addItem({ price: 100, discount: 0 });
expect(cart.calculateDiscount()).to.equal(100);
});
it('应正确应用单个商品折扣', () => {
cart.addItem({ price: 100, discount: 0.2 });
expect(cart.calculateDiscount()).to.equal(80);
});
});
从测试到实现
根据上述测试,逐步实现购物车类:
// src/shopping-cart.js
class ShoppingCart {
constructor() {
this.items = [];
}
addItem(item) {
this.items.push(item);
}
calculateDiscount() {
if (this.items.length === 0) return 0;
return this.items.reduce((total, item) => {
const discounted = item.price * (1 - (item.discount || 0));
return total + Math.max(0, discounted); // 确保不低于0
}, 0);
}
}
处理异步代码的TDD
Node.js中大量使用异步操作,TDD需要特殊处理。以数据库操作为例:
// test/user-repo.test.js
const { expect } = require('chai');
const UserRepository = require('../src/user-repo');
const db = require('./mock-db'); // 模拟数据库
describe('用户仓库', () => {
it('应通过ID查找用户', async () => {
const repo = new UserRepository(db);
const user = await repo.findById(1);
expect(user).to.have.property('name', '张三');
});
it('找不到用户时应返回null', async () => {
const repo = new UserRepository(db);
const user = await repo.findById(999);
expect(user).to.be.null;
});
});
对应的实现需要考虑异步操作:
// src/user-repo.js
class UserRepository {
constructor(db) {
this.db = db;
}
async findById(id) {
return this.db.query('SELECT * FROM users WHERE id = ?', [id])
.then(rows => rows[0] || null);
}
}
TDD中的常见陷阱与解决方案
过度模拟问题
新手常犯的错误是过度使用mock,导致测试与实际脱节。例如:
// 不好的实践:过度mock
it('不应这样过度mock', () => {
const db = {
query: sinon.stub().returns(Promise.resolve([{ id: 1, name: '测试' }]))
};
const repo = new UserRepository(db);
// 测试变得毫无意义
});
更好的方式是使用内存数据库或固定测试数据:
// 更好的做法
const testDb = createTestDatabaseWithSampleData();
it('应使用真实查询逻辑', async () => {
const repo = new UserRepository(testDb);
const user = await repo.findById(1);
expect(user.name).to.match(/[\u4e00-\u9fa5]+/); // 验证中文名
});
测试粒度过细或过粗
好的测试应该:
- 单元测试:聚焦单个函数/方法
- 集成测试:验证模块间交互
- 不测试实现细节(如内部变量)
// 不好的测试:测试实现细节
it('不应测试内部状态', () => {
const cart = new ShoppingCart();
cart.addItem({ price: 100 });
expect(cart.items.length).to.equal(1); // 太依赖实现
});
// 好的测试:测试行为
it('应正确计算总价', () => {
const cart = new ShoppingCart();
cart.addItem({ price: 100 });
expect(cart.getTotal()).to.equal(100); // 测试公开接口
});
TDD在复杂场景中的应用
中间件测试
Express中间件的TDD示例:
// test/auth-middleware.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const authMiddleware = require('../src/middlewares/auth');
describe('认证中间件', () => {
it('应拒绝无token请求', () => {
const req = { headers: {} };
const res = { status: sinon.stub(), json: sinon.stub() };
res.status.returns(res);
authMiddleware(req, res, () => {});
expect(res.status.calledWith(401)).to.be.true;
});
it('应允许有效token', () => {
const req = { headers: { authorization: 'valid-token' } };
const next = sinon.spy();
authMiddleware(req, {}, next);
expect(next.calledOnce).to.be.true;
});
});
事件驱动架构
Node.js事件发射器的TDD:
// test/event-bus.test.js
const EventEmitter = require('events');
const { expect } = require('chai');
class OrderService extends EventEmitter {
placeOrder(order) {
this.emit('order_placed', order);
}
}
describe('订单服务', () => {
it('应触发order_placed事件', (done) => {
const service = new OrderService();
const testOrder = { id: 1 };
service.on('order_placed', (order) => {
expect(order).to.deep.equal(testOrder);
done();
});
service.placeOrder(testOrder);
});
});
测试金字塔与TDD
健康的测试结构应遵循金字塔模型:
- 大量快速运行的单元测试(底层)
- 适量集成测试(中层)
- 少量端到端测试(顶层)
在Node.js中的典型分布:
// 单元测试(70%)
test/units/
└── services/
└── user-service.test.js
// 集成测试(20%)
test/integration/
└── api/
└── user-routes.test.js
// E2E测试(10%)
test/e2e/
└── checkout-flow.test.js
持续集成中的TDD
现代CI/CD流程可以强化TDD实践。一个基本的GitHub Actions配置示例:
# .github/workflows/test.yml
name: Node.js TDD
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- run: npm ci
- run: npm test
- run: npm run coverage
对应的package.json脚本:
{
"scripts": {
"test": "mocha test/**/*.test.js",
"coverage": "nyc mocha test/**/*.test.js",
"watch": "mocha --watch test/**/*.test.js"
}
}
性能测试与TDD
TDD也可以应用于性能关键代码。使用benchmark.js的例子:
// test/performance/search-algo.test.js
const Benchmark = require('benchmark');
const { linearSearch, binarySearch } = require('../../src/algorithms');
const suite = new Benchmark.Suite;
const sortedArray = Array.from({ length: 10000 }, (_, i) => i);
suite
.add('线性搜索', () => {
linearSearch(sortedArray, 5000);
})
.add('二分搜索', () => {
binarySearch(sortedArray, 5000);
})
.on('cycle', event => {
console.log(String(event.target));
})
.run();
测试可维护性技巧
-
描述性测试名称:使用"应..."的句式
// 不好 it('test case 1', () => { ... }); // 好 it('当传入负数时应返回错误', () => { ... });
-
测试数据工厂:避免重复创建测试数据
function createTestUser(overrides = {}) { return Object.assign({ id: 1, name: '测试用户', email: 'test@example.com' }, overrides); }
-
自定义断言:提高测试可读性
chai.Assertion.addMethod('validUser', function() { this.assert( this._obj.name && this._obj.email, '期望是有效用户', '期望是无效用户' ); }); // 使用 expect(user).to.be.validUser;
遗留系统的TDD策略
对于已有代码库引入TDD的渐进方法:
-
接缝测试:在现有代码边界添加测试
// 旧代码 function processOrder(order) { // 复杂逻辑... } // 新增测试 describe('processOrder', () => { it('应处理基本订单', () => { const simpleOrder = createSimpleOrder(); expect(() => processOrder(simpleOrder)).not.to.throw(); }); });
-
特性标记:安全地重构代码
function calculateTotal(order) { if (useNewAlgorithm) { return newAlgorithm(order); } return oldAlgorithm(order); }
-
测试覆盖率引导:优先测试关键路径
# 生成覆盖率报告 npx nyc --reporter=html mocha
本站部分内容来自互联网,一切版权均归源网站或源作者所有。
如果侵犯了你的权益请来信告知我们删除。邮箱:cc@cccx.cn