nodejs|April 02, 2026|5 min read

Testing Node.js — Unit, Integration, and E2E

TL;DR

Unit test pure business logic with Jest mocks. Integration test API routes with Supertest and a real database (Testcontainers). E2E test critical user flows. Aim for 80% coverage but prioritize testing complex logic over simple getters.

Testing Strategy

A solid testing strategy follows the testing pyramid — many fast unit tests at the base, fewer integration tests in the middle, and a handful of E2E tests at the top.

Testing Pyramid

Jest Setup

// jest.config.js
module.exports = {
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.test.js', '**/*.spec.js'],
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/__tests__/**',
    '!src/index.js',
  ],
  coverageThresholds: {
    global: { branches: 75, functions: 80, lines: 80, statements: 80 },
  },
  setupFilesAfterSetup: ['./jest.setup.js'],
};

Unit Tests — Pure Logic

Unit tests target individual functions and classes in isolation.

// src/services/pricing.js
function calculateDiscount(price, quantity, memberTier) {
  if (price <= 0 || quantity <= 0) {
    throw new Error('Price and quantity must be positive');
  }

  let discount = 0;

  // Volume discount
  if (quantity >= 100) discount += 0.15;
  else if (quantity >= 50) discount += 0.10;
  else if (quantity >= 10) discount += 0.05;

  // Member tier discount
  if (memberTier === 'gold') discount += 0.10;
  else if (memberTier === 'silver') discount += 0.05;

  // Cap at 25%
  discount = Math.min(discount, 0.25);

  return {
    subtotal: price * quantity,
    discount: parseFloat((price * quantity * discount).toFixed(2)),
    total: parseFloat((price * quantity * (1 - discount)).toFixed(2)),
  };
}

// src/services/__tests__/pricing.test.js
const { calculateDiscount } = require('../pricing');

describe('calculateDiscount', () => {
  it('applies volume discount for 50+ items', () => {
    const result = calculateDiscount(10, 50, 'none');
    expect(result.discount).toBe(50); // 10%
    expect(result.total).toBe(450);
  });

  it('stacks volume and member discounts', () => {
    const result = calculateDiscount(100, 100, 'gold');
    // 15% volume + 10% gold = 25%
    expect(result.discount).toBe(2500);
    expect(result.total).toBe(7500);
  });

  it('caps total discount at 25%', () => {
    const result = calculateDiscount(100, 100, 'gold');
    // 15% + 10% = 25% (already at cap)
    expect(result.discount).toBe(2500);
  });

  it('throws for invalid price', () => {
    expect(() => calculateDiscount(-1, 10, 'none')).toThrow('Price and quantity must be positive');
  });

  it('returns zero discount for small orders without membership', () => {
    const result = calculateDiscount(10, 5, 'none');
    expect(result.discount).toBe(0);
    expect(result.total).toBe(50);
  });
});

Mocking with Jest

// src/services/order.service.js
class OrderService {
  constructor(db, emailService, paymentGateway) {
    this.db = db;
    this.emailService = emailService;
    this.paymentGateway = paymentGateway;
  }

  async createOrder(userId, items) {
    const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

    const payment = await this.paymentGateway.charge(userId, total);

    const order = await this.db.orders.create({
      userId,
      items,
      total,
      paymentId: payment.id,
      status: 'confirmed',
    });

    await this.emailService.sendOrderConfirmation(userId, order);

    return order;
  }
}

// __tests__/order.service.test.js
describe('OrderService', () => {
  let service;
  let mockDb, mockEmail, mockPayment;

  beforeEach(() => {
    mockDb = {
      orders: {
        create: jest.fn().mockResolvedValue({ id: 'order-1', status: 'confirmed' }),
      },
    };
    mockEmail = {
      sendOrderConfirmation: jest.fn().mockResolvedValue(true),
    };
    mockPayment = {
      charge: jest.fn().mockResolvedValue({ id: 'pay-1', status: 'succeeded' }),
    };

    service = new OrderService(mockDb, mockEmail, mockPayment);
  });

  it('creates order and sends confirmation', async () => {
    const items = [{ productId: 'p1', price: 25, quantity: 2 }];
    const order = await service.createOrder('user-1', items);

    expect(mockPayment.charge).toHaveBeenCalledWith('user-1', 50);
    expect(mockDb.orders.create).toHaveBeenCalledWith(
      expect.objectContaining({ userId: 'user-1', total: 50, status: 'confirmed' })
    );
    expect(mockEmail.sendOrderConfirmation).toHaveBeenCalledWith('user-1', order);
  });

  it('does not create order if payment fails', async () => {
    mockPayment.charge.mockRejectedValue(new Error('Card declined'));

    await expect(service.createOrder('user-1', [{ price: 50, quantity: 1 }]))
      .rejects.toThrow('Card declined');

    expect(mockDb.orders.create).not.toHaveBeenCalled();
    expect(mockEmail.sendOrderConfirmation).not.toHaveBeenCalled();
  });
});

Integration Tests — API Routes

Integration tests verify that routes, middleware, services, and database work together.

const request = require('supertest');
const { createApp } = require('../app');
const { setupTestDb, teardownTestDb } = require('./helpers/db');

describe('Users API', () => {
  let app;
  let db;

  beforeAll(async () => {
    db = await setupTestDb();
    app = createApp(db);
  });

  afterAll(async () => {
    await teardownTestDb(db);
  });

  beforeEach(async () => {
    await db.query('DELETE FROM users');
  });

  describe('POST /api/users', () => {
    it('creates a new user', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({ name: 'Alice', email: '[email protected]', password: 'securePass123' })
        .expect(201);

      expect(res.body).toMatchObject({
        name: 'Alice',
        email: '[email protected]',
      });
      expect(res.body).not.toHaveProperty('password');
      expect(res.body).toHaveProperty('id');
    });

    it('returns 400 for invalid email', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({ name: 'Alice', email: 'not-an-email', password: 'securePass123' })
        .expect(400);

      expect(res.body.error.code).toBe('VALIDATION_ERROR');
    });

    it('returns 409 for duplicate email', async () => {
      await request(app)
        .post('/api/users')
        .send({ name: 'Alice', email: '[email protected]', password: 'pass1234' });

      await request(app)
        .post('/api/users')
        .send({ name: 'Bob', email: '[email protected]', password: 'pass5678' })
        .expect(409);
    });
  });

  describe('GET /api/users/:id', () => {
    it('returns user by id', async () => {
      const created = await request(app)
        .post('/api/users')
        .send({ name: 'Alice', email: '[email protected]', password: 'pass1234' });

      const res = await request(app)
        .get(`/api/users/${created.body.id}`)
        .expect(200);

      expect(res.body.name).toBe('Alice');
    });

    it('returns 404 for non-existent user', async () => {
      await request(app)
        .get('/api/users/99999')
        .expect(404);
    });
  });
});

Database Testing with Testcontainers

const { PostgreSqlContainer } = require('@testcontainers/postgresql');

let container;
let db;

beforeAll(async () => {
  container = await new PostgreSqlContainer()
    .withDatabase('test_db')
    .start();

  db = new Pool({
    connectionString: container.getConnectionUri(),
  });

  // Run migrations
  await runMigrations(db);
}, 30000); // 30s timeout for container startup

afterAll(async () => {
  await db.end();
  await container.stop();
});

E2E Tests — Critical Flows

describe('Order Flow E2E', () => {
  let authToken;

  beforeAll(async () => {
    // Register and login
    await request(app)
      .post('/api/auth/register')
      .send({ name: 'Test User', email: '[email protected]', password: 'test1234' });

    const loginRes = await request(app)
      .post('/api/auth/login')
      .send({ email: '[email protected]', password: 'test1234' });

    authToken = loginRes.body.accessToken;
  });

  it('completes full order lifecycle', async () => {
    // Create order
    const orderRes = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({ items: [{ productId: 'prod-1', quantity: 2 }] })
      .expect(201);

    const orderId = orderRes.body.id;

    // Check order status
    const statusRes = await request(app)
      .get(`/api/orders/${orderId}`)
      .set('Authorization', `Bearer ${authToken}`)
      .expect(200);

    expect(statusRes.body.status).toBe('confirmed');

    // List user orders
    const listRes = await request(app)
      .get('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .expect(200);

    expect(listRes.body.items).toHaveLength(1);
  });
});

Coverage and CI Integration

// package.json scripts
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:unit": "jest --testPathPattern='unit'",
    "test:integration": "jest --testPathPattern='integration' --runInBand"
  }
}
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test_db
          POSTGRES_PASSWORD: test
        ports: ['5432:5432']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npm run test:coverage
      - uses: codecov/codecov-action@v3

What to Test vs What Not to Test

Test: Business logic, edge cases, error paths, input validation, auth flows, data transformations.

Skip: Framework internals, simple CRUD with no logic, third-party library behavior, getters/setters.

Focus testing effort on code with high complexity and high business impact.

Related Posts

Latest Posts