Jest Complete Guide | JavaScript Testing· Mocking
이 글의 핵심
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);
});
});
Internals snapshot (runner, mocks, coverage)
Runner: Jest maintains its own haste map for fast incremental builds, spawns child processes via jest-worker, and runs each test file in a sandboxed environment (node, jsdom, or custom). Parallelism is great until shared state leaks—use clearMocks and isolate env mutations.
Mocks: jest.mock is hoisted; factories run before your imports bind. Use jest.requireActual for partial mocks. jest.spyOn observes real implementations—prefer it when you only need call counts.
Coverage: Istanbul instrumentation rewrites sources at transform time. Thresholds should exclude generated files. Coverage proves execution, not spec correctness—pair with integration and E2E tests.
E2E & production: Jest targets developer machines and CI. For browser automation see [Playwright E2E Guide](/en/blog/playwright-e2e-testing-guide/; for narrower Jest-focused workflows see Jest Testing Guide.
Related posts:
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. Master Jest for JavaScript and TypeScript testing. Covers test suites, matchers, mock functions, module mocking, spies, …
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
- Vitest 완벽 가이드 — Vite 네이티브 테스트 러너, Jest보다 10배 빠르게
- Testing Library 완벽 가이드 | React·Vue·사용자 중심 테스트·실전 활용
- GitHub Actions 완벽 가이드 | CI/CD·자동화·Workflow·배포·실전 활용
Keywords Covered in This Article (Related Search Terms)
This article covers Jest, Testing, JavaScript, TypeScript, Node.js, React, TDD.