Jest Testing: Complete Tutorial for JavaScript and React
Jest is the most widely used JavaScript testing framework. It ships with everything you need: a test runner, assertion library, mocking system, and code coverage. This guide covers setup, writing tests, mocking dependencies, testing async code, React Testing Library integration, and common patterns.
Key Takeaways
Jest is batteries-included. Unlike Mocha or Jasmine, Jest includes assertions, mocking, snapshot testing, and coverage out of the box. Zero configuration for most projects.
describe groups tests, it/test defines individual tests. Structure your tests to reflect your code structure — one describe block per function or component, one it block per behavior.
Mocking is how you isolate units. jest.fn() creates mock functions. jest.mock() replaces entire modules. jest.spyOn() wraps existing methods. Use the right tool for each situation.
Async tests require async test functions. Use async/await or return a Promise. Jest waits for the Promise to resolve before considering the test done. Forgetting await is the most common async testing mistake.
Vitest is the modern alternative. If you're starting a new project with Vite, use Vitest instead of Jest — it's faster, has native ESM support, and is API-compatible. If you're on an existing Jest project, stay with Jest.
What Is Jest?
Jest is an open-source JavaScript testing framework developed by Facebook (now Meta). It's the default testing framework for Create React App, Next.js, and many other JavaScript toolchains.
Jest provides everything you need in one package:
- Test runner: Finds and executes test files
- Assertion library:
expect(value).toBe(expected)and 50+ matchers - Mock system: Replace functions and modules with controlled fakes
- Snapshot testing: Serialize and compare complex outputs
- Code coverage: Measure which code your tests execute
Before Jest, you needed separate packages for each of these. Jest unified them into a single tool with a single configuration.
Installing Jest
New Project
npm init -y
npm install --save-dev jest
Add to package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
TypeScript Support
npm install --save-dev jest @types/jest ts-jest
Create jest.config.ts:
import type { Config } from 'jest'
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
}
export default config
React + Testing Library
npm install --save-dev jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom
Create jest.config.js:
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterFramework: ['@testing-library/jest-dom'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
}
Writing Your First Jest Test
Create math.js:
function add(a, b) {
return a + b
}
function divide(a, b) {
if (b === 0) throw new Error('Division by zero')
return a / b
}
module.exports = { add, divide }
Create math.test.js:
const { add, divide } = require('./math')
describe('add', () => {
it('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5)
})
it('adds negative numbers', () => {
expect(add(-1, -2)).toBe(-3)
})
it('handles zero', () => {
expect(add(5, 0)).toBe(5)
})
})
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')
})
})
Run:
npm test
Jest automatically finds files matching *.test.js, *.spec.js, or files in __tests__/ directories.
Jest Matchers
Matchers are the assertion methods on expect().
Equality
expect(value).toBe(4) // Strict equality (===)
expect(value).toEqual({ a: 1 }) // Deep equality (works for objects/arrays)
expect(value).not.toBe(5) // Negation
Use toBe for primitives (numbers, strings, booleans). Use toEqual for objects and arrays.
Truthiness
expect(value).toBeTruthy() // Truthy value
expect(value).toBeFalsy() // Falsy value
expect(value).toBeNull() // null
expect(value).toBeUndefined() // undefined
expect(value).toBeDefined() // not undefined
Numbers
expect(value).toBeGreaterThan(3)
expect(value).toBeGreaterThanOrEqual(3)
expect(value).toBeLessThan(5)
expect(value).toBeCloseTo(0.1 + 0.2, 5) // Float comparison
Strings
expect('hello world').toContain('world')
expect('hello').toMatch(/^hel/)
expect('hello').toHaveLength(5)
Arrays
expect([1, 2, 3]).toContain(2)
expect([1, 2, 3]).toHaveLength(3)
expect([1, 2, 3]).toEqual(expect.arrayContaining([1, 3])) // Subset
Objects
expect({ a: 1, b: 2 }).toHaveProperty('a')
expect({ a: 1, b: 2 }).toHaveProperty('a', 1)
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }) // Partial match
Errors
expect(() => fn()).toThrow()
expect(() => fn()).toThrow('error message')
expect(() => fn()).toThrow(TypeError)
Mocking
Mocking replaces real dependencies with controlled fakes. This isolates the code under test from external services, databases, and time-dependent behavior.
jest.fn() — Mock Functions
const mockFn = jest.fn()
mockFn('hello')
mockFn('world')
expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn).toHaveBeenCalledWith('hello')
expect(mockFn).toHaveBeenLastCalledWith('world')
// Control return values
mockFn.mockReturnValue(42)
mockFn.mockReturnValueOnce(100) // Only for next call
// Async mock
mockFn.mockResolvedValue({ data: 'response' })
mockFn.mockRejectedValueOnce(new Error('Network error'))
jest.mock() — Mock Modules
// Mock entire module
jest.mock('./userService', () => ({
getUser: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
createUser: jest.fn().mockResolvedValue({ id: 2, name: 'Bob' })
}))
// In tests:
import { getUser } from './userService'
test('loads user', async () => {
const user = await getUser(1)
expect(user.name).toBe('Alice')
expect(getUser).toHaveBeenCalledWith(1)
})
jest.spyOn() — Spy on Real Methods
import * as emailService from './emailService'
test('sends welcome email on signup', async () => {
const sendEmailSpy = jest.spyOn(emailService, 'sendEmail')
.mockResolvedValue({ sent: true })
await signupUser({ email: 'user@example.com', password: 'pass123' })
expect(sendEmailSpy).toHaveBeenCalledWith({
to: 'user@example.com',
template: 'welcome'
})
})
Mocking Date and Time
beforeEach(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2026-01-01'))
})
afterEach(() => {
jest.useRealTimers()
})
test('subscription expires after 30 days', () => {
const sub = createSubscription()
expect(sub.expiresAt).toEqual(new Date('2026-01-31'))
})
Testing Async Code
async/await (Recommended)
test('fetches user data', async () => {
const user = await fetchUser(1)
expect(user.name).toBe('Alice')
})
test('handles fetch failure', async () => {
await expect(fetchUser(999)).rejects.toThrow('User not found')
})
Promises
test('fetches user data', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('Alice')
})
})
Common Async Mistake
// WRONG: Test passes even if assertion fails
test('bug: no await', () => {
fetchUser(1).then(user => {
expect(user.name).toBe('wrong name') // This never runs
})
})
// CORRECT: Test fails if assertion fails
test('correct: uses await', async () => {
const user = await fetchUser(1)
expect(user.name).toBe('wrong name') // This correctly fails
})
Always use async/await or return the Promise. Forgetting this is the most common source of tests that always pass but test nothing.
Testing React Components
React Testing Library is the standard companion to Jest for React component testing.
npm install --save-dev @testing-library/react @testing-library/user-event
Basic Component Test
// Button.tsx
function Button({ label, onClick, disabled = false }) {
return (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
)
}
// Button.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'
describe('Button', () => {
it('renders with label', () => {
render(<Button label="Submit" onClick={() => {}} />)
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
})
it('calls onClick when clicked', async () => {
const handleClick = jest.fn()
render(<Button label="Submit" onClick={handleClick} />)
await userEvent.click(screen.getByRole('button', { name: 'Submit' }))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when disabled prop is true', () => {
render(<Button label="Submit" onClick={() => {}} disabled />)
expect(screen.getByRole('button')).toBeDisabled()
})
})
Testing Forms
// LoginForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
test('submits form with email and password', async () => {
const onSubmit = jest.fn()
render(<LoginForm onSubmit={onSubmit} />)
await userEvent.type(screen.getByLabelText('Email'), 'user@example.com')
await userEvent.type(screen.getByLabelText('Password'), 'password123')
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123'
})
})
test('shows error for empty email', async () => {
render(<LoginForm onSubmit={jest.fn()} />)
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Email is required')).toBeInTheDocument()
})
Testing Async Components
// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { UserProfile } from './UserProfile'
jest.mock('./api', () => ({
fetchUser: jest.fn().mockResolvedValue({ name: 'Alice', email: 'alice@example.com' })
}))
test('displays user info after loading', async () => {
render(<UserProfile userId={1} />)
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument()
// After data loads
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
})
Test Organization
Setup and Teardown
describe('Database tests', () => {
beforeAll(async () => {
await db.connect() // Run once before all tests in this block
})
afterAll(async () => {
await db.disconnect() // Run once after all tests
})
beforeEach(async () => {
await db.seed() // Run before each test
})
afterEach(async () => {
await db.clear() // Run after each test
})
test('creates user', async () => {
// ...
})
})
Parameterized Tests (test.each)
describe('validateEmail', () => {
test.each([
['valid@email.com', true],
['invalid-email', false],
['missing@domain', false],
['', false],
['@nodomain.com', false],
])('validateEmail(%s) returns %s', (email, expected) => {
expect(validateEmail(email)).toBe(expected)
})
})
Skipping and Focusing
test.skip('not ready yet', () => { /* ... */ })
test.only('run only this', () => { /* ... */ }) // Run just this test
describe.skip('entire block', () => { /* ... */ })
Snapshot Testing
Snapshots capture component output and detect unintended changes:
import { render } from '@testing-library/react'
import { UserCard } from './UserCard'
test('matches snapshot', () => {
const { container } = render(
<UserCard name="Alice" role="Admin" />
)
expect(container).toMatchSnapshot()
})
The first run creates __snapshots__/UserCard.test.tsx.snap. Subsequent runs compare against it.
Update snapshots after intentional changes:
npx jest --updateSnapshot
Caution: Snapshot tests are easy to write but easy to approve blindly. Use them sparingly for stable, complex outputs — not for every component.
Code Coverage
npx jest --coverage
Output:
----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
math.js | 100 | 100 | 100 | 100 |
utils.js | 75 | 60 | 80 | 75 |
----------|---------|----------|---------|---------|
Configure minimum thresholds in jest.config.js:
module.exports = {
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
}
CI/CD Integration
# .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: '20'
- run: npm ci
- run: npm test -- --coverage --ci
- uses: codecov/codecov-action@v4 # Upload coverage report
The --ci flag:
- Fails if snapshots are outdated (instead of updating)
- Disables interactive mode
- Doesn't print success messages (only failures)
Jest vs Vitest
If you're starting a new project, you might be deciding between Jest and Vitest.
| Feature | Jest | Vitest |
|---|---|---|
| Ecosystem maturity | Mature, huge | Growing fast |
| Speed | Fast | Faster (native ESM, Vite-powered) |
| Config | Separate jest.config | Uses vite.config |
| API compatibility | Standard | Compatible with Jest API |
| Best for | Existing projects, non-Vite | Vite/Nuxt/new projects |
For a new project with Vite (React, Vue, SvelteKit): use Vitest. For an existing Jest project or a non-Vite project: stay with Jest.
The APIs are nearly identical, so switching later is not difficult.
Common Gotchas
ES modules: Jest uses CommonJS by default. If your code uses import/export, configure transform:
// jest.config.js
module.exports = {
transform: {
'^.+\\.js$': 'babel-jest',
},
}
Static assets: Jest runs in Node, not a browser. Mock static assets like images and CSS:
// jest.config.js
moduleNameMapper: {
'\\.(css|scss)$': '<rootDir>/__mocks__/styleMock.js',
'\\.(png|jpg|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
}
act() warnings in React: Wrap state-changing operations:
await act(async () => {
await userEvent.click(button)
})
Need to test beyond unit tests? HelpMeTest automates end-to-end testing so you don't have to write Playwright or Selenium code.