arrow_backBACK TO NODE.JS BACKEND ENGINEERING
Lesson 11Node.js Backend Engineering7 min read

Testing — Unit, Integration, E2E

April 03, 2026

TL;DR

Unit tests verify isolated functions, integration tests verify API endpoints with real databases, E2E tests verify complete user flows. Use Vitest for speed, Supertest for HTTP assertions, and testcontainers for real database integration tests. Aim for 80% integration test coverage on critical paths.

Shipping code without tests is shipping bugs on a delayed schedule. But not all tests are equal. A test suite full of shallow unit tests that mock everything gives you a false sense of security. A test suite with only E2E tests runs for 20 minutes and nobody waits for it. The right strategy is a deliberate mix — fast unit tests for logic, integration tests for API contracts, and a thin layer of E2E tests for critical user flows.

The Testing Pyramid

The testing pyramid is a model for balancing test types by speed, cost, and confidence.

Testing pyramid

Unit tests sit at the base. They test individual functions and classes in isolation. They are fast (milliseconds each), cheap to write, and catch logic errors. But they tell you nothing about whether your components work together.

Integration tests sit in the middle. They test how multiple components interact — your route handler calling a service that queries a real database. They are slower (seconds each) but give you much higher confidence that your API actually works.

E2E tests sit at the top. They test the full system from the outside — making real HTTP requests, going through authentication, and verifying the response. They are the slowest and most brittle, but they catch issues that nothing else can.

A healthy backend test suite for a typical Node.js API looks roughly like:

  • 60% integration tests (API endpoints with real database)
  • 30% unit tests (business logic, utilities, validators)
  • 10% E2E tests (critical paths: signup, checkout, payment)

Setting Up Vitest

Vitest is the modern choice for Node.js testing. It is fast, has native ESM support, and uses the same config as Vite. Jest works too, but Vitest is significantly faster for large test suites.

npm install -D vitest @vitest/coverage-v8
// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: ['node_modules/', 'tests/fixtures/'],
    },
    setupFiles: ['./tests/setup.js'],
  },
});
// tests/setup.js
import { beforeAll, afterAll } from 'vitest';

beforeAll(async () => {
  // Global setup: start test database, seed data
});

afterAll(async () => {
  // Global teardown: stop containers, close connections
});

Add test scripts to package.json:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}

Unit Testing Services and Utilities

Unit tests verify pure logic. They should not touch databases, file systems, or external APIs.

// src/services/pricing.js
export function calculateDiscount(price, userTier) {
  if (price <= 0) throw new ValidationError('Price must be positive');

  const discounts = {
    free: 0,
    basic: 0.05,
    premium: 0.15,
    enterprise: 0.25,
  };

  const rate = discounts[userTier] ?? 0;
  return Math.round(price * (1 - rate) * 100) / 100;
}
// tests/unit/pricing.test.js
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from '../../src/services/pricing.js';

describe('calculateDiscount', () => {
  it('applies no discount for free tier', () => {
    expect(calculateDiscount(100, 'free')).toBe(100);
  });

  it('applies 15% discount for premium tier', () => {
    expect(calculateDiscount(100, 'premium')).toBe(85);
  });

  it('handles unknown tier as zero discount', () => {
    expect(calculateDiscount(100, 'unknown')).toBe(100);
  });

  it('throws for negative price', () => {
    expect(() => calculateDiscount(-10, 'basic'))
      .toThrow('Price must be positive');
  });

  it('rounds to 2 decimal places', () => {
    expect(calculateDiscount(99.99, 'basic')).toBe(94.99);
  });
});

Good unit tests follow the Arrange-Act-Assert pattern. Each test verifies one behavior. Test names describe the scenario and expected outcome.

Mocking with vi.mock

When a unit under test depends on an external module (database client, HTTP client), you mock the dependency to isolate the logic.

// tests/unit/user-service.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService } from '../../src/services/user-service.js';

// Mock the database module
vi.mock('../../src/db/client.js', () => ({
  db: {
    query: vi.fn(),
  },
}));

import { db } from '../../src/db/client.js';

describe('UserService', () => {
  const service = new UserService();

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns user by ID', async () => {
    db.query.mockResolvedValue({
      rows: [{ id: '1', name: 'Alice', email: '[email protected]' }],
    });

    const user = await service.findById('1');

    expect(user).toEqual({
      id: '1',
      name: 'Alice',
      email: '[email protected]',
    });
    expect(db.query).toHaveBeenCalledWith(
      expect.stringContaining('SELECT'),
      ['1']
    );
  });

  it('returns null when user not found', async () => {
    db.query.mockResolvedValue({ rows: [] });

    const user = await service.findById('999');
    expect(user).toBeNull();
  });
});

A word of caution: excessive mocking is the most common testing anti-pattern. If you are mocking five dependencies to test one function, the function probably has too many responsibilities. Prefer integration tests for code that is heavily coupled to infrastructure.

Integration Testing with Supertest

Integration tests verify your API endpoints end-to-end within the process. Supertest binds to your Express app and makes real HTTP requests without starting a server on a port.

// tests/integration/users.test.js
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app.js';
import { db } from '../../src/db/client.js';

describe('GET /api/users/:id', () => {
  let testUser;

  beforeAll(async () => {
    // Seed test data
    const result = await db.query(
      `INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *`,
      ['Test User', '[email protected]']
    );
    testUser = result.rows[0];
  });

  afterAll(async () => {
    await db.query('DELETE FROM users WHERE email = $1', [
      '[email protected]',
    ]);
  });

  it('returns 200 with user data', async () => {
    const res = await request(app)
      .get(`/api/users/${testUser.id}`)
      .set('Authorization', 'Bearer test-token')
      .expect(200);

    expect(res.body).toMatchObject({
      id: testUser.id,
      name: 'Test User',
      email: '[email protected]',
    });
  });

  it('returns 404 for non-existent user', async () => {
    const res = await request(app)
      .get('/api/users/00000000-0000-0000-0000-000000000000')
      .set('Authorization', 'Bearer test-token')
      .expect(404);

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

  it('returns 401 without auth header', async () => {
    await request(app)
      .get(`/api/users/${testUser.id}`)
      .expect(401);
  });
});

Integration test flow

Integration tests give you the highest confidence-per-effort ratio for backend APIs. They verify routing, middleware, validation, business logic, and database queries all work together.

Test Database with Testcontainers

Hard-coding database connection strings for tests is fragile. Testcontainers spins up real Docker containers for each test run, giving you an isolated, disposable database.

// tests/setup-db.js
import { PostgreSqlContainer } from '@testcontainers/postgresql';

let container;

export async function startTestDb() {
  container = await new PostgreSqlContainer('postgres:16')
    .withDatabase('testdb')
    .withUsername('test')
    .withPassword('test')
    .start();

  process.env.DATABASE_URL = container.getConnectionUri();

  // Run migrations
  const { migrate } = await import('../src/db/migrate.js');
  await migrate();
}

export async function stopTestDb() {
  if (container) await container.stop();
}
// tests/setup.js
import { beforeAll, afterAll } from 'vitest';
import { startTestDb, stopTestDb } from './setup-db.js';

beforeAll(async () => {
  await startTestDb();
}, 30000); // Container startup can take 10-20 seconds

afterAll(async () => {
  await stopTestDb();
});

Each test run gets a fresh PostgreSQL instance. No test pollution, no shared state, no “works on my machine” issues. The tradeoff is slower startup (10-20 seconds for the container), but this only happens once per test suite.

E2E Testing

E2E tests run against a fully started server and verify complete user flows. For backend APIs, this means making real HTTP requests to a running instance.

// tests/e2e/auth-flow.test.js
import { describe, it, expect, beforeAll, afterAll } from 'vitest';

const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000';

describe('Authentication flow', () => {
  let authToken;

  it('registers a new user', async () => {
    const res = await fetch(`${BASE_URL}/api/auth/register`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: 'E2E User',
        email: `e2e-${Date.now()}@test.com`,
        password: 'SecurePass123!',
      }),
    });

    expect(res.status).toBe(201);
    const body = await res.json();
    expect(body.token).toBeDefined();
    authToken = body.token;
  });

  it('accesses protected route with token', async () => {
    const res = await fetch(`${BASE_URL}/api/users/me`, {
      headers: { Authorization: `Bearer ${authToken}` },
    });

    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.name).toBe('E2E User');
  });

  it('rejects expired token', async () => {
    const res = await fetch(`${BASE_URL}/api/users/me`, {
      headers: { Authorization: 'Bearer expired.token.here' },
    });

    expect(res.status).toBe(401);
  });
});

E2E tests are sequential by nature — the register test produces a token that subsequent tests use. Keep E2E suites small and focused on critical paths only.

Test Fixtures and Factories

Hard-coding test data in every test file leads to duplication and brittle tests. Use factories to generate test data.

// tests/factories/user-factory.js
let counter = 0;

export function buildUser(overrides = {}) {
  counter++;
  return {
    name: `Test User ${counter}`,
    email: `user-${counter}-${Date.now()}@test.com`,
    password: 'TestPass123!',
    role: 'user',
    ...overrides,
  };
}

export async function createUser(db, overrides = {}) {
  const data = buildUser(overrides);
  const result = await db.query(
    `INSERT INTO users (name, email, password_hash, role)
     VALUES ($1, $2, $3, $4) RETURNING *`,
    [data.name, data.email, await hashPassword(data.password), data.role]
  );
  return { ...result.rows[0], plainPassword: data.password };
}

buildUser creates plain objects for unit tests. createUser inserts into the database for integration tests. The counter and Date.now() ensure uniqueness even when tests run in parallel.

Code Coverage

Code coverage measures which lines, branches, and functions your tests execute. It is a useful metric but not a target to game.

npx vitest run --coverage

Practical coverage targets:

  • 80% line coverage on business logic (services, validators, utilities)
  • 90%+ coverage on critical paths (auth, payments, data mutations)
  • Skip coverage on generated code, config files, and type definitions

Do not aim for 100% coverage. You will end up writing tests for trivial code that prove nothing. Focus on testing behavior that matters — edge cases, error paths, and business rules.

CI Integration

Tests must run automatically on every pull request. Here is a GitHub Actions workflow:

# .github/workflows/test.yml
name: Tests
on: [pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm test
      - run: npm run test:coverage
      - uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info

If you use testcontainers, the CI runner needs Docker. GitHub Actions Ubuntu runners have Docker pre-installed, so this works out of the box.

Block merging when tests fail. This is non-negotiable. A broken test suite that everyone ignores is worse than no tests at all, because it gives false confidence.

What Makes a Good Test Suite

A good test suite is one your team actually trusts and maintains. That means:

  • Fast feedback: Unit and integration tests complete in under 2 minutes. Developers will not wait longer.
  • Deterministic: Tests pass or fail consistently. Flaky tests erode trust. If a test is flaky, fix it or delete it.
  • Independent: Tests do not depend on execution order. Each test sets up and tears down its own state.
  • Readable: A failing test name tells you what broke without reading the implementation. "returns 404 for non-existent user" is better than "test case 7".
  • Maintained: When you change a feature, update the tests in the same PR. Stale tests are dead weight.

The investment in testing pays off exponentially as your codebase grows. Without tests, every change is a gamble. With tests, you refactor with confidence, onboard new developers faster, and deploy without fear.