Module P-5·18 min read

Unit testing with Jest, integration testing with Supertest, mocking modules, database testing strategies, meaningful coverage, and CI integration.

Written by Jatin Jain Saraf · Architect of SupraScan

Module P-5 — Testing Node.js Applications

What this module covers: Untested code is a liability — you cannot refactor it safely, you cannot ship it confidently, and you cannot onboard a new engineer without fear. This module covers the testing pyramid for Node.js: unit tests with Jest that test one function in isolation, integration tests with Supertest that hit real Express routes, the correct way to mock dependencies so tests stay fast and deterministic, database testing strategies, and wiring tests into CI. By the end you will have a test suite that actually catches bugs and runs in seconds.


The Testing Pyramid

Three levels of tests, each with a different trade-off:

         /\
        /  \
       / E2E \          Few — slow, brittle, expensive
      /--------\
     /          \
    / Integration \     Some — test routes end-to-end in memory
   /--------------\
  /                \
 /   Unit Tests     \   Many — fast, isolated, test one thing
/--------------------\
  • Unit tests: Test a single function — a service method, a validator, a utility. No HTTP, no database, no network. Run in milliseconds.
  • Integration tests: Test an Express route from HTTP request to HTTP response. Real validation middleware, real service logic, but the database layer is mocked or uses a test database.
  • End-to-end tests: Spin up the full stack against a real database. Slow and brittle. Write few, keep them for critical paths only.

For a Node.js API, the sweet spot is: many unit tests for service/business logic, integration tests for every route, minimal E2E tests.


Setup: Jest and Supertest

npm install -D jest ts-jest @types/jest ts-node supertest @types/supertest
// jest.config.ts (or jest.config.js)
import type { Config } from 'jest';
 
const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
  collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 80,
      lines: 80,
    },
  },
};
 
export default config;

Note that jest.config.ts requires ts-node on top of ts-jest — they do different jobs. ts-jest transforms your .test.ts files at test-run time; Jest's own config loader needs ts-node to parse jest.config.ts itself, before any tests run. Skip the ts-node install and Jest fails immediately trying to load its own config, before a single test executes.

// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:ci": "jest --ci --coverage"
  }
}

Unit Testing: Services

Services contain the business logic — test them thoroughly. They take plain inputs and return plain outputs. No HTTP to set up.

// src/services/__tests__/orders.service.test.ts
import { createOrder } from '../orders.service.js';
import * as ordersRepo from '../../repositories/orders.repository.js';
import * as usersRepo from '../../repositories/users.repository.js';
import * as productsRepo from '../../repositories/products.repository.js';
import { NotFoundError, ConflictError, ForbiddenError } from '../../errors/AppError.js';
 
// Mock entire modules — imports in the service get the mocked versions
jest.mock('../../repositories/orders.repository.js');
jest.mock('../../repositories/users.repository.js');
jest.mock('../../repositories/products.repository.js');
 
// Cast to jest mocks for TypeScript
const mockUsersRepo = usersRepo as jest.Mocked<typeof usersRepo>;
const mockProductsRepo = productsRepo as jest.Mocked<typeof productsRepo>;
const mockOrdersRepo = ordersRepo as jest.Mocked<typeof ordersRepo>;
 
describe('ordersService.createOrder', () => {
  // Reset all mocks before each test — prevents state leaking between tests
  beforeEach(() => {
    jest.clearAllMocks();
  });
 
  const activeUser = { id: 1, name: 'Jatin', status: 'active', role: 'user' };
  const product = { id: 10, name: 'Widget', price: 29.99, stock: 100 };
  const items = [{ productId: 10, quantity: 2 }];
 
  it('creates an order for an active user', async () => {
    mockUsersRepo.findById.mockResolvedValue(activeUser);
    mockProductsRepo.findById.mockResolvedValue(product);
    mockOrdersRepo.create.mockResolvedValue({ id: 1, userId: 1, total: 59.98, items: [] });
 
    const order = await createOrder({ userId: 1, items });
 
    expect(order.total).toBe(59.98);
    expect(mockOrdersRepo.create).toHaveBeenCalledWith({
      userId: 1,
      total: 59.98,
      items: [{ productId: 10, quantity: 2, price: 29.99 }],
    });
  });
 
  it('throws NotFoundError when user does not exist', async () => {
    mockUsersRepo.findById.mockResolvedValue(null);
 
    await expect(createOrder({ userId: 999, items }))
      .rejects.toThrow(NotFoundError);
  });
 
  it('throws ForbiddenError when user is banned', async () => {
    mockUsersRepo.findById.mockResolvedValue({ ...activeUser, status: 'banned' });
 
    await expect(createOrder({ userId: 1, items }))
      .rejects.toThrow(ForbiddenError);
  });
 
  it('throws ConflictError when product is out of stock', async () => {
    mockUsersRepo.findById.mockResolvedValue(activeUser);
    mockProductsRepo.findById.mockResolvedValue({ ...product, stock: 1 });
 
    await expect(createOrder({ userId: 1, items: [{ productId: 10, quantity: 5 }] }))
      .rejects.toThrow(ConflictError);
  });
 
  it('throws NotFoundError when product does not exist', async () => {
    mockUsersRepo.findById.mockResolvedValue(activeUser);
    mockProductsRepo.findById.mockResolvedValue(null);
 
    await expect(createOrder({ userId: 1, items }))
      .rejects.toThrow(NotFoundError);
  });
});

Notice the pattern: set up mocks, call the function, assert on the output or thrown error. Each test describes one behaviour. The test names read like a specification.


Unit Testing: Validators

Schemas are pure functions — trivial to test:

// src/validators/__tests__/users.schema.test.ts
import { createUserSchema, updateUserSchema } from '../users.schema.js';
 
describe('createUserSchema', () => {
  const validInput = {
    name: 'Jatin Saraf',
    email: 'jatin@example.com',
    password: 'securepassword',
  };
 
  it('parses valid input', () => {
    const result = createUserSchema.safeParse(validInput);
    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.role).toBe('user'); // default applied
    }
  });
 
  it('lowercases email', () => {
    const result = createUserSchema.safeParse({ ...validInput, email: 'JATIN@EXAMPLE.COM' });
    expect(result.success && result.data.email).toBe('jatin@example.com');
  });
 
  it('rejects invalid email', () => {
    const result = createUserSchema.safeParse({ ...validInput, email: 'not-an-email' });
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error.issues[0].path).toEqual(['email']);
    }
  });
 
  it('rejects password shorter than 8 characters', () => {
    const result = createUserSchema.safeParse({ ...validInput, password: 'short' });
    expect(result.success).toBe(false);
  });
});
 
describe('updateUserSchema', () => {
  it('accepts partial updates', () => {
    const result = updateUserSchema.safeParse({ name: 'New Name' });
    expect(result.success).toBe(true);
  });
 
  it('rejects empty update object', () => {
    const result = updateUserSchema.safeParse({});
    expect(result.success).toBe(false);
  });
});

Testing Time-Dependent Code

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.