1. Introduction โ
Automated tests give you confidence that your Node.js application behaves correctly โ both today and after every future change. This tutorial covers testing philosophy, popular frameworks, and practical techniques like mocking, coverage, and CI integration.
2. Why Test Node.js Applications? ๐ฏ
- Catches regressions before they reach production.
- Documents expected behavior through executable examples.
- Enables confident refactoring โ tests fail loudly if behavior changes unexpectedly.
- Speeds up debugging by isolating exactly which unit broke.
3. Types of Testing ๐งช
3.1 Unit Testing
Unit tests verify a single function or module in isolation, typically with dependencies mocked out.
3.2 Integration Testing
Integration tests verify that multiple units โ a route handler, a database, a service โ work correctly together.
3.3 End-to-End Testing
End-to-end (E2E) tests exercise the entire system as a real user would, often through a browser or full API calls against a running instance.
| Type | Scope | Speed |
|---|---|---|
| Unit | Single function/module | Very fast |
| Integration | Multiple units together | Moderate |
| End-to-End | Whole system | Slow |
Best Practice
4. Node.js Test Runner ๐
Since Node 18+, a built-in test runner (node:test) is available with no external dependencies required.
node-test-runner.test.js
const test = require('node:test');
const assert = require('node:assert');
test('adds two numbers', () => {
assert.strictEqual(1 + 2, 3);
});
test('async example', async () => {
const result = await Promise.resolve(42);
assert.strictEqual(result, 42);
});run-tests.sh
node --test5. Popular Testing Frameworks ๐งฐ
Jest is a full-featured, batteries-included framework: assertions, mocking, and coverage all built in.
jest-example.test.js
test('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});Vitest is a modern, fast alternative with a Jest-compatible API, built for ESM and Vite-based projects.
vitest-example.test.js
import { expect, test } from 'vitest';
test('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});Mocha provides the test runner and structure, while Chai supplies expressive assertions โ a flexible, unopinionated pairing.
mocha-chai-example.test.js
const { expect } = require('chai');
describe('Addition', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).to.equal(3);
});
});6. Supertest ๐ฎ
Supertest makes HTTP assertions easy by wrapping an http.Server instance (like an Express app) and letting you chain request builders directly.
supertest-example.test.js
const request = require('supertest');
const app = require('../app');
test('GET /users returns 200', async () => {
const response = await request(app).get('/users');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(3);
});7. Test Doubles: Mocking, Stubbing, Spying ๐ญ
7.1 Mocking
A mock replaces a real dependency with a fake implementation whose behavior and calls you can inspect.
mocking-example.test.js
jest.mock('../emailService');
const emailService = require('../emailService');
emailService.send.mockResolvedValue({ success: true });7.2 Stubbing
A stub replaces a function with one that returns a predetermined value, without necessarily tracking how it was called.
stubbing-example.test.js
const sinon = require('sinon');
const stub = sinon.stub(database, 'getUser').returns({ id: 1, name: 'Alice' });7.3 Spying
A spy wraps a real function, letting it still execute while recording how many times it was called and with what arguments.
spying-example.test.js
const spy = jest.spyOn(console, 'log');
logGreeting('Alice');
expect(spy).toHaveBeenCalledWith('Hello, Alice!');8. Testing Functions ๐ข
testing-functions.test.js
function add(a, b) {
return a + b;
}
test('add() returns the sum of two numbers', () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
});9. Testing Modules ๐ฆ
testing-modules.test.js
const { greet } = require('../greet');
describe('greet module', () => {
test('greets by name', () => {
expect(greet('Node')).toBe('Hello, Node!');
});
});10. Testing APIs ๐
testing-apis.test.js
const request = require('supertest');
const app = require('../app');
describe('POST /users', () => {
test('creates a new user', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Bob' });
expect(res.status).toBe(201);
expect(res.body.name).toBe('Bob');
});
});11. Testing Databases ๐๏ธ
Database tests typically use a dedicated test database or an in-memory alternative, resetting state before or after each test to keep them independent.
testing-databases.test.js
beforeEach(async () => {
await db.migrate.latest();
await db.seed.run();
});
afterEach(async () => {
await db('users').del();
});
test('creates a user record', async () => {
const user = await createUser({ name: 'Alice' });
expect(user.id).toBeDefined();
});12. Testing File Systems ๐
File system tests should avoid touching real project files โ use a temporary directory, or mock fs entirely to keep tests fast and side-effect free.
testing-file-systems.test.js
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs/promises');
test('writes and reads a file', async () => {
const tmpFile = path.join(os.tmpdir(), 'test-file.txt');
await fs.writeFile(tmpFile, 'hello');
const content = await fs.readFile(tmpFile, 'utf-8');
expect(content).toBe('hello');
await fs.unlink(tmpFile);
});13. Snapshot Testing ๐ธ
Snapshot tests capture the output of a function or component and compare future runs against that saved snapshot, flagging any unexpected change.
snapshot-testing.test.js
test('renders user profile correctly', () => {
const profile = renderUserProfile({ name: 'Alice', age: 30 });
expect(profile).toMatchSnapshot();
});Caution
14. Code Coverage ๐
Coverage reports show which lines, branches, and functions were actually exercised by your test suite โ useful for spotting untested code, though 100% coverage doesn't guarantee correctness.
coverage-command.sh
node --test --experimental-test-coverage
# or with Jest:
jest --coverage15. Continuous Integration ๐
CI pipelines automatically run your test suite on every push or pull request, catching regressions before they merge.
.github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test16. Debugging Tests ๐
debug-tests.sh
node --inspect-brk node_modules/.bin/jest --runInBandTip
17. Test Organization ๐๏ธ
- Mirror your src structure inside tests for easy navigation.
- Group related tests with describe() blocks by feature or module.
- Keep integration and unit tests in clearly separated directories.
18. Best Practices โ
- Write tests that are independent โ no test should rely on another running first.
- Prefer testing behavior (inputs and outputs) over internal implementation details.
- Keep test names descriptive: it('returns 404 when user does not exist') beats it('works').
- Reset mocks and test data between tests to avoid state leakage.
- Run tests automatically in CI on every pull request.
19. Common Mistakes โ ๏ธ
- Writing tests that depend on execution order or shared mutable state.
- Over-mocking to the point that tests no longer verify real behavior.
- Chasing 100% coverage as a goal in itself, rather than testing meaningful behavior.
- Forgetting to clean up test data (database rows, temp files) after each test.
- Committing outdated snapshots without reviewing what actually changed.