Testing (Jest & Supertest)

Difficulty: Intermediate

Question

How do you test a Node.js API? Explain unit testing, integration testing, and API testing with Jest and Supertest.

Answer

Testing Node.js applications involves three levels: unit tests (individual functions), integration tests (modules working together), and API/E2E tests (full HTTP request/response cycle).

Jest is the most popular test framework for Node.js. It provides test runners, assertions, mocking, code coverage, and snapshot testing out of the box. Supertest is a library for testing HTTP endpoints by simulating requests against your Express app without starting a server.

Unit tests isolate functions by mocking dependencies (database, external APIs). Integration tests verify that multiple modules work together correctly. API tests send real HTTP requests and verify status codes, headers, and response bodies.

The testing pyramid suggests: many unit tests (fast, isolated), fewer integration tests (medium speed), and few E2E tests (slow, expensive). Mock external dependencies in unit tests but use real databases in integration tests (with test containers or in-memory DBs).

Code examples

Unit Testing with Jest

// utils/math.js
function calculateDiscount(price, percentage) {
  if (price < 0 || percentage < 0 || percentage > 100) {
    throw new Error('Invalid input');
  }
  return price - (price * percentage / 100);
}
module.exports = { calculateDiscount };

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

describe('calculateDiscount', () => {
  it('should calculate 20% discount correctly', () => {
    expect(calculateDiscount(100, 20)).toBe(80);
  });

  it('should return original price for 0% discount', () => {
    expect(calculateDiscount(50, 0)).toBe(50);
  });

  it('should return 0 for 100% discount', () => {
    expect(calculateDiscount(100, 100)).toBe(0);
  });

  it('should throw on negative price', () => {
    expect(() => calculateDiscount(-10, 20)).toThrow('Invalid input');
  });

  it('should throw on percentage > 100', () => {
    expect(() => calculateDiscount(100, 150)).toThrow('Invalid input');
  });
});

Unit tests verify pure functions in isolation. Test happy paths, edge cases, and error conditions. Jest's describe/it/expect syntax is clear and readable.

API Testing with Supertest

const request = require('supertest');
const app = require('../app'); // Express app (not listening)

describe('POST /api/auth/register', () => {
  it('should register a new user', async () => {
    const res = await request(app)
      .post('/api/auth/register')
      .send({
        name: 'Test User',
        email: 'test@example.com',
        password: 'Password123',
      })
      .expect(201)
      .expect('Content-Type', /json/);

    expect(res.body).toHaveProperty('id');
    expect(res.body.email).toBe('test@example.com');
    expect(res.body).not.toHaveProperty('password');
  });

  it('should reject duplicate email', async () => {
    const res = await request(app)
      .post('/api/auth/register')
      .send({
        name: 'Duplicate',
        email: 'test@example.com',
        password: 'Password123',
      })
      .expect(409);

    expect(res.body.error).toMatch(/already/);
  });

  it('should validate required fields', async () => {
    const res = await request(app)
      .post('/api/auth/register')
      .send({ email: 'bad' })
      .expect(400);

    expect(res.body).toHaveProperty('details');
  });
});

Supertest sends HTTP requests to your Express app without starting a server. Chain .expect() for status codes and .send() for request bodies.

Mocking Dependencies

// service/user.service.test.js
const { getUserById } = require('./user.service');
const prisma = require('../db');

// Mock the entire Prisma module
jest.mock('../db', () => ({
  user: {
    findUnique: jest.fn(),
    create: jest.fn(),
  },
}));

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

  it('should return user when found', async () => {
    const mockUser = { id: 1, name: 'Alice', email: 'alice@test.com' };
    prisma.user.findUnique.mockResolvedValue(mockUser);

    const result = await getUserById(1);

    expect(result).toEqual(mockUser);
    expect(prisma.user.findUnique).toHaveBeenCalledWith({
      where: { id: 1 },
    });
  });

  it('should throw NotFoundError when user missing', async () => {
    prisma.user.findUnique.mockResolvedValue(null);

    await expect(getUserById(999))
      .rejects
      .toThrow('User not found');
  });

  it('should propagate database errors', async () => {
    prisma.user.findUnique.mockRejectedValue(
      new Error('Connection refused')
    );

    await expect(getUserById(1))
      .rejects
      .toThrow('Connection refused');
  });
});

jest.mock replaces the real module with mocks. mockResolvedValue simulates async returns. Test both success and error paths.

Key points

Concepts covered

Jest, Supertest, Unit Testing, Integration Testing, Mocking