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: 'test@example.com', name: 'Alice' });
expect(sendEmail).toHaveBeenCalledWith({
to: 'test@example.com',
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: 'test@example.com', name: 'Alice' });
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
to: 'test@example.com',
}));
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: 'alice@example.com',
}),
}));
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('alice@example.com')).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: 'test@example.com', name: 'Alice' });
expect(created.id).toBeDefined();
expect(created.email).toBe('test@example.com');
const found = await repo.findById(created.id);
expect(found).toEqual(created);
});
});
Related posts: