Jest Unit Tests — Testing Services, Middleware, and Utilities

Unit tests for Express applications focus on the business logic inside service and utility functions — the code that transforms data, applies rules, and makes decisions — in complete isolation from the database, HTTP layer, and external services. Jest is the go-to test runner for Node.js, providing a fast test runner, built-in mocking, snapshot testing, and a clean assertion API. Testing service methods in isolation is the fastest way to get comprehensive coverage of your application’s business rules.

Jest Core API for Node.js

Function Purpose Example
describe(name, fn) Group related tests describe('TaskService', () => { ... })
it(name, fn) / test() Individual test case it('should hash password', async () => { ... })
expect(value) Start an assertion chain expect(result).toBe(42)
beforeEach(fn) Run before each test in describe block Set up mocks, create test data
afterEach(fn) Run after each test Clean up mocks, restore state
jest.fn() Create a mock function const mockSave = jest.fn().mockResolvedValue(user)
jest.spyOn(obj, 'method') Spy on an existing method jest.spyOn(bcrypt, 'hash').mockResolvedValue('hashed')
jest.mock('module') Auto-mock an entire module jest.mock('../services/email.service')
jest.clearAllMocks() Reset all mock call history In afterEach

Common Jest Matchers

Matcher Asserts
.toBe(val) Strict equality (===)
.toEqual(val) Deep equality (objects/arrays)
.toMatchObject(partial) Object contains at least these properties
.toBeTruthy() / .toBeFalsy() Truthy/falsy value
.toBeNull() Strictly null
.toContain(item) Array contains item or string contains substring
.toHaveLength(n) Array or string length
.toThrow(error) Function throws an error
.rejects.toThrow() Async function rejects
.resolves.toEqual(val) Async function resolves with value
.toHaveBeenCalledWith(args) Mock was called with specific args
.toHaveBeenCalledTimes(n) Mock was called n times
Note: Jest auto-mocking with jest.mock('../services/email.service') replaces every export from the module with a jest.fn() that returns undefined by default. This is the cleanest way to prevent side effects (sending actual emails, calling external APIs) during unit tests. The mock is hoisted to the top of the test file — even before imports. If you need to control what the mock returns, use mockResolvedValue() or mockReturnValue() on the function.
Tip: Test error cases as thoroughly as success cases. A function that returns the right result for valid inputs but throws unhandled errors for edge cases (empty strings, null inputs, invalid IDs) is half-tested. For every success path, write at least one error path test: what happens with an invalid email, a null password, a duplicate key error. Error path tests often catch the most real bugs because production data is messier than the happy path assumes.
Warning: Avoid testing implementation details — test behaviour. If your service calls User.findOne() internally, do not assert expect(User.findOne).toHaveBeenCalledWith({ email }) unless you specifically need to verify the query. Instead, test what the caller cares about: the return value, the state change, or the error thrown. Tests that are coupled to implementation details break whenever you refactor, even when the behaviour is unchanged.

Complete Unit Test Examples

// ── Unit tests: auth service ──────────────────────────────────────────────
// src/__tests__/unit/auth.service.test.js
const bcrypt = require('bcryptjs');
const jwt    = require('jsonwebtoken');

// Module under test
const { hashPassword, verifyPassword, generateTokenPair, verifyToken }
    = require('../../services/auth.service');

describe('Auth Service', () => {

    describe('hashPassword()', () => {
        it('should return a bcrypt hash', async () => {
            const hash = await hashPassword('MyPassword123!');
            expect(hash).not.toBe('MyPassword123!');
            expect(hash).toMatch(/^\$2[aby]\$\d{2}\$/);  // bcrypt hash format
        });

        it('should produce different hashes for the same password', async () => {
            const [h1, h2] = await Promise.all([
                hashPassword('same-password'),
                hashPassword('same-password'),
            ]);
            expect(h1).not.toBe(h2);  // different salts → different hashes
        });

        it('should reject empty password', async () => {
            await expect(hashPassword('')).rejects.toThrow();
        });
    });

    describe('verifyPassword()', () => {
        let hash;
        beforeAll(async () => { hash = await bcrypt.hash('correct-password', 12); });

        it('should return true for correct password', async () => {
            expect(await verifyPassword('correct-password', hash)).toBe(true);
        });

        it('should return false for wrong password', async () => {
            expect(await verifyPassword('wrong-password', hash)).toBe(false);
        });
    });

    describe('generateTokenPair()', () => {
        const mockUser = { _id: '64a1f2b3c8e4d5f6a7b8c9d1', email: 'test@test.com', role: 'user' };

        it('should return accessToken and refreshToken', () => {
            const { accessToken, refreshToken } = generateTokenPair(mockUser);
            expect(accessToken).toBeDefined();
            expect(refreshToken).toBeDefined();
            expect(typeof accessToken).toBe('string');
        });

        it('should embed user data in access token', () => {
            const { accessToken } = generateTokenPair(mockUser);
            const decoded = jwt.decode(accessToken);
            expect(decoded.sub).toBe(mockUser._id.toString());
            expect(decoded.role).toBe(mockUser.role);
        });

        it('should set short expiry on access token', () => {
            const { accessToken } = generateTokenPair(mockUser);
            const decoded = jwt.decode(accessToken);
            const expiresInSeconds = decoded.exp - decoded.iat;
            expect(expiresInSeconds).toBeLessThanOrEqual(60 * 60);  // max 1 hour
        });
    });
});

// ── Unit tests: task query builder ────────────────────────────────────────
// src/__tests__/unit/task-query.test.js
const { parseTaskQuery } = require('../../utils/task-query.util');

describe('parseTaskQuery()', () => {
    it('should use safe defaults', () => {
        const result = parseTaskQuery({});
        expect(result.page).toBe(1);
        expect(result.limit).toBe(10);
        expect(result.sort).toEqual({ createdAt: -1 });
        expect(result.filter).not.toHaveProperty('status');
    });

    it('should parse valid status filter', () => {
        const { filter } = parseTaskQuery({ status: 'pending' });
        expect(filter.status).toBe('pending');
    });

    it('should ignore invalid status', () => {
        const { filter } = parseTaskQuery({ status: 'invalid-status' });
        expect(filter).not.toHaveProperty('status');
    });

    it('should cap limit at 50', () => {
        expect(parseTaskQuery({ limit: '999' }).limit).toBe(50);
    });

    it('should reject sort on disallowed field', () => {
        const { sort } = parseTaskQuery({ sort: 'password' });
        expect(sort).toEqual({ createdAt: -1 });  // falls back to default
    });

    it('should parse descending sort', () => {
        const { sort } = parseTaskQuery({ sort: '-priority' });
        expect(sort).toEqual({ priority: -1 });
    });

    it('should build text search filter', () => {
        const { filter } = parseTaskQuery({ q: 'client meeting' });
        expect(filter.$text).toEqual({ $search: 'client meeting' });
    });

    it('should sanitise overly long search query', () => {
        const { filter } = parseTaskQuery({ q: 'a'.repeat(200) });
        expect(filter.$text.$search.length).toBeLessThanOrEqual(100);
    });

    it('should parse date range', () => {
        const { filter } = parseTaskQuery({ from: '2025-01-01', to: '2025-01-31' });
        expect(filter.createdAt.$gte).toEqual(new Date('2025-01-01'));
        expect(filter.createdAt.$lte).toEqual(new Date('2025-01-31'));
    });
});

// ── Unit tests: auth middleware ────────────────────────────────────────────
// src/__tests__/unit/auth.middleware.test.js
const jwt = require('jsonwebtoken');
const { verifyAccessToken } = require('../../middleware/auth.middleware');

describe('verifyAccessToken middleware', () => {
    let req, res, next;
    const SECRET = 'test-secret';

    beforeEach(() => {
        process.env.JWT_SECRET = SECRET;
        req  = { headers: {} };
        res  = { status: jest.fn().mockReturnThis(), json: jest.fn() };
        next = jest.fn();
    });

    afterEach(() => jest.clearAllMocks());

    it('should call next() with valid token', () => {
        const token = jwt.sign({ sub: '123', role: 'user' }, SECRET, { expiresIn: '1h' });
        req.headers.authorization = `Bearer ${token}`;
        verifyAccessToken(req, res, next);
        expect(next).toHaveBeenCalledTimes(1);
        expect(req.user.sub).toBe('123');
    });

    it('should return 401 when no token', () => {
        verifyAccessToken(req, res, next);
        expect(res.status).toHaveBeenCalledWith(401);
        expect(next).not.toHaveBeenCalled();
    });

    it('should return 401 for expired token', () => {
        const token = jwt.sign({ sub: '123' }, SECRET, { expiresIn: '-1s' });
        req.headers.authorization = `Bearer ${token}`;
        verifyAccessToken(req, res, next);
        expect(res.status).toHaveBeenCalledWith(401);
        const body = res.json.mock.calls[0][0];
        expect(body.code).toBe('TOKEN_EXPIRED');
    });

    it('should return 401 for tampered token', () => {
        const token = jwt.sign({ sub: '123', role: 'user' }, SECRET);
        const tampered = token.slice(0, -5) + 'xxxxx';
        req.headers.authorization = `Bearer ${tampered}`;
        verifyAccessToken(req, res, next);
        expect(res.status).toHaveBeenCalledWith(401);
    });
});

How It Works

Step 1 — Describe Blocks Organise Tests by Feature

Nested describe() blocks create a hierarchy that mirrors the code structure. describe('TaskService') > describe('parseTaskQuery()') > it('should use safe defaults') produces readable test output: TaskService > parseTaskQuery() > should use safe defaults. This makes failing test output immediately point to the exact feature and scenario that broke.

Step 2 — jest.fn() Creates Controllable Mock Dependencies

A mock function records every call made to it — arguments, return values, call count. mockResolvedValue(data) makes it return a resolved Promise with data. mockRejectedValue(new Error()) makes it throw. This allows testing how your code handles various responses from dependencies (success, error, empty) without those dependencies actually running.

Step 3 — Test the Contract, Not the Implementation

The best unit tests describe the function’s contract from the caller’s perspective: “given this input, return this output” or “given this condition, throw this error.” Tests that assert on internal implementation (which internal method was called, how many times, with which sub-expressions) become test maintenance debt — they break whenever the implementation changes even if the contract is unchanged.

Step 4 — Async Tests Require Proper async/await

Jest handles async tests via either returning a Promise or using async/await in the test function. Without awaiting the async function, the test completes synchronously before the assertion runs — always passing even if the function throws. Always declare async test functions with async and await all async operations. For error cases, use await expect(fn()).rejects.toThrow().

Step 5 — beforeEach and afterEach Manage Shared State

beforeEach resets mock implementations and shared variables before every test — preventing test pollution where mock state from test A affects test B. jest.clearAllMocks() in afterEach clears the call history of all mocks. jest.restoreAllMocks() restores any methods that were spied on with spyOn back to their original implementations.

Common Mistakes

Mistake 1 — Forgetting to await async operations

❌ Wrong — test passes without actually running the assertion:

it('should hash password', () => {
    const hash = hashPassword('test');  // returns Promise — not awaited!
    expect(hash).not.toBe('test');      // asserts on a Promise object, not the hash
    // Test passes (Promise is truthy and !== 'test') but is meaningless
});

✅ Correct — await the async function:

it('should hash password', async () => {
    const hash = await hashPassword('test');  // resolved value
    expect(hash).not.toBe('test');            // correct assertion on the string
});

Mistake 2 — Testing implementation rather than behaviour

❌ Wrong — breaks if internal implementation changes:

expect(Task.findOne).toHaveBeenCalledWith({ _id: id, user: userId });
// Refactor to findById — test breaks even though result is the same!

✅ Correct — test the return value:

const task = await taskService.getById(id, userId);
expect(task._id.toString()).toBe(id);
expect(task.user.toString()).toBe(userId);

Mistake 3 — Not clearing mocks between tests

❌ Wrong — mock call count accumulates across tests:

it('test 1', () => { emailService.send(); expect(emailService.send).toHaveBeenCalledTimes(1); });
it('test 2', () => { emailService.send(); expect(emailService.send).toHaveBeenCalledTimes(1); });
// Test 2 fails — mock was called twice total (1 from test 1 + 1 from test 2)

✅ Correct — clear mocks in afterEach:

afterEach(() => jest.clearAllMocks());

Quick Reference

Task Jest Code
Mock function const mockFn = jest.fn().mockResolvedValue(data)
Spy on method jest.spyOn(obj, 'method').mockResolvedValue(data)
Mock entire module jest.mock('../service')
Assert call args expect(mock).toHaveBeenCalledWith(arg1, arg2)
Assert call count expect(mock).toHaveBeenCalledTimes(1)
Assert async throws await expect(fn()).rejects.toThrow('message')
Assert async resolves await expect(fn()).resolves.toEqual(expected)
Deep equality expect(obj).toEqual({ key: 'value' })
Partial match expect(obj).toMatchObject({ key: 'value' })
Clear mocks afterEach(() => jest.clearAllMocks())

🧠 Test Yourself

A test for verifyAccessToken middleware needs to check that a 401 is returned for an expired JWT. What does the test need to provide and assert?