Writing Unit Tests with Jest and React Testing Library

Loading

Testing React components with Jest and React Testing Library (RTL) has become the modern standard for creating maintainable, user-centric tests. Here’s a comprehensive guide to effective unit testing:

Core Testing Principles

1. Testing Philosophy

  • User-centric tests: Test what users experience, not implementation details
  • Accessibility integration: Tests double as basic a11y checks
  • Minimal mocking: Mock only external dependencies, not React components

2. Recommended Testing Structure

src/
  components/
    Button/
      Button.tsx
      Button.test.tsx
      Button.module.css
  __mocks__/          # Manual mocks
  test-utils/         # Custom render functions

Basic Test Setup

1. Configuration

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
  moduleNameMapper: {
    '\\.(css|less)$': 'identity-obj-proxy' // CSS module mock
  }
};

2. Custom Render Function

// test-utils/customRender.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { AppProviders } from '../providers/AppProviders';

const customRender = (
  ui: ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: AppProviders, ...options });

export * from '@testing-library/react';
export { customRender as render };

Component Testing Patterns

1. Basic Component Test

import { render, screen } from '../test-utils/customRender';
import Button from './Button';

test('renders button with correct text', () => {
  render(<Button>Click me</Button>);
  expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});

2. Testing Props

test('applies primary variant class', () => {
  render(<Button variant="primary">Submit</Button>);
  expect(screen.getByRole('button')).toHaveClass('primary');
});

3. Testing Events

test('calls onClick handler when clicked', () => {
  const handleClick = jest.fn();
  render(<Button onClick={handleClick}>Click</Button>);

  fireEvent.click(screen.getByRole('button'));
  expect(handleClick).toHaveBeenCalledTimes(1);
});

4. Async Behavior Testing

test('shows loading state when isLoading prop is true', async () => {
  render(<Button isLoading>Save</Button>);

  expect(screen.getByRole('button')).toBeDisabled();
  expect(screen.getByText(/loading/i)).toBeInTheDocument();
  expect(screen.queryByText(/save/i)).not.toBeInTheDocument();
});

Advanced Testing Scenarios

1. Testing Custom Hooks

import { renderHook } from '@testing-library/react-hooks';
import { useCounter } from './useCounter';

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

  act(() => result.current.increment());
  expect(result.current.count).toBe(1);
});

2. Context Providers Testing

test('displays user name from context', () => {
  render(
    <UserContext.Provider value={{ name: 'John' }}>
      <UserProfile />
    </UserContext.Provider>
  );

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

3. Form Testing

test('submits form data', async () => {
  const handleSubmit = jest.fn();
  render(<LoginForm onSubmit={handleSubmit} />);

  userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
  userEvent.type(screen.getByLabelText(/password/i), 'password123');
  userEvent.click(screen.getByRole('button', { name: /sign in/i }));

  await waitFor(() => 
    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123'
    })
  );
});

Mocking Strategies

1. API Calls Mocking

// __mocks__/axios.ts
export default {
  get: jest.fn(() => Promise.resolve({ data: [] }))
};

// Component test
import axios from 'axios';
jest.mock('axios');

test('fetches data on mount', async () => {
  (axios.get as jest.Mock).mockResolvedValue({ data: mockData });

  render(<DataFetcher />);
  expect(axios.get).toHaveBeenCalledWith('/api/data');

  await waitFor(() => 
    expect(screen.getByText(mockData[0].name)).toBeInTheDocument()
  );
});

2. Component Mocking

// __mocks__/components/ChildComponent.tsx
const MockChild = () => <div>Mock Child</div>;
export default MockChild;

// Parent component test
jest.mock('../ChildComponent');

test('renders parent with mocked child', () => {
  render(<ParentComponent />);
  expect(screen.getByText(/mock child/i)).toBeInTheDocument();
});

Best Practices

  1. Query Priority:
  • Prefer getByRole for most queries
  • Use getByText for static content
  • Fall back to getByTestId for dynamic elements
  1. Assertion Patterns:
   // Good
   expect(element).toBeInTheDocument();
   expect(element).toHaveAttribute('aria-hidden', 'true');

   // Avoid
   expect(element).not.toBeNull();
  1. Test Organization:
   describe('Button component', () => {
     describe('when primary variant', () => {
       test('renders with primary class', () => { /* ... */ });
     });

     describe('when clicked', () => {
       test('calls onClick handler', () => { /* ... */ });
     });
   });

Performance Optimization

1. Parallel Testing

// jest.config.js
module.exports = {
  maxWorkers: '80%', // Utilize 80% of available cores
  testEnvironment: 'jsdom'
};

2. Snapshot Testing (Use Sparingly)

test('matches snapshot', () => {
  const { asFragment } = render(<Component />);
  expect(asFragment()).toMatchSnapshot();
});

3. Test Data Factories

// test-utils/factories.ts
export const createUser = (overrides = {}) => ({
  id: 1,
  name: 'Test User',
  email: 'test@example.com',
  ...overrides
});

// Usage in tests
const adminUser = createUser({ role: 'admin' });

Common Pitfalls and Solutions

ProblemSolution
“Not wrapped in act()” warningsUse async utils like waitFor
Can’t find elementVerify accessibility attributes
Tests affecting each otherAdd afterEach(cleanup)
Slow testsMock heavy dependencies

Integration with TypeScript

// Extend Jest matchers
declare global {
  namespace jest {
    interface Matchers<R> {
      toBeInTheDocument(): R;
      toHaveAttribute(attr: string, value?: string): R;
    }
  }
}

// Typed queries
const emailInput = screen.getByRole('textbox', { 
  name: /email/i 
}) as HTMLInputElement;

Leave a Reply

Your email address will not be published. Required fields are marked *