Jest Complete Guide | JavaScript Testing, Mocking, Coverage & Snapshot

Jest Complete Guide | JavaScript Testing, Mocking, Coverage & Snapshot

이 글의 핵심

Jest is the most widely used JavaScript testing framework. This guide covers the complete testing toolkit: matchers, mocks, spies, async testing, snapshot testing, and coverage — with TypeScript and React examples throughout.

Setup

npm install -D jest @types/jest ts-jest

# Or for projects with Babel
npm install -D jest babel-jest @babel/preset-env

# For React testing
npm install -D jest jsdom @testing-library/react @testing-library/jest-dom
// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',       // 'jsdom' for browser/React tests
  roots: ['<rootDir>/src'],
  testMatch: ['**/*.test.ts', '**/*.spec.ts'],
  clearMocks: true,              // Clear mock call history between tests
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/index.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

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

Test Structure

// src/math.test.ts
import { add, multiply, divide } from './math';

// describe groups related tests
describe('Math utilities', () => {
  // it (alias: test) defines individual test cases
  it('adds two numbers', () => {
    expect(add(2, 3)).toBe(5);
  });

  it('multiplies two numbers', () => {
    expect(multiply(4, 5)).toBe(20);
  });

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

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

// Lifecycle hooks
describe('with setup', () => {
  let db: Database;

  beforeAll(async () => {
    db = await Database.connect();       // Once before all tests
  });

  afterAll(async () => {
    await db.disconnect();               // Once after all tests
  });

  beforeEach(() => {
    db.clear();                          // Before each test
  });

  afterEach(() => {
    // cleanup after each test
  });
});

Matchers

// Equality
expect(value).toBe(42);               // ===  (primitives)
expect(obj).toEqual({ a: 1 });        // deep equality (objects/arrays)
expect(obj).toStrictEqual({ a: 1 }); // deep + checks undefined properties

// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// Numbers
expect(0.1 + 0.2).toBeCloseTo(0.3, 5);  // floating point
expect(value).toBeGreaterThan(3);
expect(value).toBeLessThanOrEqual(10);

// Strings
expect(str).toMatch(/pattern/);
expect(str).toContain('substring');
expect(str).toHaveLength(5);

// Arrays
expect(arr).toContain('item');
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining(['a', 'b']));

// Objects
expect(obj).toHaveProperty('key');
expect(obj).toHaveProperty('nested.key', 'value');
expect(obj).toMatchObject({ name: 'Alice' });  // partial match

// Errors
expect(() => fn()).toThrow();
expect(() => fn()).toThrow(Error);
expect(() => fn()).toThrow('specific message');
expect(() => fn()).toThrow(/pattern/);

// Negation
expect(value).not.toBe(0);
expect(arr).not.toContain('x');

Async Testing

// async/await (preferred)
it('fetches user data', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
});

// Promise return
it('resolves correctly', () => {
  return fetchUser(1).then(user => {
    expect(user.name).toBe('Alice');
  });
});

// Async errors
it('rejects on invalid ID', async () => {
  await expect(fetchUser(-1)).rejects.toThrow('User not found');
  await expect(fetchUser(-1)).rejects.toMatchObject({ code: 404 });
});

// Assertion count — ensures async assertions actually run
it('multiple async assertions', async () => {
  expect.assertions(2);          // Fail if not exactly 2 assertions run
  const user = await fetchUser(1);
  expect(user.id).toBe(1);
  expect(user.name).toBeDefined();
});

// Timers
it('calls callback after delay', () => {
  jest.useFakeTimers();
  const callback = jest.fn();

  setTimeout(callback, 1000);
  expect(callback).not.toHaveBeenCalled();

  jest.advanceTimersByTime(1000);
  expect(callback).toHaveBeenCalledTimes(1);

  jest.useRealTimers();
});

Mock Functions

// Create a mock function
const mockFn = jest.fn();

mockFn(1, 'hello');
mockFn(2, 'world');

// Inspect calls
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith(1, 'hello');
expect(mockFn).toHaveBeenLastCalledWith(2, 'world');
expect(mockFn).toHaveBeenNthCalledWith(1, 1, 'hello');

// Return values
const mockGet = jest.fn()
  .mockReturnValue('default')           // Always returns this
  .mockReturnValueOnce('first call')    // First call
  .mockReturnValueOnce('second call');  // Second call

// Async return
const mockFetch = jest.fn()
  .mockResolvedValue({ data: 'ok' })        // Always resolves
  .mockRejectedValueOnce(new Error('fail')); // First call rejects

// Implementation
const mockCalc = jest.fn().mockImplementation((a, b) => a + b);

// Access call data
console.log(mockFn.mock.calls);          // [[1, 'hello'], [2, 'world']]
console.log(mockFn.mock.results);        // [{ type: 'return', value: ... }]
console.log(mockFn.mock.instances);      // 'this' for each call

Module Mocking

// Auto-mock entire module
jest.mock('./database');

// Factory function mock — control implementation
jest.mock('./email-service', () => ({
  sendEmail: jest.fn().mockResolvedValue({ success: true }),
  sendBulk: jest.fn().mockResolvedValue({ sent: 100 }),
}));

// Partial mock — keep some real implementations
jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'),  // Keep real implementations
  formatDate: jest.fn().mockReturnValue('2026-01-01'),  // Override this one
}));

// Example: testing a service that depends on a mocked module
import { sendEmail } from './email-service';
import { UserService } from './user-service';

describe('UserService', () => {
  it('sends welcome email on registration', async () => {
    const service = new UserService();
    await service.register({ email: '[email protected]', name: 'Alice' });

    expect(sendEmail).toHaveBeenCalledWith({
      to: '[email protected]',
      subject: 'Welcome!',
      body: expect.stringContaining('Alice'),
    });
  });
});

Mocking Node.js Built-ins

// Mock fs
jest.mock('fs/promises');
import { readFile, writeFile } from 'fs/promises';

const mockReadFile = readFile as jest.MockedFunction<typeof readFile>;
mockReadFile.mockResolvedValue('file content' as any);

// Mock path (usually not needed, but possible)
jest.mock('path', () => ({
  ...jest.requireActual('path'),
  join: jest.fn().mockReturnValue('/mocked/path'),
}));

jest.spyOn

import * as emailModule from './email-service';

describe('UserService', () => {
  it('sends email without mocking the whole module', async () => {
    // Spy on a specific method — original implementation runs unless overridden
    const spy = jest.spyOn(emailModule, 'sendEmail')
      .mockResolvedValue({ success: true });

    const service = new UserService();
    await service.register({ email: '[email protected]', name: 'Alice' });

    expect(spy).toHaveBeenCalledTimes(1);
    expect(spy).toHaveBeenCalledWith(expect.objectContaining({
      to: '[email protected]',
    }));

    spy.mockRestore();  // Restore original implementation
  });
});

// Spy on class methods
class Calculator {
  add(a: number, b: number) { return a + b; }
}

const calc = new Calculator();
const spy = jest.spyOn(calc, 'add');
calc.add(1, 2);
expect(spy).toHaveBeenCalledWith(1, 2);

Snapshot Testing

// Snapshot captures serializable output and fails if it changes
it('renders user profile correctly', () => {
  const profile = generateUserProfile({ name: 'Alice', role: 'admin' });
  expect(profile).toMatchSnapshot();
  // On first run: creates __snapshots__/user.test.ts.snap
  // On subsequent runs: compares against saved snapshot
});

// Inline snapshot — snapshot stored in the test file
it('formats price correctly', () => {
  expect(formatPrice(1234.56, 'USD')).toMatchInlineSnapshot(
    `"$1,234.56"`
  );
});

// Update snapshots when intentional changes occur
// jest --updateSnapshot (or jest -u)
// React component snapshots with Testing Library
import { render } from '@testing-library/react';
import { Button } from './Button';

it('matches snapshot', () => {
  const { container } = render(<Button variant="primary">Click me</Button>);
  expect(container.firstChild).toMatchSnapshot();
});

Testing React Components

// jest.config.ts for React
const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  setupFilesAfterFramework: ['<rootDir>/src/setupTests.ts'],
};

// src/setupTests.ts
import '@testing-library/jest-dom';   // Adds custom matchers

// src/components/Button.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

describe('Button', () => {
  it('renders with correct text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
  });

  it('calls onClick when clicked', async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();

    render(<Button onClick={handleClick}>Click me</Button>);
    await user.click(screen.getByRole('button'));

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when loading', () => {
    render(<Button loading>Submit</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

Testing Async Components

import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';

jest.mock('../api/users', () => ({
  fetchUser: jest.fn().mockResolvedValue({
    id: 1,
    name: 'Alice',
    email: '[email protected]',
  }),
}));

it('loads and displays user data', async () => {
  render(<UserProfile userId={1} />);

  // Initially shows loading state
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // Wait for async update
  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument();
  });

  expect(screen.getByText('[email protected]')).toBeInTheDocument();
});

it('shows error on fetch failure', async () => {
  const { fetchUser } = require('../api/users');
  fetchUser.mockRejectedValueOnce(new Error('Network error'));

  render(<UserProfile userId={1} />);

  await waitFor(() => {
    expect(screen.getByText('Failed to load user')).toBeInTheDocument();
  });
});

Custom Matchers

// src/matchers/index.ts
expect.extend({
  toBeWithinRange(received: number, floor: number, ceiling: number) {
    const pass = received >= floor && received <= ceiling;
    if (pass) {
      return {
        message: () =>
          `expected ${received} not to be within range ${floor} - ${ceiling}`,
        pass: true,
      };
    } else {
      return {
        message: () =>
          `expected ${received} to be within range ${floor} - ${ceiling}`,
        pass: false,
      };
    }
  },
});

// Usage
expect(50).toBeWithinRange(1, 100);   // passes
expect(150).toBeWithinRange(1, 100);  // fails

// TypeScript: declare the custom matcher
declare global {
  namespace jest {
    interface Matchers<R> {
      toBeWithinRange(floor: number, ceiling: number): R;
    }
  }
}

Code Coverage

# Run coverage
jest --coverage

# Output:
# ----------|---------|----------|---------|---------|
# File       | % Stmts | % Branch | % Funcs | % Lines |
# ----------|---------|----------|---------|---------|
# All files  |   87.5  |    75.0  |   90.0  |   87.5  |
#  math.ts   |  100.0  |   100.0  |  100.0  |  100.0  |
#  user.ts   |   75.0  |    50.0  |   80.0  |   75.0  |
// jest.config.ts — coverage configuration
const config: Config = {
  collectCoverage: false,     // Don't collect by default (use --coverage flag)
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{ts,tsx}',
    '!src/index.ts',
  ],
  coverageReporters: ['text', 'lcov', 'html'],
  coverageDirectory: 'coverage',
  coverageThreshold: {
    global: {
      statements: 80,
      branches: 70,
      functions: 80,
      lines: 80,
    },
    // Per-file threshold
    './src/critical/': {
      statements: 95,
    },
  },
};

Running Tests in CI

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      - name: Run tests
        run: npm run test:ci

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info
// package.json — CI test script
{
  "scripts": {
    "test:ci": "jest --ci --coverage --maxWorkers=2"
  }
}

--ci flag: fails on new snapshots (don’t create them in CI), disables interactive mode.


Common Patterns

Testing Pure Functions

import { calculateTax, formatCurrency } from './finance';

describe('calculateTax', () => {
  test.each([
    [100, 0.1, 10],
    [200, 0.2, 40],
    [0, 0.1, 0],
  ])('calculateTax(%d, %d) = %d', (amount, rate, expected) => {
    expect(calculateTax(amount, rate)).toBe(expected);
  });
});

Testing with Environment Variables

describe('config', () => {
  const originalEnv = process.env;

  beforeEach(() => {
    jest.resetModules();  // Clear module cache (important for env-dependent modules)
    process.env = { ...originalEnv };
  });

  afterEach(() => {
    process.env = originalEnv;
  });

  it('uses production DB in prod env', () => {
    process.env.NODE_ENV = 'production';
    process.env.DB_URL = 'postgres://prod-host/db';

    const { config } = require('./config');  // Fresh require after resetModules
    expect(config.dbUrl).toBe('postgres://prod-host/db');
  });
});

Database Integration Tests

// Use a real test database — don't mock the DB layer
import { db } from '../lib/db';
import { UserRepository } from './user-repository';

describe('UserRepository', () => {
  beforeEach(async () => {
    await db.user.deleteMany();  // Clean state
  });

  afterAll(async () => {
    await db.$disconnect();
  });

  it('creates and retrieves a user', async () => {
    const repo = new UserRepository(db);
    const created = await repo.create({ email: '[email protected]', name: 'Alice' });

    expect(created.id).toBeDefined();
    expect(created.email).toBe('[email protected]');

    const found = await repo.findById(created.id);
    expect(found).toEqual(created);
  });
});

Related posts: