Testing in JavaScript: Unit, Integration, E2E — from A to Z
Developing a modern web application in JavaScript (especially on Node.js) is impossible without quality testing. Tests not only catch bugs before they reach production but also serve as living documentation and allow you to refactor code without fear of breaking something. In this article, we will break down the three main levels of the testing pyramid: unit, integration, and e2e. You will learn how they differ, which tools to use, and see ready-made examples on Node.js.
1. Unit tests: testing isolated modules
Unit tests are the foundation of the pyramid. They check the operation of individual functions, classes, or components in complete isolation from external dependencies (DB, API, file system). The main goal is to ensure that the module's logic works correctly with any input data.
For Node.js, the most popular framework is Jest (from Facebook). It requires no complex configuration and has built-in assertions, mocks, and code coverage.
Example: testing an array sorting function
// utils.jsfunction sortNumbers(arr) { if (!Array.isArray(arr)) { throw new Error('Input must be an array'); } return [...arr].sort((a, b) => a - b);}
module.exports = { sortNumbers };// utils.test.jsconst { sortNumbers } = require('./utils');
describe('sortNumbers', () => { test('should sort an array of numbers in ascending order', () => { expect(sortNumbers([3, 1, 2])).toEqual([1, 2, 3]); });
test('should handle an empty array', () => { expect(sortNumbers([])).toEqual([]); });
test('should throw error for non-array input', () => { expect(() => sortNumbers('string')).toThrow('Input must be an array'); });});Best practices for unit tests:
- One test — one check (one assert).
- Use
describeto group tests by function. - Mock external dependencies (using
jest.mock()). - Write tests before writing code (TDD — Test-Driven Development).
2. Integration tests: checking component interaction
Integration tests check how different modules of the system work together. For example, how your controller interacts with a database or an external API. Unlike unit tests, here we don't mock everything but use real (or test) environments.
For testing HTTP servers on Express, the Supertest library paired with Jest is an excellent choice.
Example: testing a REST endpoint
// app.jsconst express = require('express');const app = express();
app.get('/api/hello', (req, res) => { res.json({ message: 'Hello, World!' });});
app.post('/api/data', (req, res) => { const { name } = req.body; if (!name) { return res.status(400).json({ error: 'Name is required' }); } res.status(201).json({ received: name });});
module.exports = app;// app.test.jsconst request = require('supertest');const app = require('./app');
describe('Integration tests for API', () => { test('GET /api/hello should return a greeting', async () => { const response = await request(app) .get('/api/hello') .expect(200);
expect(response.body).toEqual({ message: 'Hello, World!' }); });
test('POST /api/data with valid body should return 201', async () => { const response = await request(app) .post('/api/data') .send({ name: 'Alice' }) .expect(201);
expect(response.body).toEqual({ received: 'Alice' }); });
test('POST /api/data without name should return 400', async () =>