TechLead
Lesson 14 of 16
5 min read
Node.js

Testing Node.js with Jest & Vitest

Write unit tests, mock modules and functions, test async code, measure coverage reports, and integration test HTTP endpoints with supertest

Testing in Node.js

Automated testing is essential for maintaining code quality and preventing regressions. Modern Node.js testing has converged around Jest and Vitest, both offering similar APIs but with Vitest providing faster execution through native ESM and Vite integration.

🧪 Testing Pyramid

E2E Tests

Few, slow, high confidence

Integration Tests

Some, medium speed

Unit Tests

Many, fast, low overhead

Unit Testing Basics

// math.js
export function add(a, b) { return a + b; }
export function divide(a, b) {
  if (b === 0) throw new Error('Division by zero');
  return a / b;
}

// math.test.js
import { describe, it, expect } from 'vitest'; // or jest
import { add, divide } from './math.js';

describe('Math utilities', () => {
  describe('add', () => {
    it('adds two positive numbers', () => {
      expect(add(2, 3)).toBe(5);
    });

    it('handles negative numbers', () => {
      expect(add(-1, -2)).toBe(-3);
    });

    it('handles zero', () => {
      expect(add(0, 5)).toBe(5);
    });
  });

  describe('divide', () => {
    it('divides two numbers', () => {
      expect(divide(10, 2)).toBe(5);
    });

    it('returns decimals', () => {
      expect(divide(1, 3)).toBeCloseTo(0.333, 2);
    });

    it('throws on division by zero', () => {
      expect(() => divide(10, 0)).toThrow('Division by zero');
    });
  });
});

Mocking Modules and Functions

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getUser, createUser } from './user-service.js';

// Mock entire module
vi.mock('./database.js', () => ({
  query: vi.fn(),
  connect: vi.fn(),
}));

import { query } from './database.js';

describe('UserService', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('fetches a user by ID', async () => {
    // Arrange: set up mock return value
    query.mockResolvedValue({
      rows: [{ id: 1, name: 'Alice', email: 'alice@test.com' }]
    });

    // Act
    const user = await getUser(1);

    // Assert
    expect(query).toHaveBeenCalledWith(
      'SELECT * FROM users WHERE id = $1',
      [1]
    );
    expect(user).toEqual({
      id: 1,
      name: 'Alice',
      email: 'alice@test.com'
    });
  });

  it('throws NotFoundError for missing user', async () => {
    query.mockResolvedValue({ rows: [] });
    await expect(getUser(999)).rejects.toThrow('User not found');
  });

  it('creates a user with hashed password', async () => {
    query.mockResolvedValue({
      rows: [{ id: 1, name: 'Bob', email: 'bob@test.com' }]
    });

    const user = await createUser({
      name: 'Bob',
      email: 'bob@test.com',
      password: 'secret123'
    });

    // Verify password was hashed (not stored in plain text)
    const callArgs = query.mock.calls[0][1];
    expect(callArgs[2]).not.toBe('secret123');
    expect(callArgs[2]).toMatch(/^\$2[aby]?\$/); // bcrypt pattern
  });
});

Testing Async Code

import { describe, it, expect, vi } from 'vitest';

// Test promises
it('resolves with user data', async () => {
  const data = await fetchUser(1);
  expect(data).toHaveProperty('name');
});

// Test rejections
it('rejects with error for invalid ID', async () => {
  await expect(fetchUser(-1)).rejects.toThrow('Invalid ID');
});

// Test timers
describe('delayed operations', () => {
  it('retries failed operations', async () => {
    vi.useFakeTimers();
    const mockFetch = vi.fn()
      .mockRejectedValueOnce(new Error('Network error'))
      .mockRejectedValueOnce(new Error('Network error'))
      .mockResolvedValueOnce({ data: 'success' });

    const promise = fetchWithRetry(mockFetch, { maxRetries: 3, delay: 1000 });

    // Fast-forward through retry delays
    await vi.advanceTimersByTimeAsync(1000); // First retry
    await vi.advanceTimersByTimeAsync(1000); // Second retry

    const result = await promise;
    expect(result).toEqual({ data: 'success' });
    expect(mockFetch).toHaveBeenCalledTimes(3);

    vi.useRealTimers();
  });
});

// Test event emitters
it('emits events in order', () => {
  const emitter = new TaskRunner();
  const events = [];

  emitter.on('start', () => events.push('start'));
  emitter.on('complete', () => events.push('complete'));

  emitter.run();

  expect(events).toEqual(['start', 'complete']);
});

Integration Testing with Supertest

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from './app.js';
import { setupTestDB, teardownTestDB } from './test-helpers.js';

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

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

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

  it('POST /api/users creates a user', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@test.com' })
      .expect(201);

    expect(res.body).toMatchObject({
      name: 'Alice',
      email: 'alice@test.com'
    });
    expect(res.body).toHaveProperty('id');
  });

  it('GET /api/users/:id returns user', async () => {
    const res = await request(app)
      .get('/api/users/1')
      .set('Authorization', 'Bearer test-token')
      .expect(200);

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

  it('returns 400 for invalid input', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: '' })
      .expect(400);

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

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

Code Coverage and Configuration

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: [
        'node_modules/',
        'test/',
        '**/*.test.ts',
        '**/*.d.ts',
      ],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      }
    },
    setupFiles: ['./test/setup.ts'],
  }
});

// test/setup.ts
import { beforeAll, afterAll } from 'vitest';

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

afterAll(async () => {
  // Global cleanup
});

// Run tests:
// npx vitest              # Watch mode
// npx vitest run          # Single run
// npx vitest --coverage   # With coverage report
// npx vitest --reporter=verbose  # Detailed output

💡 Key Takeaways

  • • Follow the testing pyramid: many unit tests, fewer integration tests
  • • Use vi.mock() to isolate units from their dependencies
  • • Test both success paths and error paths
  • • Use supertest for HTTP endpoint integration tests
  • • Set coverage thresholds to maintain quality over time

Continue Learning