100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Node.js & Express Backend
30 minintermediate

Unit Testing with Jest and Supertest

Unit Testing with Jest and Supertest

Testing is the discipline that converts 'I think this works' into 'I can prove this works'. In Node.js backend development, Jest is the dominant test runner and assertion library, while Supertest is the standard tool for making HTTP requests against an Express app in tests without starting a real server. Together they cover two essential testing layers: unit tests that verify individual functions in isolation, and integration tests that verify your Express routes handle requests and produce correct responses end-to-end.

Analogy🏏Cricket
Think of it like cricket: Before a Test match, every player goes through individual skill assessments: the batting coach watches Rohit Sharma play each shot in the nets, the bowling coach analyses Jasprit Bumrah's action frame by frame. Those are unit tests — isolated evaluation of a single skill. Then the selectors watch team practice sessions, where batsmen face actual bowlers and fielders react to real shots. Those are integration tests. A player who passes all individual assessments but falls apart in practice sessions has a systemic problem. Your code that passes unit tests but fails integration tests has the same kind of systemic problem.

Setting Up Jest

Jest is a zero-configuration test runner for Node.js. Install it as a dev dependency, add a test script to package.json, and Jest will automatically discover any file ending in .test.js or .spec.js, or any file inside a __tests__ directory.

bash
npm install --save-dev jest supertest
json
{
  "scripts": {
    "test":       "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node",
    "coverageThreshold": {
      "global": { "branches": 70, "functions": 80, "lines": 80 }
    }
  }
}
Analogy🏏Cricket
Think of it like cricket: Jest is like the BCCI's standardised assessment rubric — it defines what 'passing' means (assertions), runs every player through the same drills (test files), and produces a scorecard (test results) showing pass/fail per drill and overall coverage. The coverage threshold is like the minimum fitness standard: a player who passes only 60% of fitness tests doesn't make the squad.

Writing Unit Tests

A unit test isolates a single function and tests it against defined inputs and expected outputs. Use describe() to group related tests and it() or test() for individual cases. Jest's expect() API provides matchers: toBe (strict equality), toEqual (deep equality), toThrow, toHaveBeenCalledWith, and many more.

Analogy🏏Cricket
🏏 Think of it like cricket: a unit test is a single, controlled net session for one specific skill — you isolate a batsman and feed him defined deliveries (inputs) to check he plays the exact expected shot (output), with nothing else on the field to muddy the result. Jest's describe() groups related sessions like all your cover-drive drills bundled together, and each it() or test() is one specific ball bowled with a known outcome to verify. The expect() matchers are the coach's precise verdicts: toBe is 'that's exactly the run out we wanted' (strict equality), toEqual checks the whole shot matches shape and detail (deep equality), toThrow confirms a wild delivery is correctly rejected, and toHaveBeenCalledWith verifies the right signal was given. The payoff: isolating one function against defined inputs and expected outputs, grouped and asserted precisely, means a failing test points to exactly one broken skill — not a vague 'something in the match went wrong'.
javascript
// utils/paginate.js
function paginate(array, page = 1, limit = 10) {
  const total = array.length;
  const pages = Math.ceil(total / limit);
  return { data: array.slice((page - 1) * limit, page * limit), total, page, pages, limit };
}
module.exports = { paginate };

// utils/paginate.test.js
const { paginate } = require('./paginate');

describe('paginate()', () => {
  const items = Array.from({ length: 25 }, (_, i) => i + 1);

  it('returns the first page with default limit 10', () => {
    const result = paginate(items);
    expect(result.data).toHaveLength(10);
    expect(result.data[0]).toBe(1);
    expect(result.page).toBe(1);
    expect(result.pages).toBe(3);
    expect(result.total).toBe(25);
  });

  it('returns the correct slice for page 2', () => {
    const result = paginate(items, 2, 10);
    expect(result.data[0]).toBe(11);
    expect(result.data[9]).toBe(20);
  });

  it('returns a partial last page', () => {
    const result = paginate(items, 3, 10);
    expect(result.data).toHaveLength(5);
    expect(result.data[4]).toBe(25);
  });

  it('returns empty data for a page beyond total', () => {
    const result = paginate(items, 10, 10);
    expect(result.data).toHaveLength(0);
  });
});

Mocking Dependencies

Unit tests must be isolated: if your function calls a database, an email service, or a third-party API, those calls must be replaced with mocks. Jest provides jest.mock() for module mocking, jest.fn() for creating spy functions, and jest.spyOn() for watching real implementations. Mocking ensures tests are fast, deterministic, and don't require external services.

Analogy🏏Cricket
🏏 Think of it like cricket: a proper net session for your batsman doesn't drag the whole opposition, the scorers, and a live crowd into the nets — you replace them with a bowling machine. Mocking is that bowling machine: if your function calls a database, an email service, or a third-party API, you swap those real dependencies for controllable stand-ins. jest.mock() replaces an entire module the way a machine stands in for the whole bowling attack, jest.fn() is a spy delivery you fully script, and jest.spyOn() quietly watches a real bowler to confirm he was called without changing his action. Just as a bowling machine delivers the identical ball every single time, on demand, mocking makes tests fast and deterministic and frees them from needing external services present. The payoff: isolating your function behind mocks means one net session runs in milliseconds, gives the same result every run, and never fails just because the real database or email server happened to be down.
javascript
// services/userService.js
const User = require('../models/User');
const { sendWelcomeEmail } = require('../utils/email');

async function registerUser(email, password) {
  const existing = await User.findOne({ email });
  if (existing) throw new Error('Email already exists');
  const user = await User.create({ email, passwordHash: 'hashed' });
  await sendWelcomeEmail({ to: email, name: email.split('@')[0] });
  return user;
}
module.exports = { registerUser };

// services/userService.test.js
jest.mock('../models/User');
jest.mock('../utils/email');

const User = require('../models/User');
const { sendWelcomeEmail } = require('../utils/email');
const { registerUser } = require('./userService');

describe('registerUser()', () => {
  beforeEach(() => jest.clearAllMocks());

  it('creates a user and sends welcome email', async () => {
    User.findOne.mockResolvedValue(null);           // no existing user
    User.create.mockResolvedValue({ id: '1', email: '[email protected]' });
    sendWelcomeEmail.mockResolvedValue(undefined);

    const user = await registerUser('[email protected]', 'password123');

    expect(User.create).toHaveBeenCalledWith(
      expect.objectContaining({ email: '[email protected]' })
    );
    expect(sendWelcomeEmail).toHaveBeenCalledWith(
      expect.objectContaining({ to: '[email protected]' })
    );
    expect(user.email).toBe('[email protected]');
  });

  it('throws if email already exists', async () => {
    User.findOne.mockResolvedValue({ id: '1', email: '[email protected]' });
    await expect(registerUser('[email protected]', 'pass')).rejects.toThrow('Email already exists');
    expect(User.create).not.toHaveBeenCalled();
  });
});

Always call jest.clearAllMocks() or jest.resetAllMocks() in a beforeEach block to prevent mock state from leaking between tests. Stale mock return values or call counts from a previous test will produce false positives.

HTTP Route Testing with Supertest

Supertest wraps your Express app and lets you make real HTTP calls in tests without binding to a port. Import your app (not server.js — the file that calls app.listen), pass it to supertest's request(), and chain HTTP method calls with assertions. Supertest handles opening and closing the HTTP connection automatically.

Analogy🏏Cricket
🏏 Think of it like cricket: Supertest lets you play a full practice match without booking a stadium or selling tickets. It wraps your Express app and fires real HTTP requests through it — genuine deliveries down the full pitch — yet never binds to a public port, the way an intra-squad game runs the complete match on the ground with no gates opened to the public. Crucially you must hand it the app itself, not server.js, just as you bring the eleven players onto the field, not the whole ticketing-and-turnstile operation that server.js (which calls app.listen) represents. You pass the app to request(), then chain your HTTP method call and assertions like nominating a delivery and judging the outcome, while Supertest quietly opens and closes the connection for each ball. The payoff: real HTTP requests through your actual app, without port-binding boilerplate, means you test the true request path exactly as production would run it — minus the stadium.
javascript
// app.js — export app WITHOUT calling app.listen()
const express = require('express');
const app = express();
app.use(express.json());
app.use('/api/products', require('./routes/products'));
module.exports = app;  // no app.listen() here

// routes/products.test.js
const request = require('supertest');
const app     = require('../app');

jest.mock('../models/Product');
const Product = require('../models/Product');

describe('GET /api/products', () => {
  it('returns a list of products with 200', async () => {
    Product.find.mockResolvedValue([
      { id: '1', name: 'Cricket Bat', price: 120 },
      { id: '2', name: 'Helmet',      price: 85 }
    ]);

    const res = await request(app).get('/api/products');

    expect(res.statusCode).toBe(200);
    expect(res.body).toHaveLength(2);
    expect(res.body[0].name).toBe('Cricket Bat');
  });

  it('returns 500 when database throws', async () => {
    Product.find.mockRejectedValue(new Error('DB connection lost'));
    const res = await request(app).get('/api/products');
    expect(res.statusCode).toBe(500);
  });
});

describe('POST /api/products', () => {
  it('creates a product and returns 201', async () => {
    const newProduct = { id: '3', name: 'Gloves', price: 45 };
    Product.create.mockResolvedValue(newProduct);

    const res = await request(app)
      .post('/api/products')
      .set('Authorization', 'Bearer valid-test-token')
      .send({ name: 'Gloves', price: 45 });

    expect(res.statusCode).toBe(201);
    expect(res.body.name).toBe('Gloves');
  });

  it('returns 400 for missing required fields', async () => {
    const res = await request(app)
      .post('/api/products')
      .set('Authorization', 'Bearer valid-test-token')
      .send({});
    expect(res.statusCode).toBe(400);
  });
});
Lesson 31 of 36
0% complete