Testing Node.js Applications
Testing is a crucial part of building reliable and maintainable Node.js applications. In this lesson, we'll explore the most popular testing frameworks and tools in the Node.js ecosystem, including Mocha, Chai, Supertest, and Sinon. You'll learn how to write unit tests, integration tests, and API tests to ensure your code works as expected.
Why Testing Matters
Testing provides several important benefits:
- Confidence in Changes: Tests help you refactor and add features without breaking existing functionality
- Documentation: Tests serve as living documentation of how your code should behave
- Bug Prevention: Catch bugs early before they reach production
- Better Design: Writing testable code often leads to better architecture
- Faster Development: Automated tests are faster than manual testing
Note: The Node.js testing ecosystem has evolved significantly. While Mocha and Chai are still popular, Node.js now includes a built-in test runner (since v18), and alternatives like Jest and Vitest are also widely used.
Setting Up Your Testing Environment
First, let's install the necessary testing dependencies:
npm install --save-dev mocha chai supertest sinon
Update your package.json to add test scripts:
{
"scripts": {
"test": "mocha --exit",
"test:watch": "mocha --watch --exit",
"test:coverage": "nyc mocha --exit"
}
}
Understanding Mocha
Mocha is a flexible testing framework that provides the structure for organizing and running tests. It uses a describe/it syntax:
// test/math.test.js
const assert = require('assert');
describe('Math Operations', function() {
describe('Addition', function() {
it('should add two positive numbers', function() {
assert.strictEqual(2 + 2, 4);
});
it('should add negative numbers', function() {
assert.strictEqual(-5 + 3, -2);
});
it('should handle zero', function() {
assert.strictEqual(0 + 0, 0);
});
});
describe('Multiplication', function() {
it('should multiply two numbers', function() {
assert.strictEqual(3 * 4, 12);
});
});
});
Run your tests with:
npm test
Using Chai for Better Assertions
Chai provides more readable and expressive assertions than Node's built-in assert module. It offers three assertion styles: expect, should, and assert.
// test/user.test.js
const { expect } = require('chai');
describe('User Validation', function() {
it('should validate email format', function() {
const email = 'user@example.com';
expect(email).to.be.a('string');
expect(email).to.include('@');
expect(email).to.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
});
it('should validate password length', function() {
const password = 'securePassword123';
expect(password).to.have.lengthOf.at.least(8);
expect(password).to.not.be.empty;
});
it('should validate user object structure', function() {
const user = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
roles: ['user', 'admin']
};
expect(user).to.be.an('object');
expect(user).to.have.property('id');
expect(user).to.have.property('email').that.is.a('string');
expect(user.roles).to.be.an('array').that.includes('admin');
});
});
Tip: Chai's expect syntax is the most popular because it's readable and doesn't modify built-in prototypes (unlike the should style). Use expect for most testing scenarios.
Testing Asynchronous Code
Node.js is asynchronous by nature, so testing async code is essential. Mocha supports callbacks, promises, and async/await:
const { expect } = require('chai');
// Testing with callbacks
describe('Async Operations', function() {
it('should handle callbacks', function(done) {
setTimeout(() => {
expect(true).to.be.true;
done(); // Signal test completion
}, 100);
});
// Testing with promises
it('should handle promises', function() {
return Promise.resolve(42)
.then(result => {
expect(result).to.equal(42);
});
});
// Testing with async/await (recommended)
it('should handle async/await', async function() {
const result = await Promise.resolve(42);
expect(result).to.equal(42);
});
// Testing rejected promises
it('should handle promise rejection', async function() {
try {
await Promise.reject(new Error('Failed'));
expect.fail('Should have thrown');
} catch (error) {
expect(error.message).to.equal('Failed');
}
});
});
Warning: When testing callbacks, always call done(). If you forget, the test will timeout. For promises and async/await, Mocha handles completion automatically.
Testing Express APIs with Supertest
Supertest is specifically designed for testing HTTP servers. It makes API testing clean and simple:
// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/users', (req, res) => {
res.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]);
});
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email required' });
}
res.status(201).json({ id: 3, name, email });
});
app.get('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
if (id === 1) {
return res.json({ id: 1, name: 'Alice', email: 'alice@example.com' });
}
res.status(404).json({ error: 'User not found' });
});
module.exports = app;
// test/api.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../app');
describe('User API', function() {
describe('GET /api/users', function() {
it('should return all users', async function() {
const response = await request(app)
.get('/api/users')
.expect(200)
.expect('Content-Type', /json/);
expect(response.body).to.be.an('array');
expect(response.body).to.have.lengthOf(2);
expect(response.body[0]).to.have.property('name', 'Alice');
});
});
describe('POST /api/users', function() {
it('should create a new user', async function() {
const newUser = {
name: 'Charlie',
email: 'charlie@example.com'
};
const response = await request(app)
.post('/api/users')
.send(newUser)
.expect(201)
.expect('Content-Type', /json/);
expect(response.body).to.have.property('id');
expect(response.body).to.have.property('name', 'Charlie');
expect(response.body).to.have.property('email', 'charlie@example.com');
});
it('should reject invalid data', async function() {
const response = await request(app)
.post('/api/users')
.send({ name: 'Charlie' }) // Missing email
.expect(400);
expect(response.body).to.have.property('error');
});
});
describe('GET /api/users/:id', function() {
it('should return a specific user', async function() {
const response = await request(app)
.get('/api/users/1')
.expect(200);
expect(response.body).to.have.property('id', 1);
expect(response.body).to.have.property('name', 'Alice');
});
it('should return 404 for non-existent user', async function() {
await request(app)
.get('/api/users/999')
.expect(404);
});
});
});
Mocking with Sinon
Sinon provides tools for creating test doubles: spies, stubs, and mocks. These help you test code in isolation without dependencies.
// services/emailService.js
const sendEmail = async (to, subject, body) => {
// In reality, this would connect to an email service
console.log(`Sending email to ${to}`);
return { success: true, messageId: '12345' };
};
module.exports = { sendEmail };
// services/userService.js
const emailService = require('./emailService');
const createUser = async (userData) => {
// Save user to database (simplified)
const user = { id: 1, ...userData };
// Send welcome email
await emailService.sendEmail(
userData.email,
'Welcome!',
`Hello ${userData.name}, welcome to our platform!`
);
return user;
};
module.exports = { createUser };
// test/userService.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const userService = require('../services/userService');
const emailService = require('../services/emailService');
describe('User Service', function() {
describe('createUser', function() {
let emailStub;
beforeEach(function() {
// Create a stub for sendEmail before each test
emailStub = sinon.stub(emailService, 'sendEmail');
emailStub.resolves({ success: true, messageId: 'abc123' });
});
afterEach(function() {
// Restore the original function after each test
emailStub.restore();
});
it('should create a user and send welcome email', async function() {
const userData = {
name: 'John Doe',
email: 'john@example.com'
};
const user = await userService.createUser(userData);
expect(user).to.have.property('id');
expect(user).to.have.property('name', 'John Doe');
// Verify email was sent
expect(emailStub.calledOnce).to.be.true;
expect(emailStub.calledWith(
'john@example.com',
'Welcome!',
sinon.match.string
)).to.be.true;
});
it('should handle email sending failures', async function() {
emailStub.rejects(new Error('Email service unavailable'));
const userData = {
name: 'Jane Smith',
email: 'jane@example.com'
};
try {
await userService.createUser(userData);
expect.fail('Should have thrown');
} catch (error) {
expect(error.message).to.equal('Email service unavailable');
}
});
});
});
Using Spies to Monitor Function Calls
Spies let you track function calls without changing their behavior:
const { expect } = require('chai');
const sinon = require('sinon');
describe('Callback Spy Example', function() {
it('should track callback invocations', function() {
const callback = sinon.spy();
const processData = (data, cb) => {
data.forEach(item => cb(item));
};
processData([1, 2, 3], callback);
expect(callback.callCount).to.equal(3);
expect(callback.firstCall.args[0]).to.equal(1);
expect(callback.secondCall.args[0]).to.equal(2);
expect(callback.thirdCall.args[0]).to.equal(3);
});
});
Test Hooks: before, after, beforeEach, afterEach
Mocha provides hooks to set up and tear down test conditions:
const { expect } = require('chai');
describe('Test Hooks Example', function() {
let database;
// Runs once before all tests in this block
before(function() {
console.log('Connecting to database...');
database = { connected: true };
});
// Runs once after all tests in this block
after(function() {
console.log('Closing database connection...');
database = null;
});
// Runs before each test in this block
beforeEach(function() {
console.log('Setting up test data...');
database.testData = { users: [] };
});
// Runs after each test in this block
afterEach(function() {
console.log('Cleaning up test data...');
delete database.testData;
});
it('should have clean test data', function() {
expect(database.testData.users).to.be.empty;
database.testData.users.push({ id: 1 });
});
it('should have fresh test data again', function() {
// testData was cleaned up and recreated
expect(database.testData.users).to.be.empty;
});
});
Testing Middleware
Express middleware can be tested in isolation:
// middleware/auth.js
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
if (token !== 'Bearer valid-token') {
return res.status(403).json({ error: 'Invalid token' });
}
req.user = { id: 1, name: 'Test User' };
next();
};
module.exports = authMiddleware;
// test/middleware/auth.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const authMiddleware = require('../../middleware/auth');
describe('Auth Middleware', function() {
let req, res, next;
beforeEach(function() {
req = {
headers: {}
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub()
};
next = sinon.spy();
});
it('should reject requests without token', function() {
authMiddleware(req, res, next);
expect(res.status.calledWith(401)).to.be.true;
expect(res.json.calledWith({ error: 'No token provided' })).to.be.true;
expect(next.called).to.be.false;
});
it('should reject invalid tokens', function() {
req.headers.authorization = 'Bearer invalid-token';
authMiddleware(req, res, next);
expect(res.status.calledWith(403)).to.be.true;
expect(next.called).to.be.false;
});
it('should allow valid tokens', function() {
req.headers.authorization = 'Bearer valid-token';
authMiddleware(req, res, next);
expect(req.user).to.deep.equal({ id: 1, name: 'Test User' });
expect(next.calledOnce).to.be.true;
expect(res.status.called).to.be.false;
});
});
Code Coverage with NYC
Install NYC (Istanbul's command line interface) to measure code coverage:
npm install --save-dev nyc
Add coverage configuration to package.json:
{
"nyc": {
"reporter": ["text", "html", "lcov"],
"exclude": ["test/**", "node_modules/**"],
"all": true
}
}
Run tests with coverage:
npm run test:coverage
NYC will generate a report showing which lines of code are covered by tests.
Tip: Aim for at least 80% code coverage, but don't obsess over 100%. Focus on testing critical business logic and edge cases rather than chasing coverage numbers.
Best Practices for Testing
- Test Behavior, Not Implementation: Test what your code does, not how it does it
- One Assertion per Test: Keep tests focused on a single concern
- Descriptive Test Names: Use clear, meaningful test descriptions
- Arrange-Act-Assert: Structure tests with setup, execution, and verification phases
- Independent Tests: Each test should run independently without depending on other tests
- Fast Tests: Keep tests fast by mocking external dependencies
- DRY (Don't Repeat Yourself): Use hooks and helper functions to reduce duplication
Exercise: Build a Complete Test Suite
Create a simple blog API and write comprehensive tests:
- Create a Post model with title, content, author, and createdAt fields
- Build Express routes for CRUD operations (GET all, GET by ID, POST, PUT, DELETE)
- Implement input validation middleware
- Write unit tests for the validation logic
- Write integration tests for all API endpoints using Supertest
- Mock database operations with Sinon stubs
- Achieve at least 85% code coverage
- Add a test for pagination on the GET all posts endpoint
Bonus: Set up continuous integration with GitHub Actions to run tests automatically on every push.