Test-driven development
Basic Concepts of Test-Driven Development
Test-Driven Development (TDD) is a software development methodology that emphasizes writing test cases before writing the actual functional code. The development process follows the "Red-Green-Refactor" cycle: first write a failing test (Red), then write the simplest code to make the test pass (Green), and finally optimize the code structure (Refactor). This approach is particularly valuable in Node.js development because JavaScript's dynamic typing makes the code more prone to runtime errors.
// Example: A simple TDD cycle
// Step 1: Write a test (will fail)
const assert = require('assert');
assert.strictEqual(add(1, 2), 3); // The add function is not yet implemented
// Step 2: Implement the simplest functionality
function add(a, b) {
return a + b;
}
// Step 3: Refactor (if needed)
TDD Toolchain in Node.js
In the Node.js ecosystem, there are multiple testing frameworks that support the TDD workflow. The most popular combination includes:
- Mocha: Flexible testing framework
- Chai: Assertion library
- Sinon: For creating test doubles (stub/mock/spy)
- Istanbul/NYC: Code coverage tools
Typical installation commands for these tools:
npm install --save-dev mocha chai sinon nyc
Basic test file structure example:
// test/add.test.js
const chai = require('chai');
const expect = chai.expect;
const { add } = require('../src/math');
describe('Addition Function Test', () => {
it('should correctly calculate the sum of two numbers', () => {
expect(add(2, 3)).to.equal(5);
});
it('should handle negative number addition', () => {
expect(add(-1, -1)).to.equal(-2);
});
});
Specific Steps in TDD Practice
From Requirements Analysis to Test Cases
Suppose we need to develop a shopping cart discount calculation feature. First, break down the requirements into specific test scenarios:
- Empty cart returns 0
- Non-discounted items return the original price
- Single discounted item applies the discount
- Mixed calculation for multiple items
- Discount floor protection (not below 0)
Corresponding test cases might look like this:
describe('Shopping Cart Discount Calculation', () => {
let cart;
beforeEach(() => {
cart = new ShoppingCart();
});
it('empty cart should return 0', () => {
expect(cart.calculateDiscount()).to.equal(0);
});
it('non-discounted items should return the original price', () => {
cart.addItem({ price: 100, discount: 0 });
expect(cart.calculateDiscount()).to.equal(100);
});
it('should correctly apply a single item discount', () => {
cart.addItem({ price: 100, discount: 0.2 });
expect(cart.calculateDiscount()).to.equal(80);
});
});
From Tests to Implementation
Based on the above tests, gradually implement the shopping cart class:
// 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); // Ensure it's not below 0
}, 0);
}
}
Handling Asynchronous Code in TDD
Node.js heavily uses asynchronous operations, which require special handling in TDD. Take database operations as an example:
// test/user-repo.test.js
const { expect } = require('chai');
const UserRepository = require('../src/user-repo');
const db = require('./mock-db'); // Mock database
describe('User Repository', () => {
it('should find a user by ID', async () => {
const repo = new UserRepository(db);
const user = await repo.findById(1);
expect(user).to.have.property('name', 'John Doe');
});
it('should return null when user is not found', async () => {
const repo = new UserRepository(db);
const user = await repo.findById(999);
expect(user).to.be.null;
});
});
The corresponding implementation needs to consider asynchronous operations:
// 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);
}
}
Common Pitfalls and Solutions in TDD
Over-Mocking Problem
A common mistake beginners make is overusing mocks, causing tests to become disconnected from reality. For example:
// Bad practice: Over-mocking
it('should not over-mock like this', () => {
const db = {
query: sinon.stub().returns(Promise.resolve([{ id: 1, name: 'Test' }]))
};
const repo = new UserRepository(db);
// The test becomes meaningless
});
A better approach is to use an in-memory database or fixed test data:
// Better practice
const testDb = createTestDatabaseWithSampleData();
it('should use real query logic', async () => {
const repo = new UserRepository(testDb);
const user = await repo.findById(1);
expect(user.name).to.match(/[\u4e00-\u9fa5]+/); // Verify Chinese name
});
Test Granularity Too Fine or Too Coarse
Good tests should:
- Unit tests: Focus on a single function/method
- Integration tests: Verify interactions between modules
- Not test implementation details (e.g., internal variables)
// Bad test: Tests implementation details
it('should not test internal state', () => {
const cart = new ShoppingCart();
cart.addItem({ price: 100 });
expect(cart.items.length).to.equal(1); // Too dependent on implementation
});
// Good test: Tests behavior
it('should correctly calculate the total price', () => {
const cart = new ShoppingCart();
cart.addItem({ price: 100 });
expect(cart.getTotal()).to.equal(100); // Tests public interface
});
TDD in Complex Scenarios
Middleware Testing
Example of TDD for Express middleware:
// test/auth-middleware.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const authMiddleware = require('../src/middlewares/auth');
describe('Authentication Middleware', () => {
it('should reject requests without a 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('should allow valid tokens', () => {
const req = { headers: { authorization: 'valid-token' } };
const next = sinon.spy();
authMiddleware(req, {}, next);
expect(next.calledOnce).to.be.true;
});
});
Event-Driven Architecture
TDD for Node.js event emitters:
// 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('Order Service', () => {
it('should trigger the order_placed event', (done) => {
const service = new OrderService();
const testOrder = { id: 1 };
service.on('order_placed', (order) => {
expect(order).to.deep.equal(testOrder);
done();
});
service.placeOrder(testOrder);
});
});
Test Pyramid and TDD
A healthy test structure should follow the pyramid model:
- Many fast-running unit tests (bottom)
- Moderate number of integration tests (middle)
- Few end-to-end tests (top)
Typical distribution in Node.js:
// Unit tests (70%)
test/units/
└── services/
└── user-service.test.js
// Integration tests (20%)
test/integration/
└── api/
└── user-routes.test.js
// E2E tests (10%)
test/e2e/
└── checkout-flow.test.js
TDD in Continuous Integration
Modern CI/CD workflows can reinforce TDD practices. A basic GitHub Actions configuration example:
# .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
Corresponding package.json scripts:
{
"scripts": {
"test": "mocha test/**/*.test.js",
"coverage": "nyc mocha test/**/*.test.js",
"watch": "mocha --watch test/**/*.test.js"
}
}
Performance Testing and TDD
TDD can also be applied to performance-critical code. Example using 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('Linear search', () => {
linearSearch(sortedArray, 5000);
})
.add('Binary search', () => {
binarySearch(sortedArray, 5000);
})
.on('cycle', event => {
console.log(String(event.target));
})
.run();
Test Maintainability Tips
-
Descriptive Test Names: Use "should..." phrasing
// Bad it('test case 1', () => { ... }); // Good it('should return an error when negative numbers are passed', () => { ... });
-
Test Data Factories: Avoid repetitive test data creation
function createTestUser(overrides = {}) { return Object.assign({ id: 1, name: 'Test User', email: 'test@example.com' }, overrides); }
-
Custom Assertions: Improve test readability
chai.Assertion.addMethod('validUser', function() { this.assert( this._obj.name && this._obj.email, 'expected to be a valid user', 'expected to be an invalid user' ); }); // Usage expect(user).to.be.validUser;
TDD Strategies for Legacy Systems
Incremental approaches to introduce TDD to existing codebases:
-
Seam Testing: Add tests at the boundaries of existing code
// Legacy code function processOrder(order) { // Complex logic... } // New test describe('processOrder', () => { it('should process basic orders', () => { const simpleOrder = createSimpleOrder(); expect(() => processOrder(simpleOrder)).not.to.throw(); }); });
-
Feature Flags: Safely refactor code
function calculateTotal(order) { if (useNewAlgorithm) { return newAlgorithm(order); } return oldAlgorithm(order); }
-
Test Coverage Guidance: Prioritize testing critical paths
# Generate coverage report npx nyc --reporter=html mocha
本站部分内容来自互联网,一切版权均归源网站或源作者所有。
如果侵犯了你的权益请来信告知我们删除。邮箱:cc@cccx.cn