Node.js & Express

Testing Node.js Applications

30 min Lesson 26 of 40

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:

  1. Create a Post model with title, content, author, and createdAt fields
  2. Build Express routes for CRUD operations (GET all, GET by ID, POST, PUT, DELETE)
  3. Implement input validation middleware
  4. Write unit tests for the validation logic
  5. Write integration tests for all API endpoints using Supertest
  6. Mock database operations with Sinon stubs
  7. Achieve at least 85% code coverage
  8. 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.