Skip to content
Express.js ex testing 5 min read

Unit Testing with Jest

Unit tests verify a single piece of logic in isolation — a service function, a validator, or a controller — without spinning up an HTTP server or touching a real database. Jest is the most popular test runner for Node and Express projects because it bundles a test framework, an assertion library, mocking, and coverage into one zero-config tool. This page shows how to install Jest, test pure functions and Express controllers with mocked req/res objects, and structure your test files so the suite stays fast and maintainable.

Setting up Jest

Install Jest as a dev dependency and add a test script. For a CommonJS project no extra configuration is needed; Jest discovers any file ending in .test.js or .spec.js, or anything inside a __tests__ folder.

npm install --save-dev jest

Add the script to package.json:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  }
}

Run the suite:

npm test

Output:

PASS  src/services/price.test.js
  calculateTotal
    ✓ sums line items (2 ms)
    ✓ applies a percentage discount (1 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Time:        0.612 s

If your project uses ES modules ("type": "module" in package.json), run Jest with node --experimental-vm-modules node_modules/jest/bin/jest.js, or add Babel via babel-jest. CommonJS requires no such workaround.

Testing pure functions

Pure functions — those that return a value based only on their inputs — are the easiest and most valuable things to unit test. Keep your business logic in plain service modules, free of req/res, so it can be tested directly.

// src/services/price.js
function calculateTotal(items, discountPct = 0) {
  const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0);
  return subtotal * (1 - discountPct / 100);
}

module.exports = { calculateTotal };
// src/services/price.test.js
const { calculateTotal } = require('./price');

describe('calculateTotal', () => {
  test('sums line items', () => {
    const items = [
      { price: 10, qty: 2 },
      { price: 5, qty: 1 },
    ];
    expect(calculateTotal(items)).toBe(25);
  });

  test('applies a percentage discount', () => {
    const items = [{ price: 100, qty: 1 }];
    expect(calculateTotal(items, 10)).toBe(90);
  });
});

describe groups related tests, test (or its alias it) declares a single case, and expect(...).matcher(...) makes an assertion. Common matchers are summarised below.

MatcherPasses when
toBe(x)strict === equality (primitives)
toEqual(x)deep structural equality (objects/arrays)
toThrow(msg)the wrapped function throws
toHaveBeenCalledWith(...)a mock was called with those args
resolves / rejectsa promise settles as expected

Testing controllers with mocked req/res

A controller is just a function of (req, res, next). To unit test one you build fake req and res objects, call the controller, then assert on how it used them. Because res.status() is chained with res.json(), the status mock must return the response object so the chain works.

// src/controllers/userController.js
const userService = require('../services/userService');

async function getUser(req, res, next) {
  try {
    const user = await userService.findById(req.params.id);
    if (!user) {
      return res.status(404).json({ error: 'Not found' });
    }
    res.status(200).json(user);
  } catch (err) {
    next(err);
  }
}

module.exports = { getUser };
// src/controllers/userController.test.js
const { getUser } = require('./userController');
const userService = require('../services/userService');

jest.mock('../services/userService'); // auto-mock the module

function mockRes() {
  const res = {};
  res.status = jest.fn().mockReturnValue(res); // enable chaining
  res.json = jest.fn().mockReturnValue(res);
  return res;
}

describe('getUser controller', () => {
  afterEach(() => jest.clearAllMocks());

  test('returns 200 with the user when found', async () => {
    userService.findById.mockResolvedValue({ id: '1', name: 'Ada' });
    const req = { params: { id: '1' } };
    const res = mockRes();
    const next = jest.fn();

    await getUser(req, res, next);

    expect(userService.findById).toHaveBeenCalledWith('1');
    expect(res.status).toHaveBeenCalledWith(200);
    expect(res.json).toHaveBeenCalledWith({ id: '1', name: 'Ada' });
    expect(next).not.toHaveBeenCalled();
  });

  test('returns 404 when the user is missing', async () => {
    userService.findById.mockResolvedValue(null);
    const req = { params: { id: '99' } };
    const res = mockRes();

    await getUser(req, res, jest.fn());

    expect(res.status).toHaveBeenCalledWith(404);
    expect(res.json).toHaveBeenCalledWith({ error: 'Not found' });
  });

  test('forwards errors to next()', async () => {
    const boom = new Error('db down');
    userService.findById.mockRejectedValue(boom);
    const next = jest.fn();

    await getUser({ params: { id: '1' } }, mockRes(), next);

    expect(next).toHaveBeenCalledWith(boom);
  });
});

Mocks and spies

jest.fn() creates a mock function that records every call. jest.mock('module') replaces an entire module with auto-generated mocks so the controller never reaches the real service or database. When you only want to observe (and optionally override) an existing method, use jest.spyOn:

const logger = require('../utils/logger');

test('logs a warning on missing user', async () => {
  const warn = jest.spyOn(logger, 'warn').mockImplementation(() => {});
  // ... run code that should warn ...
  expect(warn).toHaveBeenCalledTimes(1);
  warn.mockRestore(); // restore the original implementation
});

A spy created with mockImplementation keeps the original method swapped out until you call mockRestore(). Forgetting to restore it can leak mocked behaviour into later tests, so prefer afterEach(() => jest.restoreAllMocks()).

Structuring test files

Co-locate each test beside the module it covers (price.jsprice.test.js) so related code moves together and imports stay short. Reserve a top-level __tests__ directory only for cross-cutting integration suites. Use beforeEach/afterEach hooks to reset state, and keep each test focused on one behaviour so a failure points straight at the cause.

Best Practices

  • Keep business logic in framework-agnostic service modules so it can be unit tested without req/res or HTTP.
  • Always return the res object from mocked status() and json() so chained calls behave like real Express.
  • Reset mocks between tests with jest.clearAllMocks() in afterEach to avoid cross-test contamination.
  • Assert on next (called or not called) to verify error-forwarding paths in controllers.
  • Prefer toEqual for objects and arrays, and reserve toBe for primitives and reference identity.
  • Use --watch locally for fast feedback and reserve full runs with coverage for CI.
  • Test edge cases (empty inputs, nulls, rejections), not just the happy path.
Last updated June 14, 2026
Was this helpful?