nodejs5 Min Read

Testing Node.js — Unit, Integration, and E2E

Gorav Singal

April 02, 2026

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 Node.js — Unit, Integration, and E2E

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.

Share

Related Posts

WebSockets with Socket.io in Node.js

WebSockets with Socket.io in Node.js

WebSocket vs HTTP Traditional HTTP follows a request/response model — the client…

Redis — Caching, Sessions, Pub/Sub in Node.js

Redis — Caching, Sessions, Pub/Sub in Node.js

Why Redis for Node.js Redis is an in-memory data store that serves as a cache…

Database Integration — PostgreSQL with Node.js

Database Integration — PostgreSQL with Node.js

Choosing Your PostgreSQL Client Node.js has three main approaches to working…

Performance Optimization and Profiling in Node.js

Performance Optimization and Profiling in Node.js

Profiling First, Optimize Second Never optimize blindly. Always profile to find…

Node.js Architecture — Event Loop Deep Dive

Node.js Architecture — Event Loop Deep Dive

Why the Event Loop Matters Node.js runs JavaScript on a single thread, yet…

MongoDB with Mongoose — Patterns and Pitfalls

MongoDB with Mongoose — Patterns and Pitfalls

Schema Design Philosophy MongoDB schema design is fundamentally different from…

Latest Posts

AI Video Generation in 2025 — Models, Costs, and How to Build a Cost-Effective Pipeline

AI Video Generation in 2025 — Models, Costs, and How to Build a Cost-Effective Pipeline

AI video generation went from “cool demo” to “usable in production” in 2024-202…

AI Models in 2025 — Cost, Capabilities, and Which One to Use

AI Models in 2025 — Cost, Capabilities, and Which One to Use

Choosing the right AI model is one of the most impactful decisions you’ll make…

AI Image Generation in 2025 — Models, Costs, and How to Optimize Spend

AI Image Generation in 2025 — Models, Costs, and How to Optimize Spend

Generating one image with AI costs between $0.002 and $0.12. That might sound…

AI Coding Assistants in 2025 — Every Tool Compared, and Which One to Actually Use

AI Coding Assistants in 2025 — Every Tool Compared, and Which One to Actually Use

Two years ago, AI coding meant one thing: GitHub Copilot autocompleting your…

AI Agents Demystified — It's Just Automation With a Better Brain

AI Agents Demystified — It's Just Automation With a Better Brain

Let’s cut through the noise. If you read Twitter or LinkedIn, you’d think “AI…

Supply Chain Security — Protecting Your Software Pipeline

Supply Chain Security — Protecting Your Software Pipeline

In 2024, a single malicious contributor nearly compromised every Linux system on…