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:
npm install --save-dev jest supertest
{
"scripts": {
"test": "jest"
}
}
Anatomy of a test
describe groups related tests, test (or its alias it) defines one, and expect makes an assertion:
// 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;
}
// 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);
});
});
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 test — calculateTotal 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:
// 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;
}
// 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:
// emailService.js
export async function sendWelcomeEmail(email) {
// ... calls a real third-party email API
}
// 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:
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/afterAllcleanup (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
200with 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.