Jest - The Established Standard
Jest is Facebook's JavaScript testing framework that became the de facto standard for testing React applications and Node.js projects. It provides a complete testing solution out of the box: test runner, assertion library, mocking utilities, and code coverage. While Vitest is gaining ground for Vite-based projects, Jest remains the right choice for many existing codebases and specific ecosystem needs.
When to Use Jest vs. Vitest
Choose Jest when:
- You have an existing Jest test suite (migration has costs)
- You're using Create React App, Next.js, or other Jest-preconfigured setups
- You need mature snapshot testing with established workflows
- Your team knows Jest and values stability over cutting-edge features
- You're testing Node.js backend code without a Vite build
Choose Vitest when:
- You're starting a new Vite-based project
- You need native ESM support without configuration headaches
- You want faster test execution with Vite's HMR
- You're already using Vite and want unified configuration
Installation and Setup
pnpm add -D jest @types/jest
For TypeScript projects, you'll need a transformer. The three main options:
Option 1: ts-jest (Most Compatible)
pnpm add -D ts-jest
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
Option 2: @swc/jest (Fastest)
pnpm add -D @swc/jest @swc/core
// jest.config.js
module.exports = {
transform: {
'^.+\\.(t|j)sx?$': '@swc/jest',
},
testEnvironment: 'node',
};
Option 3: babel-jest (Most Flexible)
pnpm add -D babel-jest @babel/core @babel/preset-env @babel/preset-typescript
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
};
// jest.config.js
module.exports = {
testEnvironment: 'node',
};
Recommendation: Use @swc/jest for speed in most projects. Use ts-jest if you need full TypeScript type-checking during tests or have complex TypeScript features.
Add Test Scripts
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
Configuration Deep Dive
Create jest.config.js (or jest.config.ts with ts-node) in your project root:
// jest.config.js
/** @type {import('jest').Config} */
module.exports = {
// TypeScript transformation
preset: 'ts-jest',
// Test environment
testEnvironment: 'jsdom', // or 'node' for backend
// Where to find tests
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
// Module resolution (match your tsconfig paths)
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
// Handle CSS imports in tests
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
// Handle image imports
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
// Setup files
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
// Coverage configuration
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/test/**',
'!src/**/index.ts', // barrel files
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
// Performance
maxWorkers: '50%',
// Clear mocks between tests
clearMocks: true,
};
Essential Configuration Options
| Option | Description | Common Values |
|---|---|---|
testEnvironment |
DOM or Node.js | 'jsdom', 'node' |
roots |
Where to look for tests | ['<rootDir>/src'] |
testMatch |
File patterns | ['**/*.test.ts'] |
moduleNameMapper |
Path aliases, asset mocks | See above |
setupFilesAfterEnv |
Run before each test file | ['./src/test/setup.ts'] |
transform |
Code transformers | { '^.+\\.tsx?$': 'ts-jest' } |
maxWorkers |
Parallelism | '50%', 4, 1 |
testTimeout |
Default timeout (ms) | 5000 |
Core API
Test Structure
// src/utils/math.test.ts
import { add, divide } from './math';
describe('math utilities', () => {
describe('add', () => {
it('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(add(-1, 1)).toBe(0);
});
test('is an alias for it', () => {
// test() and it() are identical
expect(add(0, 0)).toBe(0);
});
});
describe('divide', () => {
it('divides numbers correctly', () => {
expect(divide(10, 2)).toBe(5);
});
it('throws on division by zero', () => {
expect(() => divide(1, 0)).toThrow('Cannot divide by zero');
});
});
});
Matchers
// Equality
expect(value).toBe(5); // Strict equality (Object.is)
expect(obj).toEqual({ a: 1 }); // Deep equality
expect(obj).toStrictEqual({ a: 1 }); // Deep equality + prototype check
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
expect(value).toBeNaN();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(5);
expect(0.1 + 0.2).toBeCloseTo(0.3, 5); // 5 decimal places
// Strings
expect(str).toMatch(/pattern/);
expect(str).toContain('substring');
expect(str).toHaveLength(5);
// Arrays
expect(arr).toContain(item);
expect(arr).toContainEqual({ id: 1 }); // Deep equality for items
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining([1, 2]));
// Objects
expect(obj).toHaveProperty('key');
expect(obj).toHaveProperty('nested.key', 'value');
expect(obj).toMatchObject({ partial: 'match' });
expect(obj).toEqual(expect.objectContaining({ key: 'value' }));
// Exceptions
expect(() => fn()).toThrow();
expect(() => fn()).toThrow('message');
expect(() => fn()).toThrow(Error);
expect(() => fn()).toThrow(/pattern/);
// Negation
expect(value).not.toBe(5);
expect(arr).not.toContain(item);
Async Testing
// Async/await (recommended)
it('fetches user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('John');
});
// Promises
it('fetches user data', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('John');
});
});
// Resolves/rejects matchers
it('resolves with user', async () => {
await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'John' });
});
it('rejects for invalid id', async () => {
await expect(fetchUser(-1)).rejects.toThrow('Not found');
});
// Callbacks (legacy)
it('calls callback with data', done => {
fetchUserCallback(1, (err, user) => {
expect(err).toBeNull();
expect(user.name).toBe('John');
done();
});
});
Setup and Teardown
describe('database operations', () => {
// Run once before all tests in this describe
beforeAll(async () => {
await db.connect();
});
// Run once after all tests
afterAll(async () => {
await db.disconnect();
});
// Run before each test
beforeEach(async () => {
await db.beginTransaction();
});
// Run after each test
afterEach(async () => {
await db.rollback();
});
it('creates user', async () => {
const user = await db.createUser({ name: 'John' });
expect(user.id).toBeDefined();
});
});
Setup functions can be nested and run in order: outer beforeAll → outer beforeEach → inner beforeEach → test → inner afterEach → outer afterEach.
Mocking
Function Mocks with jest.fn()
// Basic mock function
const mockFn = jest.fn();
mockFn('arg1', 'arg2');
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('arg1', 'arg2');
// Return values
const mockFn = jest.fn()
.mockReturnValue('default')
.mockReturnValueOnce('first')
.mockReturnValueOnce('second');
mockFn(); // 'first'
mockFn(); // 'second'
mockFn(); // 'default'
// Async return values
const mockAsync = jest.fn()
.mockResolvedValue({ data: 'success' })
.mockResolvedValueOnce({ data: 'first' })
.mockRejectedValueOnce(new Error('fail'));
// Custom implementation
const mockFn = jest.fn((x: number) => x * 2);
expect(mockFn(5)).toBe(10);
Module Mocking with jest.mock()
// src/services/userService.ts
import { api } from './api';
export async function getUser(id: number) {
const response = await api.get(`/users/${id}`);
return response.data;
}
// src/services/userService.test.ts
import { getUser } from './userService';
import { api } from './api';
// Mock the entire module
jest.mock('./api');
// TypeScript: cast to mocked type
const mockedApi = jest.mocked(api);
describe('getUser', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('fetches user from API', async () => {
mockedApi.get.mockResolvedValue({ data: { id: 1, name: 'John' } });
const user = await getUser(1);
expect(mockedApi.get).toHaveBeenCalledWith('/users/1');
expect(user).toEqual({ id: 1, name: 'John' });
});
});
Factory Mocking
Provide custom implementations:
jest.mock('./api', () => ({
api: {
get: jest.fn(),
post: jest.fn(),
},
}));
// Access original module
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
helperFunction: jest.fn(),
}));
Manual Mocks with __mocks__
Create manual mocks in a __mocks__ directory adjacent to the module:
src/
services/
api.ts
__mocks__/
api.ts
// src/services/__mocks__/api.ts
export const api = {
get: jest.fn().mockResolvedValue({ data: {} }),
post: jest.fn().mockResolvedValue({ data: {} }),
};
Then in your test:
jest.mock('./api'); // Automatically uses __mocks__/api.ts
Spying with jest.spyOn()
const user = {
getName() {
return 'John';
},
};
// Spy without changing implementation
const spy = jest.spyOn(user, 'getName');
user.getName();
expect(spy).toHaveBeenCalled();
// Spy with mock implementation
jest.spyOn(user, 'getName').mockReturnValue('Jane');
expect(user.getName()).toBe('Jane');
// Spy on prototype methods
jest.spyOn(Array.prototype, 'push');
// Restore original
spy.mockRestore();
Timer Mocks
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('debounces function calls', () => {
const fn = jest.fn();
const debounced = debounce(fn, 1000);
debounced();
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalledTimes(1);
});
// Other timer methods
jest.runAllTimers(); // Run all pending timers
jest.runOnlyPendingTimers(); // Run pending, not newly scheduled
jest.advanceTimersToNextTimer(); // Advance to next timer
jest.setSystemTime(new Date('2024-01-01')); // Mock Date.now()
Snapshot Testing
Snapshots capture output and compare against stored versions:
// Component snapshot
it('renders correctly', () => {
const tree = renderer.create(<Button label="Click me" />).toJSON();
expect(tree).toMatchSnapshot();
});
// Data snapshot
it('returns expected structure', () => {
const config = generateConfig({ env: 'production' });
expect(config).toMatchSnapshot();
});
// Inline snapshot (stored in test file)
it('formats date correctly', () => {
expect(formatDate(new Date('2024-01-15'))).toMatchInlineSnapshot(
`"January 15, 2024"`
);
});
Update snapshots when intentional changes occur:
pnpm test -- -u
See Snapshot Testing for detailed guidance on when and how to use snapshots effectively.
Performance Optimization
Running Tests in Band
For CI or when tests conflict:
jest --runInBand # Run serially, one at a time
Controlling Workers
jest --maxWorkers=4 # Fixed number
jest --maxWorkers=50% # Percentage of CPUs
Test Sharding
Split tests across CI jobs:
# Job 1
jest --shard=1/3
# Job 2
jest --shard=2/3
# Job 3
jest --shard=3/3
Speeding Up TypeScript
If ts-jest is slow, switch to @swc/jest:
// jest.config.js
module.exports = {
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest', {
jsc: {
transform: {
react: {
runtime: 'automatic',
},
},
},
}],
},
};
Caching
Jest caches transformed files. Clear cache if you have stale results:
jest --clearCache
Running Tests
Basic Commands
# Run all tests
pnpm test
# Watch mode
pnpm test -- --watch
# Watch only changed files
pnpm test -- --watchAll=false --onlyChanged
# Run specific file
pnpm test -- src/utils/math.test.ts
# Run tests matching pattern
pnpm test -- --testNamePattern="creates user"
# Run tests in specific directory
pnpm test -- --testPathPattern="src/components"
# Verbose output
pnpm test -- --verbose
Filtering in Code
// Skip tests
describe.skip('broken feature', () => {});
it.skip('incomplete test', () => {});
// Focus tests (only these run)
describe.only('feature I am debugging', () => {});
it.only('just this test', () => {});
// Conditional
const isCI = process.env.CI === 'true';
(isCI ? it.skip : it)('skipped in CI', () => {});
Code Coverage
pnpm test -- --coverage
Coverage configuration in jest.config.js:
module.exports = {
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/test/**',
],
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
// Stricter for specific paths
'./src/utils/': {
branches: 95,
functions: 95,
lines: 95,
statements: 95,
},
},
};
Testing React Components
Install Testing Library:
pnpm add -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
Setup file:
// src/test/setup.ts
import '@testing-library/jest-dom';
// jest.config.js
module.exports = {
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
testEnvironment: 'jsdom',
};
Example component test:
// src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('increments count on click', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});
Gotchas and Tips
ESM Support
Jest's ESM support is experimental. For projects using ES modules:
// jest.config.js
module.exports = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
transform: {
'^.+\\.tsx?$': ['ts-jest', { useESM: true }],
},
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
};
Or switch to Vitest for native ESM.
Mocking Node Modules
Mock packages in node_modules by creating __mocks__ at project root:
__mocks__/
axios.ts
src/
...
// __mocks__/axios.ts
export default {
get: jest.fn().mockResolvedValue({ data: {} }),
post: jest.fn().mockResolvedValue({ data: {} }),
};
Debugging Tests
# Run with Node debugger
node --inspect-brk node_modules/.bin/jest --runInBand
# Or in VS Code, add launch config:
{
"type": "node",
"request": "launch",
"name": "Debug Jest",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["--runInBand", "--no-cache"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
Common Errors
"Cannot use import statement outside a module"
- Your transformer isn't processing the file. Check
transformconfig. - The file might be in
node_modules. Add totransformIgnorePatterns.
"Jest encountered an unexpected token"
- Similar cause. Ensure the file extension is being transformed.
Tests pass individually but fail together
- Shared mutable state. Use
beforeEachto reset state. - Mock not cleared. Add
clearMocks: trueto config or calljest.clearAllMocks().
See Also
- Jest Documentation
- Testing Overview - Testing philosophy and strategies
- Vitest - Modern alternative for Vite projects
- Mocking Strategies - Deep dive into mocking
- Snapshot Testing - When and how to use snapshots