Introduction to Jest & Testing Library

Jest and Testing Library represent the modern standard for testing React applications. Together, they provide a powerful, developer-friendly ecosystem for writing maintainable tests that focus on user behavior rather than implementation details. For JavaScript unit testing alternatives, consider Mocha and Chai which offer similar flexibility with different assertion styles.

Jest is a comprehensive JavaScript testing framework developed by Facebook, featuring built-in test runners, assertion library, mocking capabilities, and code coverage tools. React Testing Library (part of the Testing Library family) encourages testing components the way users interact with them, promoting better testing practices and more resilient test suites.

Why Choose Jest & Testing Library?

Key Advantages:

  • Zero Configuration: Works out-of-the-box with Create React App
  • User-Centric Testing: Tests focus on user behavior, not implementation
  • Fast Execution: Parallel test execution with intelligent caching
  • Rich Ecosystem: Extensive matchers, utilities, and community plugins
  • Accessibility Focus: Built-in accessibility testing capabilities
  • Excellent DX: Clear error messages and helpful debugging tools
  • Universal Support: Works with React, Vue, Angular, Svelte, and vanilla JS

Getting Started with Jest

Installation and Setup

# For new React projects (Jest included)
npx create-react-app my-app

# For existing projects
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event

# TypeScript support
npm install --save-dev @types/jest

Jest Configuration

// jest.config.js
module.exports = {
  // Test environment
  testEnvironment: 'jsdom',

  // Setup files
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],

  // Module paths
  moduleNameMapper: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
    '\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
  },

  // Coverage configuration
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/index.js',
    '!src/**/*.test.{js,jsx,ts,tsx}',
  ],

  coverageThresholds: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
      statements: 70,
    },
  },

  // Transform files
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
  },

  // Test match patterns
  testMatch: [
    '**/__tests__/**/*.(test|spec).(js|jsx|ts|tsx)',
    '**/*.(test|spec).(js|jsx|ts|tsx)',
  ],
};

Setup File

// src/setupTests.js
import '@testing-library/jest-dom';

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
  constructor() {}
  disconnect() {}
  observe() {}
  takeRecords() {
    return [];
  }
  unobserve() {}
};

Core Jest Concepts

Basic Test Structure

// sum.js
export function sum(a, b) {
  return a + b;
}

// sum.test.js
import { sum } from './sum';

describe('sum function', () => {
  test('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).toBe(3);
  });

  test('adds negative numbers correctly', () => {
    expect(sum(-1, -2)).toBe(-3);
  });

  test('handles zero', () => {
    expect(sum(0, 5)).toBe(5);
  });
});

Jest Matchers

describe('Jest matchers', () => {
  // Equality
  test('toBe vs toEqual', () => {
    const obj = { name: 'John' };
    expect(obj).toEqual({ name: 'John' }); // Deep equality
    expect(obj).not.toBe({ name: 'John' }); // Reference equality
  });

  // Truthiness
  test('truthiness matchers', () => {
    expect(null).toBeNull();
    expect(undefined).toBeUndefined();
    expect(true).toBeTruthy();
    expect(false).toBeFalsy();
    expect(0).toBeFalsy();
  });

  // Numbers
  test('number matchers', () => {
    expect(10).toBeGreaterThan(5);
    expect(10).toBeGreaterThanOrEqual(10);
    expect(10).toBeLessThan(20);
    expect(0.1 + 0.2).toBeCloseTo(0.3);
  });

  // Strings
  test('string matchers', () => {
    expect('hello world').toMatch(/world/);
    expect('hello').not.toMatch(/goodbye/);
    expect('team').toContain('tea');
  });

  // Arrays and iterables
  test('array matchers', () => {
    const fruits = ['apple', 'banana', 'orange'];
    expect(fruits).toContain('banana');
    expect(fruits).toHaveLength(3);
    expect(fruits).toEqual(expect.arrayContaining(['apple', 'banana']));
  });

  // Objects
  test('object matchers', () => {
    const user = {
      name: 'John',
      age: 30,
      email: 'john@example.com',
    };
    expect(user).toHaveProperty('name');
    expect(user).toHaveProperty('age', 30);
    expect(user).toMatchObject({ name: 'John' });
  });

  // Exceptions
  test('exception matchers', () => {
    function throwError() {
      throw new Error('Something went wrong');
    }
    expect(throwError).toThrow();
    expect(throwError).toThrow('Something went wrong');
    expect(throwError).toThrow(Error);
  });
});

Asynchronous Testing

// Promises
test('fetches user data', () => {
  return fetchUser(1).then((user) => {
    expect(user.name).toBe('John Doe');
  });
});

// Async/Await
test('fetches user data with async/await', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('John Doe');
});

// Resolves/Rejects
test('user fetch resolves', async () => {
  await expect(fetchUser(1)).resolves.toHaveProperty('name');
});

test('user fetch rejects with error', async () => {
  await expect(fetchUser(999)).rejects.toThrow('User not found');
});

Setup and Teardown

describe('Database operations', () => {
  let db;

  // Runs before all tests in this block
  beforeAll(async () => {
    db = await initDatabase();
  });

  // Runs before each test
  beforeEach(async () => {
    await db.clear();
  });

  // Runs after each test
  afterEach(async () => {
    await db.cleanup();
  });

  // Runs after all tests
  afterAll(async () => {
    await db.close();
  });

  test('inserts user', async () => {
    await db.insert({ name: 'John' });
    const users = await db.findAll();
    expect(users).toHaveLength(1);
  });

  test('updates user', async () => {
    await db.insert({ id: 1, name: 'John' });
    await db.update(1, { name: 'Jane' });
    const user = await db.findById(1);
    expect(user.name).toBe('Jane');
  });
});

React Testing Library Fundamentals

Component Testing Basics

// Button.jsx
export function Button({ onClick, children, disabled }) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {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 component', () => {
  test('renders button with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  test('calls onClick when clicked', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();

    render(<Button onClick={handleClick}>Click me</Button>);

    const button = screen.getByRole('button');
    await user.click(button);

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  test('does not call onClick when disabled', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();

    render(<Button onClick={handleClick} disabled>Click me</Button>);

    const button = screen.getByRole('button');
    await user.click(button);

    expect(handleClick).not.toHaveBeenCalled();
  });
});

Querying Elements

Query Priority (Recommended Order):

  1. Accessible by Everyone: getByRole, getByLabelText, getByPlaceholderText, getByText
  2. Semantic Queries: getByAltText, getByTitle
  3. Test IDs: getByTestId (last resort)
import { render, screen, within } from '@testing-library/react';

describe('Query methods', () => {
  test('getBy queries', () => {
    render(<LoginForm />);

    // By role (BEST)
    const button = screen.getByRole('button', { name: /log in/i });

    // By label text
    const emailInput = screen.getByLabelText(/email/i);

    // By placeholder
    const searchInput = screen.getByPlaceholderText(/search/i);

    // By text content
    const heading = screen.getByText(/welcome/i);

    // By test ID (LAST RESORT)
    const element = screen.getByTestId('custom-element');

    expect(button).toBeInTheDocument();
  });

  test('queryBy for elements that might not exist', () => {
    render(<Notification show={false} />);

    // Returns null if not found (no error thrown)
    const message = screen.queryByText(/notification/i);
    expect(message).not.toBeInTheDocument();
  });

  test('findBy for async elements', async () => {
    render(<AsyncComponent />);

    // Waits for element to appear (returns promise)
    const data = await screen.findByText(/loaded data/i);
    expect(data).toBeInTheDocument();
  });

  test('getAllBy for multiple elements', () => {
    render(<TodoList items={['Buy milk', 'Walk dog', 'Write code']} />);

    const items = screen.getAllByRole('listitem');
    expect(items).toHaveLength(3);
  });

  test('within for scoped queries', () => {
    render(<UserCard name="John" email="john@example.com" />);

    const card = screen.getByRole('article');
    const nameInCard = within(card).getByText(/john/i);

    expect(nameInCard).toBeInTheDocument();
  });
});

User Interactions

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('user interactions', async () => {
  const user = userEvent.setup();

  render(<ContactForm />);

  // Type into input
  const nameInput = screen.getByLabelText(/name/i);
  await user.type(nameInput, 'John Doe');
  expect(nameInput).toHaveValue('John Doe');

  // Clear input
  await user.clear(nameInput);
  expect(nameInput).toHaveValue('');

  // Click button
  const submitButton = screen.getByRole('button', { name: /submit/i });
  await user.click(submitButton);

  // Double click
  await user.dblClick(submitButton);

  // Select option
  const select = screen.getByLabelText(/country/i);
  await user.selectOptions(select, 'USA');
  expect(select).toHaveValue('USA');

  // Check checkbox
  const checkbox = screen.getByRole('checkbox', { name: /agree/i });
  await user.click(checkbox);
  expect(checkbox).toBeChecked();

  // Upload file
  const fileInput = screen.getByLabelText(/upload/i);
  const file = new File(['hello'], 'hello.png', { type: 'image/png' });
  await user.upload(fileInput, file);
  expect(fileInput.files[0]).toBe(file);

  // Tab navigation
  await user.tab();
  expect(screen.getByLabelText(/email/i)).toHaveFocus();
});

Advanced Testing Patterns

Testing Forms

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

describe('ContactForm', () => {
  test('submits form with valid data', async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn();

    render(<ContactForm onSubmit={onSubmit} />);

    // Fill form
    await user.type(screen.getByLabelText(/name/i), 'John Doe');
    await user.type(screen.getByLabelText(/email/i), 'john@example.com');
    await user.type(screen.getByLabelText(/message/i), 'Hello world');

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

    // Verify submission
    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({
        name: 'John Doe',
        email: 'john@example.com',
        message: 'Hello world',
      });
    });
  });

  test('shows validation errors for empty fields', async () => {
    const user = userEvent.setup();

    render(<ContactForm />);

    // Submit empty form
    await user.click(screen.getByRole('button', { name: /submit/i }));

    // Check for error messages
    expect(await screen.findByText(/name is required/i)).toBeInTheDocument();
    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
  });

  test('shows error for invalid email format', async () => {
    const user = userEvent.setup();

    render(<ContactForm />);

    await user.type(screen.getByLabelText(/email/i), 'invalid-email');
    await user.click(screen.getByRole('button', { name: /submit/i }));

    expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
  });
});

Testing with Context and State Management

For React Native applications, check our React Native testing guide for platform-specific considerations.

// Testing with Context
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { Button } from './Button';

function renderWithTheme(ui, { theme = 'light', ...options } = {}) {
  return render(<ThemeProvider value={theme}>{ui}</ThemeProvider>, options);
}

test('renders with dark theme', () => {
  renderWithTheme(<Button>Click me</Button>, { theme: 'dark' });

  const button = screen.getByRole('button');
  expect(button).toHaveClass('btn-dark');
});

// Testing with Redux
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';

function renderWithRedux(
  ui,
  {
    preloadedState = {},
    store = configureStore({ reducer: { user: userReducer }, preloadedState }),
    ...options
  } = {}
) {
  function Wrapper({ children }) {
    return <Provider store={store}>{children}</Provider>;
  }
  return { store, ...render(ui, { wrapper: Wrapper, ...options }) };
}

test('displays user name from store', () => {
  const preloadedState = {
    user: { name: 'John Doe', isLoggedIn: true },
  };

  renderWithRedux(<UserProfile />, { preloadedState });

  expect(screen.getByText(/john doe/i)).toBeInTheDocument();
});

Mocking API Calls

// Using MSW (Mock Service Worker)
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen, waitFor } from '@testing-library/react';
import { UserList } from './UserList';

const server = setupServer(
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: 1, name: 'John Doe' },
        { id: 2, name: 'Jane Smith' },
      ])
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('loads and displays users', async () => {
  render(<UserList />);

  // Wait for loading to finish
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // Wait for users to be displayed
  await waitFor(() => {
    expect(screen.getByText(/john doe/i)).toBeInTheDocument();
  });

  expect(screen.getByText(/jane smith/i)).toBeInTheDocument();
});

test('handles server error', async () => {
  server.use(
    rest.get('/api/users', (req, res, ctx) => {
      return res(ctx.status(500), ctx.json({ message: 'Internal server error' }));
    })
  );

  render(<UserList />);

  expect(await screen.findByText(/error loading users/i)).toBeInTheDocument();
});

Testing Custom Hooks

// useCounter.js
import { useState } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount((c) => c + 1);
  const decrement = () => setCount((c) => c - 1);
  const reset = () => setCount(initialValue);

  return { count, increment, decrement, reset };
}

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

describe('useCounter', () => {
  test('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  test('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  test('increments count', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  test('decrements count', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
  });

  test('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => {
      result.current.increment();
      result.current.increment();
    });

    expect(result.current.count).toBe(12);

    act(() => {
      result.current.reset();
    });

    expect(result.current.count).toBe(10);
  });
});

Accessibility Testing

Accessibility is crucial for inclusive applications. For mobile-specific accessibility testing, see our mobile accessibility guide.

import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

describe('Accessibility tests', () => {
  test('LoginForm has no accessibility violations', async () => {
    const { container } = render(<LoginForm />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  test('button has proper aria-label', () => {
    render(<IconButton icon="trash" aria-label="Delete item" />);

    const button = screen.getByRole('button', { name: /delete item/i });
    expect(button).toHaveAccessibleName('Delete item');
  });

  test('form inputs have associated labels', () => {
    render(<SignupForm />);

    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
  });

  test('image has alt text', () => {
    render(<Avatar src="/avatar.jpg" alt="User profile picture" />);

    const image = screen.getByAltText(/user profile picture/i);
    expect(image).toBeInTheDocument();
  });
});

Jest Mocking Strategies

Function Mocking

// Simple mock
const mockCallback = jest.fn();
mockCallback('test');
expect(mockCallback).toHaveBeenCalledWith('test');

// Mock implementation
const mockFn = jest.fn((x) => x * 2);
expect(mockFn(5)).toBe(10);

// Mock return values
const mock = jest.fn();
mock.mockReturnValue(42);
mock.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(3);

// Mock resolved/rejected promises
const asyncMock = jest.fn();
asyncMock.mockResolvedValue({ data: 'success' });
asyncMock.mockRejectedValue(new Error('Failed'));

Module Mocking

// api.js
export const fetchUser = (id) => {
  return fetch(`/api/users/${id}`).then((res) => res.json());
};

// component.test.js
import { fetchUser } from './api';

jest.mock('./api');

test('loads user data', async () => {
  fetchUser.mockResolvedValue({ name: 'John', email: 'john@example.com' });

  render(<UserProfile userId={1} />);

  expect(await screen.findByText(/john/i)).toBeInTheDocument();
  expect(fetchUser).toHaveBeenCalledWith(1);
});

Partial Module Mocking

jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'),
  formatDate: jest.fn(() => '2025-01-01'),
}));

Snapshot Testing

import { render } from '@testing-library/react';
import { UserCard } from './UserCard';

test('matches snapshot', () => {
  const { container } = render(
    <UserCard name="John Doe" email="john@example.com" role="Admin" />
  );

  expect(container.firstChild).toMatchSnapshot();
});

// Inline snapshots
test('renders user name', () => {
  const { container } = render(<UserCard name="John Doe" />);

  expect(container.textContent).toMatchInlineSnapshot(`"John Doe"`);
});

Code Coverage and Reporting

# Run tests with coverage
npm test -- --coverage --watchAll=false

# Coverage thresholds in package.json
{
  "jest": {
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    }
  }
}

CI/CD Integration

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test -- --coverage --watchAll=false

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

Best Practices

1. Test User Behavior, Not Implementation

// ❌ Bad: Testing implementation details
test('calls handleSubmit when form is submitted', () => {
  const handleSubmit = jest.fn();
  const { getByRole } = render(<Form onSubmit={handleSubmit} />);
  // Testing prop directly
});

// ✅ Good: Testing user interaction
test('submits form when user clicks submit button', async () => {
  const user = userEvent.setup();
  render(<Form />);
  await user.click(screen.getByRole('button', { name: /submit/i }));
  expect(await screen.findByText(/success/i)).toBeInTheDocument();
});

2. Use Accessible Queries

// ❌ Avoid: Using test IDs
screen.getByTestId('submit-button');

// ✅ Prefer: Using accessible queries
screen.getByRole('button', { name: /submit/i });

3. Avoid Testing Library Internals

// ❌ Bad: Testing state
expect(component.state.isLoading).toBe(false);

// ✅ Good: Testing rendered output
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();

Comparison with Other Tools

For E2E testing alternatives, explore Playwright and Cypress.

FeatureJest + Testing LibraryEnzymeCypress Component
FocusUser behaviorImplementationE2E + Components
Learning CurveLowMediumMedium
SpeedFastFastSlower
Real BrowserNo (JSDOM)NoYes
AccessibilityExcellentLimitedGood
CommunityVery LargeDecliningGrowing

Conclusion

Jest and Testing Library have become the de facto standard for modern React testing due to their focus on testing user behavior, excellent developer experience, and comprehensive feature set. By encouraging tests that interact with components as users would, they help create more maintainable and resilient test suites.

Jest & Testing Library are Perfect For:

  • React applications of any size
  • Teams prioritizing accessibility
  • Projects requiring fast test execution
  • Developers new to testing
  • Component library development

Next Steps

  1. Install Jest and Testing Library in your project
  2. Start with simple component tests
  3. Learn accessible query methods
  4. Practice testing user interactions
  5. Add coverage reporting to your CI/CD
  6. Explore MSW for API mocking

Focus on testing what users see and do, and your test suite will remain valuable as your application evolves.