Testing with Jest

Unit tests, mocking, and Supertest integration tests for an Express route.

Why Jest

Jest is the most widely used testing framework in the Node.js/JavaScript ecosystem — a test runner, an assertion library (expect), and a mocking system bundled together with no separate configuration needed to get started:

Bash
npm install --save-dev jest supertest
JSON
{
  "scripts": {
    "test": "jest"
  }
}

Anatomy of a test

describe groups related tests, test (or its alias it) defines one, and expect makes an assertion:

Javascript
// price.js
export function calculateTotal(items, taxRate) {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  return Math.round(subtotal * (1 + taxRate) * 100) / 100;
}
Javascript
// price.test.js
import { calculateTotal } from './price';

describe('calculateTotal', () => {
  test('sums item prices and applies tax', () => {
    const items = [{ price: 10, quantity: 2 }, { price: 5, quantity: 1 }];
    expect(calculateTotal(items, 0.1)).toBe(27.5); // (20 + 5) * 1.1
  });

  test('returns 0 for an empty cart', () => {
    expect(calculateTotal([], 0.1)).toBe(0);
  });
});
Bash
npx jest
#  PASS  ./price.test.js
#  calculateTotal
#    ✓ sums item prices and applies tax (2 ms)
#    ✓ returns 0 for an empty cart

This is a unit testcalculateTotal is pure logic with no database, no network, and no filesystem involved, so it runs in milliseconds and needs nothing set up beforehand.

Testing an Express route: integration tests with Supertest

Supertest drives real HTTP requests against an Express app in-process, without actually binding to a network port — you get real routing, real middleware, and real status codes, without the overhead or flakiness of managing an actual running server for tests:

Javascript
// app.js — a small Express app, exported (not started) so tests can import it directly
import express from 'express';

export function createApp(usersRepository) {
  const app = express();
  app.use(express.json());

  app.get('/users/:id', async (req, res) => {
    const user = await usersRepository.findById(req.params.id);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.json(user);
  });

  app.post('/users', async (req, res) => {
    const { name } = req.body;
    if (!name) {
      return res.status(400).json({ error: 'name is required' });
    }
    const user = await usersRepository.create({ name });
    res.status(201).json(user);
  });

  return app;
}
Javascript
// app.test.js
import request from 'supertest';
import { createApp } from './app';

describe('GET /users/:id', () => {
  test('returns the user when found', async () => {
    const fakeRepository = { findById: jest.fn().mockResolvedValue({ id: 1, name: 'Ali Raza' }) };
    const app = createApp(fakeRepository);

    const response = await request(app).get('/users/1');

    expect(response.status).toBe(200);
    expect(response.body).toEqual({ id: 1, name: 'Ali Raza' });
    expect(fakeRepository.findById).toHaveBeenCalledWith('1');
  });

  test('returns 404 when the user does not exist', async () => {
    const fakeRepository = { findById: jest.fn().mockResolvedValue(null) };
    const app = createApp(fakeRepository);

    const response = await request(app).get('/users/999');

    expect(response.status).toBe(404);
    expect(response.body).toEqual({ error: 'User not found' });
  });
});

describe('POST /users', () => {
  test('creates a user and returns 201', async () => {
    const fakeRepository = {
      create: jest.fn().mockResolvedValue({ id: 2, name: 'Zara Khan' }),
    };
    const app = createApp(fakeRepository);

    const response = await request(app).post('/users').send({ name: 'Zara Khan' });

    expect(response.status).toBe(201);
    expect(response.body.name).toBe('Zara Khan');
  });

  test('returns 400 when name is missing', async () => {
    const app = createApp({});

    const response = await request(app).post('/users').send({});

    expect(response.status).toBe(400);
  });
});

Two design choices make this testable at all: createApp() accepts its repository as a parameter instead of importing a real database module directly, and each test builds its own fake with jest.fn() — a mock function that records how it was called and returns whatever .mockResolvedValue() tells it to. Neither test touches a real database, so the whole suite runs in milliseconds and never depends on external state.

Mocking a module directly

Sometimes a dependency is imported directly rather than passed in, and there's no constructor parameter to substitute a fake through. jest.mock() replaces an entire module with an automatically mocked version:

Javascript
// emailService.js
export async function sendWelcomeEmail(email) {
  // ... calls a real third-party email API
}
Javascript
// signup.test.js
import { sendWelcomeEmail } from './emailService';
import { signUpUser } from './signup';

jest.mock('./emailService'); // every export becomes a jest.fn() automatically

test('signing up a user sends a welcome email', async () => {
  await signUpUser({ email: 'ali@example.com' });

  expect(sendWelcomeEmail).toHaveBeenCalledWith('ali@example.com');
});

This is what keeps a test suite fast and deterministic — without the mock, every signup test would attempt a real network call to an email provider, which is slow, requires network access, and would actually send emails during a test run.

Setup and teardown

beforeEach/afterEach (and their All variants) run shared setup/cleanup code around every test in a describe block, instead of repeating it inside each one:

Javascript
describe('OrdersRepository', () => {
  let db;

  beforeEach(async () => {
    db = await createTestDatabase(); // a fresh, empty in-memory DB for every single test
  });

  afterEach(async () => {
    await db.close();
  });

  test('creates an order', async () => {
    const order = await db.orders.create({ total: 49.99 });
    expect(order.id).toBeDefined();
  });
});

Running beforeEach (not just beforeAll) matters here specifically because it gives every test a clean, isolated database — without it, one test's leftover data could make a completely unrelated test pass or fail depending on execution order, which is exactly the kind of flaky behavior a good test suite should never have.

Unit vs. integration tests, side by side

Unit test Integration test (Supertest)
What's under test One function, in isolation A route, including routing, middleware, and (fake or real) dependencies
Dependencies None, or trivial Mocked repository/service, or a real test database
Speed Milliseconds Fast, but slower than a pure unit test
Catches Logic bugs in one function Wrong status codes, broken middleware ordering, wiring mistakes between layers
Example above calculateTotal GET /users/:id returning the right status and body

Common mistakes

  • Testing against a real, shared database instead of a fake repository or an isolated test database — tests become slow, order-dependent, and prone to failing for reasons that have nothing to do with the code under test.
  • Forgetting afterEach/afterAll cleanup (closing a test database connection, restoring a mocked module), letting state leak between tests and causing failures that only reproduce when the full suite runs, not a single test in isolation.
  • Asserting only the HTTP status code and never the response body — a route can return 200 with completely wrong data and a status-only test suite will still report full green.
  • Mocking so much of a route's dependencies that the test no longer verifies anything the route handler itself does — leaving nothing but the mocks' own return values reflected back, which isn't actually testing your code.

Interview questions

Q: Why does Supertest let you test an Express route without a real running server? It drives requests directly against the Express app object in-process, exercising the exact same routing and middleware Express would use in production, without actually binding to a network port. This makes tests fast and avoids the flakiness of managing a real server's lifecycle (starting it, waiting for it to be ready, tearing it down) for every test run.

Q: Why use beforeEach instead of beforeAll when a test needs a fresh database? beforeAll runs its setup once for the entire describe block, so all tests inside it would share the same database state — one test's leftover data could silently affect another test's outcome depending on run order. beforeEach re-runs the setup before every individual test, guaranteeing each one starts from a clean, predictable state regardless of what ran before it.