Testing is a critical aspect of software development that ensures your application functions as expected. For Express.js applications, testing is crucial to verify that routes, middleware, and business logic work correctly under various conditions. In this chapter, we will explore the essentials of testing in Express.js, from basic concepts to advanced techniques. We will cover different types of tests, popular testing tools, and provide detailed examples with explanations. By the end of this chapter, you will have a comprehensive understanding of how to effectively test your Express.js applications.
Testing is the process of evaluating your application to identify any errors, bugs, or missing functionality. It involves running your application under various conditions to ensure it behaves as expected.
For testing Express.js applications, several tools are commonly used:
To get started, install the necessary testing tools:
npm install --save-dev mocha chai supertest sinon
In your package.json
file, add a script to run your tests:
{
"scripts": {
"test": "mocha --recursive"
}
}
This configuration will allow you to run your tests using the npm test
command.
Unit testing involves testing individual parts of your application in isolation, such as functions or methods. The goal is to ensure that each unit of your code performs as expected.
Let’s start with a simple utility function and write a unit test for it.
utils.js
function add(a, b) {
return a + b;
}
module.exports = add;
test/utils.test.js
const chai = require('chai');
const expect = chai.expect;
const add = require('../utils');
describe('add function', () => {
it('should return the sum of two numbers', () => {
const result = add(2, 3);
expect(result).to.equal(5);
});
it('should return a negative sum if both numbers are negative', () => {
const result = add(-2, -3);
expect(result).to.equal(-5);
});
});
describe
Block: Groups related tests. Here, we group tests for the add
function.it
Block: Defines individual test cases. Each it
block describes a specific scenario to test.expect
assertion style to verify that the add
function returns the correct sum.Output: Running npm test
will produce the following output:
add function
✓ should return the sum of two numbers
✓ should return a negative sum if both numbers are negative
2 passing (10ms)
This output indicates that both test cases have passed successfully.
Integration testing involves testing how different components of your application work together. In an Express.js application, this typically involves testing routes, middleware, and database interactions.
Let’s create a simple Express.js application and write an integration test for one of its routes.
app.js
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/greet', (req, res) => {
res.status(200).json({ message: 'Hello, World!' });
});
app.post('/api/greet', (req, res) => {
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
res.status(200).json({ message: `Hello, ${name}!` });
});
module.exports = app;
test/app.test.js
const request = require('supertest');
const app = require('../app');
describe('GET /api/greet', () => {
it('should return a greeting message', (done) => {
request(app)
.get('/api/greet')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
res.body.message === 'Hello, World!';
})
.end(done);
});
});
describe('POST /api/greet', () => {
it('should return a personalized greeting message', (done) => {
request(app)
.post('/api/greet')
.send({ name: 'John' })
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
res.body.message === 'Hello, John!';
})
.end(done);
});
it('should return a 400 error if name is missing', (done) => {
request(app)
.post('/api/greet')
.send({})
.expect(400)
.expect((res) => {
res.body.error === 'Name is required';
})
.end(done);
});
});
supertest
: This library allows us to test HTTP requests by simulating them against our Express.js application./api/greet
route returns a JSON response with the correct greeting message./api/greet
route returns a personalized greeting when a name is provided and a 400
error when it’s not.Output: Running npm test
will produce the following output:
GET /api/greet
✓ should return a greeting message
POST /api/greet
✓ should return a personalized greeting message
✓ should return a 400 error if name is missing
3 passing (15ms)
This output indicates that all integration tests have passed successfully.
End-to-End (E2E) testing simulates real user scenarios to ensure that the entire application works as expected from start to finish. E2E tests are generally conducted using tools like Selenium, Cypress, or Puppeteer.
In this example, we’ll use Cypress to test a simple user flow in our Express.js application. Let’s assume we have a basic front-end application that interacts with our Express.js backend.
cypress/integration/userFlow.spec.js
describe('User Flow', () => {
it('should allow a user to submit their name and receive a greeting', () => {
cy.visit('http://localhost:3000');
cy.get('input[name="name"]').type('John');
cy.get('button[type="submit"]').click();
cy.get('.greeting').should('contain', 'Hello, John!');
});
});
Output: Running Cypress will open a browser window where the test will be executed, and you will see whether the test passes or fails.
Mocking and stubbing are techniques used in testing to simulate the behavior of real objects and functions. This is especially useful when testing components that interact with external dependencies, like databases or APIs.
Let’s say we have a function that interacts with a database. We’ll use Sinon to mock this database interaction in our tests.
userService.js
const db = require('./db');
async function getUser(id) {
const user = await db.findUserById(id);
if (!user) {
throw new Error('User not found');
}
return user;
}
module.exports = getUser;
test/userService.test.js
const sinon = require('sinon');
const chai = require('chai');
const expect = chai.expect;
const getUser = require('../userService');
const db = require('../db');
describe('getUser', () => {
it('should return a user when found', async () => {
const mockUser = { id: 1, name: 'John' };
const stub = sinon.stub(db, 'findUserById').resolves(mockUser);
const user = await getUser(1);
expect(user).to.equal(mockUser);
stub.restore();
});
it('should throw an error when user is not found', async () => {
const stub = sinon.stub(db, 'findUserById').resolves(null);
try {
await getUser(1);
} catch (err) {
expect(err.message).to.equal('User not found');
}
stub.restore();
});
});
sinon.stub()
to replace the findUserById
function with a fake implementation for our test. This allows us to control the behavior of the function and simulate different scenarios.Output: Running npm test
will show whether the mocked database interactions produce the expected results.
Ensure that your tests are independent of each other. One test should not depend on the result of another test.
Focus on testing the critical paths of your application—the most common and important workflows that users will follow.
Your test names should clearly describe the behavior being tested. This makes it easier to understand the purpose of each test at a glance.
Run your tests frequently during development to catch issues early. Integrate testing into your CI/CD pipeline to automate this process.
Consider using Test-Driven Development (TDD), where you write tests before implementing the actual functionality. This can lead to cleaner, more reliable code.
Testing is an essential practice for building robust, reliable, and maintainable Express.js applications. By covering various aspects of testing—from unit tests to E2E tests, and understanding how to mock and stub dependencies—you can ensure your application behaves as expected in all scenarios. Happy coding !❤️