Back to Blog

Technical Blog

Articles on testing, frontend development, and engineering best practices.

Mastering Jest: Unit Testing for Modern JavaScript Apps

Write reliable unit and integration tests with Jest, React Testing Library, and advanced mocking strategies.

1. Why Jest?

Jest is a delightful JavaScript testing framework with a focus on simplicity. Created by Meta (Facebook), it's the go-to test runner for React projects and works beautifully with any JavaScript codebase.

Key Features

  • Zero Configuration — Works out of the box for most JavaScript projects
  • Fast Execution — Parallel test running and intelligent test isolation
  • Built-in Mocking — Powerful mocking for functions, modules, and timers
  • Snapshot Testing — Catch unexpected UI changes automatically
  • Code Coverage — Built-in coverage reports with no extra setup
  • Rich Assertions — Comprehensive matcher library for any assertion need
FeatureJestMocha
SetupZero configRequires config
Assertion LibraryBuilt-in (expect)Needs Chai
MockingBuilt-inSinon (external)
CoverageBuilt-in (Istanbul)Needs nyc
Snapshot TestingBuilt-inNot available
Parallel ExecutionAutomaticManual setup

2. Setting Up Jest

For React Projects (with CRA)

# Jest comes pre-installed with Create React App
npx create-react-app my-app
cd my-app

# Just run the tests
npm test

For Vanilla JavaScript Projects

# Install Jest
npm install --save-dev jest

# Add test script to package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

jest.config.js

module.exports = {
  testEnvironment: 'jsdom',  // For React/browser-like environment
  collectCoverageFrom: [
    'src/**/*.{js,jsx}',
    '!src/index.js',
    '!src/reportWebVitals.js',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  setupFilesAfterSetup: ['@testing-library/jest-dom'],
}

3. Test Anatomy

Every Jest test follows a predictable structure using describe, it, and expect.

// math.test.js

describe('Math Operations', () => {

  describe('add()', () => {
    it('adds two positive numbers', () => {
      expect(add(2, 3)).toBe(5)
    })

    it('adds negative numbers', () => {
      expect(add(-1, -3)).toBe(-4)
    })

    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('Cannot divide by zero')
    })
  })

})
One assertion per test: Each it() block should test one specific behavior. This makes failures easier to identify and debug.

4. Matchers & Assertions

Jest's expect() comes with a rich set of matchers for any assertion scenario.

Common Matchers

// Equality
expect(value).toBe(42)            // Strict equality (===)
expect(obj).toEqual({ a: 1 })    // Deep equality

// Truthiness
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeDefined()

// Numbers
expect(price).toBeGreaterThan(10)
expect(price).toBeLessThanOrEqual(100)
expect(price).toBeCloseTo(9.99, 1)

// Strings
expect(name).toContain('Avinash')
expect(name).toMatch(/^A\w+/)

// Arrays
expect(items).toContain('React')
expect(items).toHaveLength(3)

// Objects
expect(user).toHaveProperty('name', 'John')
expect(user).toHaveProperty('email')
expect(user).toMatchObject({
  name: 'John',
  role: 'developer',
})

// Exceptions
expect(() => badFn()).toThrow()
expect(() => badFn()).toThrow('Error message')
expect(() => badFn()).toThrow(Error)

// Not
expect(value).not.toBe(0)
expect(items).not.toContain('Vue')

DOM Assertions (@testing-library/jest-dom)

import '@testing-library/jest-dom'

// Element visibility
expect(element).toBeVisible()
expect(element).not.toBeInTheDocument()

// Classes & Attributes
expect(button).toHaveClass('active')
expect(link).toHaveAttribute('href', '/about')

// Content
expect(element).toHaveTextContent('Hello')
expect(element).toHaveValue('test@email.com')

// Focus
expect(input).toHaveFocus()
expect(input).not.toHaveFocus()

// Disabled
expect(button).toBeDisabled()
expect(button).not.toBeDisabled()

5. Setup & Teardown

Jest provides hooks to run code before and after each test or test suite.

describe('Database Tests', () => {
  let db

  // Run once before ALL tests in this describe block
  beforeAll(async () => {
    db = await createDatabase()
  })

  // Run before EACH test
  beforeEach(async () => {
    await db.clearUsers()
    await db.insertUser({ name: 'Test User', email: 'test@test.com' })
  })

  // Run after EACH test
  afterEach(async () => {
    await db.clearLogs()
  })

  // Run once after ALL tests
  afterAll(async () => {
    await db.close()
  })

  it('fetches user', async () => {
    const user = await db.findUser('test@test.com')
    expect(user).toBeTruthy()
    expect(user.name).toBe('Test User')
  })

  it('deletes user', async () => {
    await db.deleteUser('test@test.com')
    const user = await db.findUser('test@test.com')
    expect(user).toBeNull()
  })
})

6. Mocking & Spying

Mocking isolates your tests from external dependencies and gives you full control over behavior.

Mock Functions

// Create a mock function
const mockFn = jest.fn()

// Call it
mockFn('hello')
mockFn('world')

// Assertions
expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn).toHaveBeenCalledWith('hello')
expect(mockFn).toHaveBeenLastCalledWith('world')

// Mock return values
mockFn.mockReturnValue('result')
mockFn.mockReturnValueOnce('first')
mockFn.mockReturnValueOnce('second')

expect(mockFn()).toBe('first')
expect(mockFn()).toBe('second')
expect(mockFn()).toBe('result')  // Returns to normal

Mocking Modules

// api.js
export const fetchProjects = async () => {
  const res = await fetch('/api/projects')
  return res.json()
}

// api.test.js
import { fetchProjects } from './api'

jest.mock('./api')

beforeEach(() => {
  jest.clearAllMocks()
})

it('fetches projects', async () => {
  const mockData = [{ id: 1, name: 'Project A' }]
  fetchProjects.mockResolvedValue(mockData)

  const result = await fetchProjects()
  expect(result).toEqual(mockData)
  expect(fetchProjects).toHaveBeenCalledTimes(1)
})

Mocking API Calls (fetch)

// Mock fetch globally
beforeEach(() => {
  global.fetch = jest.fn()
})

afterEach(() => {
  jest.restoreAllMocks()
})

it('fetches user data', async () => {
  global.fetch.mockResolvedValue({
    ok: true,
    json: async () => ({ name: 'Avinash', role: 'Developer' }),
  })

  const user = await getUser(1)
  expect(user.name).toBe('Avinash')
  expect(fetch).toHaveBeenCalledWith('/api/users/1')
})

it('handles fetch errors', async () => {
  global.fetch.mockResolvedValue({
    ok: false,
    status: 404,
  })

  await expect(getUser(999)).rejects.toThrow('User not found')
})

Mocking Timers

beforeEach(() => {
  jest.useFakeTimers()
})

afterEach(() => {
  jest.useRealTimers()
})

it('calls callback after delay', () => {
  const callback = jest.fn()
  setTimeout(callback, 3000)

  expect(callback).not.toHaveBeenCalled()

  jest.advanceTimersByTime(3000)

  expect(callback).toHaveBeenCalledTimes(1)
})

7. React Testing Library

React Testing Library (RTL) lets you test components the way users interact with them.

Installation

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

Testing a Button Component

// Button.jsx
export function Button({ children, onClick, disabled }) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      data-testid="btn-submit"
    >
      {children}
    </button>
  )
}

// Button.test.jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'

describe('Button', () => {
  it('renders with text', () => {
    render(<Button>Submit</Button>)
    expect(screen.getByRole('button', { name: /submit/i })).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', { name: /click me/i }))

    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Submit</Button>)
    expect(screen.getByRole('button')).toBeDisabled()
  })
})

Testing a Form Component

// ContactForm.test.jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactForm } from './ContactForm'

describe('ContactForm', () => {
  it('validates required fields', async () => {
    const user = userEvent.setup()
    render(<ContactForm />)

    await user.click(screen.getByRole('button', { name: /send/i }))

    expect(screen.getByText('Name is required')).toBeInTheDocument()
    expect(screen.getByText('Email is required')).toBeInTheDocument()
  })

  it('submits form with valid data', async () => {
    const user = userEvent.setup()
    const handleSubmit = jest.fn()
    render(<ContactForm onSubmit={handleSubmit} />)

    await user.type(screen.getByLabelText(/name/i), 'John')
    await user.type(screen.getByLabelText(/email/i), 'john@test.com')
    await user.type(screen.getByLabelText(/message/i), 'Hello!')
    await user.click(screen.getByRole('button', { name: /send/i }))

    expect(handleSubmit).toHaveBeenCalledWith({
      name: 'John',
      email: 'john@test.com',
      message: 'Hello!',
    })
  })
})

Finding Elements

// By role (preferred - accessibility-first)
screen.getByRole('button', { name: /submit/i })
screen.getByRole('textbox', { name: /email/i })
screen.getByRole('heading', { name: /welcome/i })

// By label
screen.getByLabelText(/password/i)

// By placeholder
screen.getByPlaceholder(/search/i)

// By text
screen.getByText(/hello world/i)

// By test id
screen.getByTestId('login-form')

// Query variants (return null instead of throwing)
screen.queryByText('Hidden')           // Returns null if not found
screen.queryByRole('button')           // Returns null if not found

// Async (waits for element to appear)
await screen.findByText('Loaded!')     // Waits until found

8. Async Testing

Testing asynchronous code requires special handling for promises, async/await, and API calls.

Async/Await

it('fetches user data', async () => {
  const user = await fetchUser(1)
  expect(user.name).toBe('Avinash')
  expect(user.email).toContain('@')
})

it('handles rejected promises', async () => {
  await expect(fetchUser(999))
    .rejects
    .toThrow('User not found')
})

Testing Custom Hooks

// useCounter.js
import { useState, useCallback } from 'react'

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)
  const increment = useCallback(() => setCount(c => c + 1), [])
  const decrement = useCallback(() => setCount(c => c - 1), [])
  const reset = useCallback(() => setCount(initialValue), [initialValue])

  return { count, increment, decrement, reset }
}

// useCounter.test.js
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('starts with initial value', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })

  it('increments', () => {
    const { result } = renderHook(() => useCounter(0))
    act(() => result.current.increment())
    expect(result.current.count).toBe(1)
  })

  it('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(5))
    act(() => result.current.increment())
    act(() => result.current.increment())
    act(() => result.current.reset())
    expect(result.current.count).toBe(5)
  })
})

9. Coverage & CI

Running Coverage

# Run with coverage
npm test -- --coverage

# Watch mode with coverage
npm test -- --coverage --watch

Coverage Report Output

----------|---------|----------|---------|---------|
File      | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
All files |   87.5  |   82.3   |   91.2  |   87.5  |
 src/     |   92.1  |   88.5   |   95.0  |   92.1  |
  App.js  |  100.0  |  100.0   |  100.0  |  100.0  |
  utils.js|   85.0  |   75.0   |   90.0  |   85.0  |
----------|---------|----------|---------|---------|

GitHub Actions CI

name: Jest Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage --watchAll=false
      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/
Coverage isn't everything: Aim for 80%+ coverage, but remember that 100% coverage doesn't mean zero bugs. Focus on testing critical paths and edge cases.

Jest Best Practices Summary

Test Behavior, Not Implementation

// Don't test internal state
expect(component.state.count).toBe(1)

// Test what the user sees
expect(screen.getByText('1 item')).toBeInTheDocument()

Use descriptive test names

// Bad
it('works', () => { ... })

// Good
it('displays an error message when email is invalid', () => { ... })

Keep tests independent

Each test should work in isolation. Don't rely on test execution order or shared mutable state.

Prefer userEvent over fireEvent

// fireEvent fires a synthetic event
fireEvent.click(button)

// userEvent simulates real user interaction
await userEvent.click(button)  // Handles focus, keyboard, etc.
Back to Top