Testing in Node.js

Node.js Intermediate — Testing in Node.js

Node.js Intermediate

Chapter 7 of 8  ·  Testing in Node.js

Testing in Node.js

Tests aren't just a safety net — they're the fastest way to get feedback on your code. A well-tested module can be refactored boldly; an untested one has to be touched carefully and deployed nervously. This chapter covers Jest, the dominant test runner in the Node ecosystem, from the basics of writing and structuring tests through mocking, async testing, and code coverage.

Test Types

Unit tests

Test a single function or class in isolation. Dependencies (database, network, file system) are replaced with mocks. Fast — thousands can run in seconds. The bulk of your test suite.

Integration tests

Test that two or more modules work correctly together — typically your code against a real (test) database. Slower, but catch bugs unit tests miss. Run before every commit or PR.

End-to-end tests

Drive the full application via HTTP (supertest) or a browser (Playwright/Cypress). Slowest and most brittle. Reserve for critical user journeys.

The rule of thumb

Write many unit tests, some integration tests, and few E2E tests. The faster a test is, the more of them you run, and the faster you get feedback.

Setting Up Jest

npm install --save-dev jest

package.json

{ "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" }, "jest": { "testEnvironment": "node", "testMatch": ["**/__tests__/**/*.js", "**/*.test.js"], "coverageDirectory": "coverage", "collectCoverageFrom": ["src/**/*.js", "!src/**/*.test.js"] } }

💡 File Naming Convention

The two common patterns are co-location (src/users.js + src/users.test.js) and a dedicated folder (src/users.js + __tests__/users.test.js). Co-location is generally preferred for unit tests — the test lives next to what it tests, making it obvious when a file has no test.

The Basic Structure — describe / it / expect

// math.js — the module being tested function add(a, b) { return a + b; } function divide(a, b) { if (b === 0) throw new Error('Division by zero'); return a / b; } module.exports = { add, divide };

math.test.js

const { add, divide } = require('./math'); describe('add()', () => { it('adds two positive numbers', () => { expect(add(2, 3)).toBe(5); }); it('handles negative numbers', () => { expect(add(-1, -3)).toBe(-4); }); }); describe('divide()', () => { it('divides two numbers', () => { expect(divide(10, 2)).toBe(5); }); it('throws on division by zero', () => { expect(() => divide(10, 0)).toThrow('Division by zero'); }); });

The Most Useful Matchers

// Equality expect(value).toBe(42); // strict equality (===), use for primitives expect(obj).toEqual({ a: 1 }); // deep equality, use for objects/arrays expect(obj).toStrictEqual({...}); // like toEqual but also checks undefined properties // Truthiness expect(value).toBeTruthy(); expect(value).toBeFalsy(); expect(value).toBeNull(); expect(value).toBeUndefined(); expect(value).toBeDefined(); // Numbers expect(n).toBeGreaterThan(0); expect(n).toBeLessThanOrEqual(100); expect(0.1 + 0.2).toBeCloseTo(0.3); // floating-point safe comparison // Strings expect(str).toContain('hello'); expect(str).toMatch(/^error:/i); // Arrays and objects expect(arr).toHaveLength(3); expect(arr).toContain('item'); expect(obj).toHaveProperty('user.email', 'alice@example.com'); // Errors expect(() => fn()).toThrow(); expect(() => fn()).toThrow('specific message'); expect(() => fn()).toThrow(TypeError); // Negation — prefix any matcher with .not expect(value).not.toBe(null); expect(arr).not.toContain('deleted-item');

Setup and Teardown

describe('UserService', () => { let service; beforeAll(async () => { // Runs once before the entire describe block // Use for expensive setup: starting a DB, seeding fixtures await seedTestDatabase(); }); afterAll(async () => { // Runs once after all tests in the block — clean up resources await pool.end(); }); beforeEach(() => { // Runs before each test — reset state so tests don't bleed into each other service = new UserService(); }); afterEach(async () => { // Runs after each test — undo mutations await clearTestUsers(); jest.clearAllMocks(); // reset mock call counts and implementations }); it('creates a user', async () => { const id = await service.create({ name: 'Alice', email: 'a@b.com' }); expect(id).toBeDefined(); }); });

Testing Async Code

// Jest handles async/await natively — just mark the test function async it('fetches a user by ID', async () => { const user = await findUserById(1); expect(user).toEqual({ id: 1, name: 'Alice', email: 'alice@example.com' }); }); // Testing that a Promise rejects it('throws when user is not found', async () => { await expect(findUserById(9999)).rejects.toThrow('User not found'); }); // Alternatively — use try/catch it('throws a specific error type', async () => { try { await findUserById(9999); expect.fail(); // fail if no error was thrown } catch (err) { expect(err).toBeInstanceOf(NotFoundError); expect(err.message).toContain('9999'); } });

Mocking — jest.fn() and jest.mock()

Unit tests should run without real databases, real email servers, or real APIs. Mocking lets you replace dependencies with controlled fakes that record how they were called.

// jest.fn() — a mock function you can configure and interrogate const sendEmail = jest.fn(); sendEmail.mockResolvedValue({ messageId: 'abc123' }); // async — always resolves // sendEmail.mockReturnValue(x) — sync — always returns x // sendEmail.mockRejectedValue(err) — async — always rejects // sendEmail.mockReturnValueOnce(x) — returns x only on the next call // sendEmail.mockImplementation(fn) — custom implementation await sendEmail({ to: 'bob@example.com', subject: 'Hello' }); // Interrogate the mock expect(sendEmail).toHaveBeenCalledTimes(1); expect(sendEmail).toHaveBeenCalledWith({ to: 'bob@example.com', subject: 'Hello', }); expect(sendEmail).toHaveBeenLastCalledWith(expect.objectContaining({ to: 'bob@example.com' }));
// jest.mock() — replaces an entire module with auto-mocked version jest.mock('./emailService'); const emailService = require('./emailService'); // All exports are now jest.fn() — they return undefined by default emailService.send.mockResolvedValue({ sent: true }); // Manual mock — control exactly what the module exports jest.mock('./db', () => ({ execute: jest.fn().mockResolvedValue([[{ id: 1, name: 'Alice' }]]), end: jest.fn(), }));
// jest.spyOn() — mock a method on a real object, leaving others intact const fs = require('fs'); const readSpy = jest.spyOn(fs, 'readFileSync') .mockReturnValue('mocked file content'); expect(parseConfig('./config.json')).toEqual({ ... }); expect(readSpy).toHaveBeenCalledWith('./config.json', 'utf8'); readSpy.mockRestore(); // restore the original fs.readFileSync after the test

A Real Unit Test — UserService with Mocked DB

userService.test.js

jest.mock('./db'); // hoisted to the top by Jest — runs before any require() const db = require('./db'); const UserService = require('./userService'); describe('UserService.findById', () => { afterEach(() => jest.clearAllMocks()); it('returns the user when found', async () => { db.execute.mockResolvedValue([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); const user = await UserService.findById(1); expect(user).toEqual({ id: 1, name: 'Alice', email: 'a@b.com' }); expect(db.execute).toHaveBeenCalledWith( 'SELECT * FROM users WHERE id = ? LIMIT 1', [1] ); }); it('returns null when user is not found', async () => { db.execute.mockResolvedValue([[]]); // empty result set const user = await UserService.findById(999); expect(user).toBeNull(); }); it('propagates database errors', async () => { db.execute.mockRejectedValue(new Error('Connection lost')); await expect(UserService.findById(1)) .rejects.toThrow('Connection lost'); }); });

Code Coverage

# Run with coverage report npx jest --coverage # Output shows four metrics per file: # Stmts — % of statements executed # Branch — % of if/else/ternary branches taken # Funcs — % of functions called # Lines — % of lines executed

💡 Coverage Is a Tool, Not a Target

100% coverage doesn't mean your code is correct — it means every line was executed at least once. A test that calls a function without asserting anything gives coverage without confidence. Chase meaningful assertions, not a coverage number. A good starting goal for most projects is 70–80% branch coverage on business logic, with the gaps consciously chosen (e.g., error paths that are genuinely hard to trigger).

Integration Testing with a Real Database

// jest.config.js — use a separate setup file for integration tests module.exports = { projects: [ { displayName: 'unit', testMatch: ['**/*.test.js'] }, { displayName: 'integration', testMatch: ['**/*.int-test.js'], globalSetup: './tests/setup/globalSetup.js', globalTeardown: './tests/setup/globalTeardown.js' }, ], };

tests/setup/globalSetup.js

// Runs once before the entire integration test suite module.exports = async () => { process.env.DB_NAME = 'app_test'; await runMigrations(); await seedFixtures(); };

userRepository.int-test.js

const { createUser, findUserById, deleteUser } = require('./userRepository'); const pool = require('./db'); afterAll(async () => pool.end()); describe('UserRepository (integration)', () => { let createdId; it('creates and retrieves a user', async () => { createdId = await createUser({ name: 'Bob', email: 'bob@test.com' }); const user = await findUserById(createdId); expect(user.name).toBe('Bob'); expect(user.email).toBe('bob@test.com'); }); afterEach(async () => { if (createdId) await deleteUser(createdId); }); });

⚠️ Common Testing Mistakes

Tests that depend on each other — each test must be able to run in any order and in isolation. Shared state between tests causes flaky results that are very hard to debug. Use beforeEach/afterEach to reset state.

Asserting implementation, not behaviour — checking that a specific private method was called makes tests brittle. Test what the function returns or what side effects it causes, not how it does it internally.

Not calling jest.clearAllMocks() between tests — mock call counts accumulate across tests in the same file unless you reset them. jest.clearAllMocks() in afterEach prevents one test's calls from polluting the next test's toHaveBeenCalledTimes assertion.

Forgetting to await async matchersexpect(promise).rejects.toThrow() returns a Promise. Without await, the test passes regardless of whether the assertion holds.

Coding Challenges

Challenge 1 — Test a Password Utility

Write a passwordUtils.js module with two functions: hashPassword(plain) (uses bcrypt with 10 salt rounds, returns a Promise) and verifyPassword(plain, hash) (returns a Promise resolving to a boolean). Then write a complete Jest test suite covering: correct passwords verify, wrong passwords don't, the hash is never equal to the plain text, and the hash is different on every call even for the same input. Use npm install bcrypt.

View sample solution ↗

Challenge 2 — Mock an External API

Write a weatherService.js that fetches current weather from an external HTTP API using Node's built-in fetch (Node 18+). Then write tests that mock fetch using jest.spyOn(global, 'fetch') to test: successful response returns parsed weather data, a 404 throws a NotFoundError, a network failure (fetch rejects) throws a NetworkError, and the correct URL was called with the city name. No real HTTP requests should be made during the test run.

View sample solution ↗

Challenge 3 — Test a Rate Limiter

Write a RateLimiter class that allows at most N calls per windowMs milliseconds per key (e.g. an IP address). The check(key) method returns { allowed: boolean, remaining: number, resetAt: number }. Write tests covering: first call is allowed, calls within the limit are allowed, the (N+1)th call is denied, and the window resets after windowMs. Use jest.useFakeTimers() and jest.advanceTimersByTime() to control time without actually waiting.

View sample solution ↗