Jest Testing: Complete Tutorial for JavaScript and React

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

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.

Read more