adnenre
#React#TypeScript

Using react-testing-library for testing your react components

A full guide for react testing library that cover core concepts and testing examples

React Testing Library (RTL) is a lightweight testing utility for React components, built on top of DOM Testing Library . It provides utility functions that encourage better testing practices by focusing on how users interact with your application rather than implementation details.

Key Features:

  • User-Centric Testing: Queries the DOM in the same way users would (by label text, role, etc.)
  • Lightweight: Thin layer on top of react-dom and react-dom/test-utils
  • Framework Agnostic: Works with Jest, Mocha, Vitest, or any test runner
  • Accessibility Focused: Encourages building more accessible applications
  • Maintainable Tests: Tests break only when functionality changes, not implementation

Why React Testing Library?

  • Write tests that give confidence your app works for real users
  • Avoid testing implementation details that change frequently
  • Promote accessible component design
  • Industry standard with widespread adoption

#2. Philosophy: Testing Behavior, Not Implementation

The primary guiding principle of React Testing Library comes from its creator, Kent C. Dodds:

“The more your tests resemble the way your software is used, the more confidence they can give you.”

#What This Means in Practice

Test what users experience:

  • Users see rendered output, not component state
  • Users click buttons, not call event handlers directly
  • Users read text content, not prop values

Good Example (Behavior-Driven):

import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

test('increments count when button is clicked', () => {
  render(<Counter initialCount={0} />);

  // Find button by its visible text (how a user finds it)
  const button = screen.getByRole('button', { name: /increment/i });

  // Simulate user click
  fireEvent.click(button);

  // Verify what user sees
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

Bad Example (Testing Implementation):

// Don't do this - tests implementation details
test('increments count when button is clicked (bad)', () => {
  const { container } = render(<Counter initialCount={0} />);

  // Testing internal state (user doesn't see this)
  expect(component.state().count).toBe(0); // ❌ Implementation detail

  // Testing by class name (user doesn't care about CSS classes)
  container.querySelector('.increment-btn').click(); // ❌ Brittle

  // Testing props passed to child (user doesn't see this)
  expect(childComponent.prop('onClick')).toHaveBeenCalled(); // ❌ Too internal
});

Benefits of This Philosophy :

  • Tests survive refactoring: Rename state variables, change internal logic, tests still pass
  • Better confidence: If tests pass, real users will likely succeed
  • Accessibility by default: Using accessible queries improves your app for everyone
  • Simpler tests: No need to understand component internals

#3. Installation and Setup

#Basic Installation

For a project created with Create React App, React Testing Library is included by default . For manual setup:

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

If you’re using a test runner other than Jest, install it separately:

npm install --save-dev jest

#TypeScript Setup

For TypeScript projects, install the type definitions:

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

#Jest Configuration

For Jest 27+ , you need to set the test environment to jsdom :

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
};

For Jest 28+ , install the jsdom environment separately :

npm install --save-dev jest-environment-jsdom

Create a setup file (src/setupTests.js):

import '@testing-library/jest-dom';

This adds helpful assertions like toBeInTheDocument(), toHaveTextContent(), etc.

#With Vite and Vitest

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/setupTests.js'],
  },
});

#4. Core Concepts

#Render Function

The render function renders a React component into a virtual DOM container .

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

test('renders component', () => {
  const { container, unmount, rerender } = render(<MyComponent />);

  // container is the DOM node containing the rendered component
  expect(container).toBeInTheDocument();
});

Render Options:

// With props
render(<MyComponent title="Hello" />);

// With wrapper (for providers)
render(<MyComponent />, {
  wrapper: ({ children }) => <ThemeProvider theme="dark">{children}</ThemeProvider>,
});

#Screen Object

The screen object provides access to query methods bound to document.body . It’s the recommended way to query elements.

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

test('using screen', () => {
  render(<MyComponent />);

  // No need to destructure from render
  const heading = screen.getByRole('heading');
  expect(heading).toBeInTheDocument();

  // Debug what's in the document
  screen.debug();
});

#Queries and Their Priority

React Testing Library provides different query methods, prioritized by how users find elements .

Priority Order (highest to lowest):

  1. Queries Accessible to Everyone :

    • getByRole – Find by ARIA role (button, heading, etc.)
    • getByLabelText – Find form inputs by their <label>
    • getByPlaceholderText – Find by placeholder (less preferred)
    • getByText – Find by visible text content
    • getByDisplayValue – Find form elements by current value
  2. Semantic Queries :

    • getByAltText – Find images by alt text
    • getByTitle – Find elements by title attribute
  3. Test IDs (last resort) :

    • getByTestId – Find by data-testid attribute

#FireEvent vs UserEvent

FireEvent :

  • Synchronous, lower-level DOM event dispatcher
  • Fires exactly the event you specify
  • Faster but less realistic
import { fireEvent, screen } from '@testing-library/react';

fireEvent.click(screen.getByRole('button'));
fireEvent.change(screen.getByLabelText('Name'), {
  target: { value: 'John' },
});

UserEvent (recommended) :

  • Higher-level simulation of user interactions
  • Fires multiple events like a real user (hover, focus, etc.)
  • More realistic, catches edge cases
  • Requires installation: npm install --save-dev @testing-library/user-event
import userEvent from '@testing-library/user-event';

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

  await user.click(screen.getByRole('button'));
  await user.type(screen.getByLabelText('Name'), 'John');
  await user.keyboard('{Enter}');
});

#Act and Async Utilities

The act() function ensures all updates related to a component are completed before assertions are made .

import { act } from 'react-dom/test-utils';
// Note: React Testing Library wraps most operations in act() automatically

// Manual act example (rarely needed with RTL)
act(() => {
  render(<Component />);
});

When act warnings appear :

// Problem: State update after event
test('problematic test', () => {
  render(<Counter />);
  fireEvent.click(screen.getByText('Increment'));
  // Warning: update not wrapped in act()
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

// Solution: RTL's findBy queries auto-wait
test('fixed with findBy', async () => {
  render(<Counter />);
  fireEvent.click(screen.getByText('Increment'));
  expect(await screen.findByText('Count: 1')).toBeInTheDocument();
});

#5. Query Methods Deep Dive

#getBy* Queries

getBy* methods return the matching node, or throw an error if not found .

import { screen } from '@testing-library/react';

// These throw if element not found
const button = screen.getByRole('button');
const heading = screen.getByText('Welcome');
const input = screen.getByLabelText('Email');
const image = screen.getByAltText('Profile');
const byTestId = screen.getByTestId('submit-button');

Use when: Element MUST exist, test should fail if not found.

#findBy* Queries

findBy* methods return a Promise that resolves when the element is found (up to 1000ms default) . Perfect for async operations.

test('async element appears', async () => {
  render(<AsyncComponent />);

  // Waits for element to appear
  const element = await screen.findByText('Loaded');
  expect(element).toBeInTheDocument();

  // With custom timeout
  const slowElement = await screen.findByRole('dialog', {}, { timeout: 2000 });
});

Use when: Element appears after async operation (API call, timeout, animation).

#queryBy* Queries

queryBy* methods return the matching node or null if not found (no error thrown) .

test('element is not present', () => {
  render(<Component />);

  // Returns null, doesn't throw
  const errorMessage = screen.queryByText('Error');
  expect(errorMessage).not.toBeInTheDocument();
});

Use when: Testing that an element DOES NOT exist.

#Multiple Elements Queries

For finding multiple elements, use getAllBy*, findAllBy*, queryAllBy* .

test('multiple elements', async () => {
  render(<List items={['a', 'b', 'c']} />);

  // Returns array of matches
  const items = screen.getAllByRole('listitem');
  expect(items).toHaveLength(3);

  // Async version
  const asyncItems = await screen.findAllByText(/item/);
  expect(asyncItems.length).toBeGreaterThan(0);
});

#Assertions with Jest-DOM

The @testing-library/jest-dom package provides custom matchers for DOM assertions .

import { screen } from '@testing-library/react';

// Element presence
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeDisabled();

// Text content
expect(element).toHaveTextContent('Hello');
expect(element).toHaveTextContent(/hello/i);

// Attributes
expect(element).toHaveAttribute('type', 'submit');
expect(element).toHaveClass('active');
expect(element).toHaveStyle('color: red');

// Form values
expect(input).toHaveValue('test');
expect(checkbox).toBeChecked();
expect(select).toHaveDisplayValue('Option 1');

#6. Testing User Interactions

#Click Events

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

test('increments counter on click', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  const button = screen.getByRole('button', { name: /increment/i });

  // Single click
  await user.click(button);
  expect(screen.getByText('Count: 1')).toBeInTheDocument();

  // Double click
  await user.dblClick(button);
  expect(screen.getByText('Count: 2')).toBeInTheDocument();
});

#Form Input and Submission

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

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

  render(<LoginForm onSubmit={mockSubmit} />);

  // Fill form
  await user.type(screen.getByLabelText(/email/i), 'user@example.com');
  await user.type(screen.getByLabelText(/password/i), 'password123');

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

  // Verify submission
  expect(mockSubmit).toHaveBeenCalledWith({
    email: 'user@example.com',
    password: 'password123',
  });
});

#Keyboard Events

test('keyboard interactions', async () => {
  const user = userEvent.setup();
  render(<SearchInput />);

  const input = screen.getByRole('textbox');

  // Type text
  await user.type(input, 'search term');
  expect(input).toHaveValue('search term');

  // Tab navigation
  await user.tab();
  expect(screen.getByRole('button')).toHaveFocus();

  // Press specific keys
  await user.keyboard('{Enter}');
  await user.keyboard('{Escape}');

  // Clear input
  await user.clear(input);
});

#Hover and Focus

test('hover and focus states', async () => {
  const user = userEvent.setup();
  render<Tooltip />);

  const button = screen.getByRole('button');

  // Hover
  await user.hover(button);
  expect(screen.getByText('Tooltip text')).toBeVisible();

  // Unhover
  await user.unhover(button);
  expect(screen.queryByText('Tooltip text')).not.toBeInTheDocument();

  // Focus
  await user.tab();
  expect(button).toHaveFocus();
});

#7. Testing Asynchronous Behavior

#Waiting for Elements

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

test('waits for loading to complete', async () => {
  render(<DataFetcher />);

  // Method 1: findBy (recommended)
  const data = await screen.findByText('Loaded data');
  expect(data).toBeInTheDocument();

  // Method 2: waitFor with getBy
  await waitFor(() => {
    expect(screen.getByText('Loaded data')).toBeInTheDocument();
  });

  // Method 3: waitForElementToBeRemoved
  await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
});

#Testing API Calls

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

// Mock fetch or axios
global.fetch = jest.fn();

test('fetches and displays user data', async () => {
  const mockUser = { name: 'John Doe', email: 'john@example.com' };
  fetch.mockResolvedValueOnce({
    ok: true,
    json: async () => mockUser,
  });

  render(<UserProfile userId="123" />);

  // Loading state
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // Data loaded
  expect(await screen.findByText('John Doe')).toBeInTheDocument();
  expect(screen.getByText('john@example.com')).toBeInTheDocument();

  // Verify fetch was called correctly
  expect(fetch).toHaveBeenCalledWith('/api/users/123');
});

test('handles API error', async () => {
  fetch.mockRejectedValueOnce(new Error('Network error'));

  render(<UserProfile userId="123" />);

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

#Timers and Delays

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

test('handles setTimeout', async () => {
  jest.useFakeTimers();

  render<DelayedMessage />);

  // Trigger delayed action
  fireEvent.click(screen.getByText('Show message'));

  // Fast-forward time
  act(() => {
    jest.advanceTimersByTime(3000);
  });

  expect(screen.getByText('Delayed message')).toBeInTheDocument();

  jest.useRealTimers();
});

#8. Advanced Testing Patterns

#Custom Render with Providers

For components that depend on context providers, create a custom render function .

// test-utils.jsx
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';

const AllTheProviders = ({ children }) => {
  return (
    <ThemeProvider>
      <AuthProvider>{children}</AuthProvider>
    </ThemeProvider>
  );
};

const customRender = (ui, options) => render(ui, { wrapper: AllTheProviders, ...options });

// re-export everything
export * from '@testing-library/react';
export { customRender as render };
// In test files
import { render, screen } from '../test-utils';

test('component with providers', () => {
  render(<ComponentThatUsesTheme />);
  expect(screen.getByText('Dark mode')).toBeInTheDocument();
});

#Custom Queries

Create custom queries for specific use cases .

// custom-queries.js
import { queryHelpers, buildQueries } from '@testing-library/react';

const queryAllByDataCy = (container, id) =>
  queryHelpers.queryAllByAttribute('data-cy', container, id);

const getMultipleError = (c, dataCyValue) =>
  `Found multiple elements with data-cy="${dataCyValue}"`;
const getMissingError = (c, dataCyValue) => `Unable to find element with data-cy="${dataCyValue}"`;

const [queryByDataCy, getAllByDataCy, getByDataCy, findAllByDataCy, findByDataCy] = buildQueries(
  queryAllByDataCy,
  getMultipleError,
  getMissingError
);

export { queryByDataCy, queryAllByDataCy, getByDataCy, findAllByDataCy, findByDataCy };

#Testing Custom Hooks

Use @testing-library/react-hooks for hook testing .

npm install --save-dev @testing-library/react-hooks
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';

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

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

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

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

#Testing Context and State Management

// Testing Context consumer
test('uses context value', () => {
  const testValue = { user: 'testuser' };

  render(
    <UserContext.Provider value={testValue}>
      <UserProfile />
    </UserContext.Provider>
  );

  expect(screen.getByText('testuser')).toBeInTheDocument();
});

// Testing Redux connected component
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import reducer from './reducer';

test('redux component', () => {
  const store = createStore(reducer, { user: 'test' });

  render(
    <Provider store={store}>
      <UserComponent />
    </Provider>
  );

  expect(screen.getByText('test')).toBeInTheDocument();
});

#9. React Testing Library vs Enzyme

#Philosophical Differences

AspectReact Testing LibraryEnzyme
Testing ApproachUser behavior focusComponent internals focus
DOM QueryingLike user wouldTraverse component output
Rendering StrategiesFull DOM onlyShallow, full, static
Learning CurveGentle, intuitiveSteeper, more API
React 18 SupportFull supportLimited (community adapter)
Philosophy”Test like a user""Test like a developer”

#API Comparison

RTL Example:

import { render, screen, fireEvent } from '@testing-library/react';

test('RTL test', () => {
  render(<Button>Click</Button>);
  fireEvent.click(screen.getByText('Click'));
  expect(screen.getByText('Clicked')).toBeInTheDocument();
});

Enzyme Example:

import { shallow } from 'enzyme';

test('Enzyme test', () => {
  const wrapper = shallow(<Button>Click</Button>);
  wrapper.find('button').simulate('click');
  expect(wrapper.find('div').text()).toBe('Clicked');
});

#When to Use Which

Choose React Testing Library when:

  • You want tests that survive refactoring
  • You care about accessibility
  • You’re starting a new project
  • You want industry-standard practices

Choose Enzyme when (legacy considerations):

  • Maintaining large existing Enzyme test suite
  • Need shallow rendering for performance (though RTL encourages integration tests)
  • Testing complex component internals (though this is often an anti-pattern)

#Migration from Enzyme to RTL

HubSpot successfully migrated over 76,000 tests from Enzyme to React Testing Library over 2.5 years . Key lessons:

Migration Strategy :

  1. Measure: Instrument Enzyme usage to understand scope
  2. Educate: Create guides and documentation
  3. Pilot: Start with smaller packages
  4. Scale: Develop tooling and provide direct support
  5. Celebrate: Track progress and share wins

Migration Tips:

  • Replace shallow with full rendering (embrace integration tests)
  • Replace instance() and state() access with DOM assertions
  • Use userEvent instead of simulating events directly
  • Leverage findBy queries for async behavior
  • Add data-testid as last resort for hard-to-query elements

#10. Best Practices

#The Testing Trophy

The testing trophy represents a balanced testing strategy:

        /\
       / E2E \
      /  (few) \
     /-----------\
    / Integration \
   /   (some)     \
  /-----------------\
 /      Unit         \
/(most, but not too many)/
----------------------
  • Static Analysis (TypeScript, ESLint) – Catch errors early
  • Unit Tests – Test individual functions and utilities
  • Integration Tests – Test component interactions (sweet spot for RTL)
  • E2E Tests – Test critical user paths (Playwright, Cypress)

#Arrange-Act-Assert Pattern

Structure every test with three clear sections:

test('should show error for invalid email', () => {
  // Arrange - setup
  render(<LoginForm />);
  const emailInput = screen.getByLabelText(/email/i);
  const submitButton = screen.getByRole('button', { name: /submit/i });

  // Act - perform action
  fireEvent.change(emailInput, { target: { value: 'invalid' } });
  fireEvent.click(submitButton);

  // Assert - verify result
  const errorMessage = screen.getByText(/invalid email/i);
  expect(errorMessage).toBeInTheDocument();
});

#Query Priority

Follow this priority order for queries:

  1. getByRole – Most preferred (supports name option)

    screen.getByRole('button', { name: /submit/i });
    screen.getByRole('heading', { level: 1 });
  2. getByLabelText – For form fields

    screen.getByLabelText(/email address/i);
  3. getByPlaceholderText – (only if label not available)

  4. getByText – For non-interactive elements

  5. getByDisplayValue – For form current values

  6. getByAltText – For images

  7. getByTitle – For title attributes

  8. getByTestId – Last resort

#Mocking Boundaries

Mock at the application boundaries, not internally:

// ✅ GOOD: Mock external API
jest.mock('../api', () => ({
  fetchUser: jest.fn().mockResolvedValue({ name: 'Test' }),
}));

// ❌ BAD: Mock internal module
jest.mock('../utils/helpers'); // Don't mock your own utilities
jest.mock('./ChildComponent'); // Don't mock child components

// Instead, test the integration of parent + child together

#Test Isolation

Each test should be independent :

beforeEach(() => {
  // Clean up before each test
  jest.clearAllMocks();
});

afterEach(() => {
  // Additional cleanup if needed
  cleanup(); // render() automatically cleans up
});

#11. Integration Testing

Integration tests verify that multiple parts work together .

#Testing Component Composition

// ShoppingCart.jsx
function ShoppingCart() {
  const [items, setItems] = useState([]);

  const addItem = (product) => {
    setItems([...items, product]);
  };

  return (
    <div>
      <ProductList onAddToCart={addItem} />
      <Cart items={items} />
    </div>
  );
}
// ShoppingCart.test.jsx
test('adds product to cart', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);

  // Find and click "Add to Cart" on a product
  const addButton = screen.getByRole('button', {
    name: /add.*product 1/i,
  });
  await user.click(addButton);

  // Verify cart updated
  expect(screen.getByText(/cart.*1 item/i)).toBeInTheDocument();
  expect(screen.getByText(/product 1/i)).toBeInTheDocument();
});

#Testing Routes and Navigation

import { MemoryRouter } from 'react-router-dom';

test('navigates to about page', async () => {
  const user = userEvent.setup();

  render(
    <MemoryRouter initialEntries={['/']}>
      <App />
    </MemoryRouter>
  );

  // Click navigation link
  await user.click(screen.getByRole('link', { name: /about/i }));

  // Verify new page content
  expect(await screen.findByRole('heading', { name: /about/i })).toBeInTheDocument();
});

#Testing Forms End-to-End

test('complete form submission flow', async () => {
  const user = userEvent.setup();
  const mockSubmit = jest.fn();

  render(<RegistrationForm onSubmit={mockSubmit} />);

  // Fill all fields
  await user.type(screen.getByLabelText(/first name/i), 'John');
  await user.type(screen.getByLabelText(/last name/i), 'Doe');
  await user.type(screen.getByLabelText(/email/i), 'john@example.com');
  await user.type(screen.getByLabelText(/password/i), 'password123');

  // Check terms checkbox
  await user.click(screen.getByLabelText(/terms/i));

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

  // Verify submission data
  expect(mockSubmit).toHaveBeenCalledWith({
    firstName: 'John',
    lastName: 'Doe',
    email: 'john@example.com',
    password: 'password123',
    acceptedTerms: true,
  });

  // Verify success message or redirect
  expect(await screen.findByText(/registration successful/i)).toBeInTheDocument();
});

#12. Real-World Project: Todo Application

#Project Overview

Build and test a complete Todo application with:

  • Add new todos
  • Mark todos complete
  • Delete todos
  • Filter todos (all/active/completed)
  • Local storage persistence

#Component Structure

src/
  components/
    TodoApp.jsx
    TodoList.jsx
    TodoItem.jsx
    AddTodo.jsx
    FilterButtons.jsx
  hooks/
    useTodos.js
  __tests__/
    TodoApp.test.jsx
    useTodos.test.js

#Testing Todo Creation

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

test('adds a new todo', async () => {
  const user = userEvent.setup();
  render(<TodoApp />);

  // Find input and add button
  const input = screen.getByPlaceholderText(/add a new todo/i);
  const addButton = screen.getByRole('button', { name: /add/i });

  // Type and submit
  await user.type(input, 'Learn React Testing Library');
  await user.click(addButton);

  // Verify todo appears
  expect(screen.getByText('Learn React Testing Library')).toBeInTheDocument();

  // Input should be cleared
  expect(input).toHaveValue('');
});

test('prevents adding empty todos', async () => {
  const user = userEvent.setup();
  render(<TodoApp />);

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

  // No new todos should appear
  const todos = screen.queryAllByRole('listitem');
  expect(todos).toHaveLength(0); // Assuming initial empty list
});

#Testing Todo Completion

test('toggles todo completion', async () => {
  const user = userEvent.setup();
  render(<TodoApp />);

  // Add a todo first
  const input = screen.getByPlaceholderText(/add a new todo/i);
  const addButton = screen.getByRole('button', { name: /add/i });

  await user.type(input, 'Test todo');
  await user.click(addButton);

  // Find the checkbox and click it
  const checkbox = screen.getByRole('checkbox');
  await user.click(checkbox);

  // Todo should be marked complete (has strikethrough or different style)
  expect(checkbox).toBeChecked();

  // Todo text might have completed class
  const todoText = screen.getByText('Test todo');
  expect(todoText).toHaveClass('completed'); // Assuming class-based styling

  // Toggle back
  await user.click(checkbox);
  expect(checkbox).not.toBeChecked();
});

test('deletes a todo', async () => {
  const user = userEvent.setup();
  render(<TodoApp />);

  // Add a todo
  await user.type(screen.getByPlaceholderText(/add/i), 'To be deleted');
  await user.click(screen.getByRole('button', { name: /add/i }));

  // Delete button should be available
  const deleteButton = screen.getByRole('button', { name: /delete/i });
  await user.click(deleteButton);

  // Todo should be gone
  expect(screen.queryByText('To be deleted')).not.toBeInTheDocument();
});

#Testing Filtering

test('filters todos by status', async () => {
  const user = userEvent.setup();
  render(<TodoApp />);

  // Add multiple todos
  const input = screen.getByPlaceholderText(/add/i);
  const addButton = screen.getByRole('button', { name: /add/i });

  await user.type(input, 'Active todo 1');
  await user.click(addButton);

  await user.type(input, 'Active todo 2');
  await user.click(addButton);

  await user.type(input, 'Completed todo');
  await user.click(addButton);

  // Complete one todo
  const checkboxes = screen.getAllByRole('checkbox');
  await user.click(checkboxes[2]); // Complete the third todo

  // Test "Active" filter
  await user.click(screen.getByRole('button', { name: /active/i }));
  expect(screen.getByText('Active todo 1')).toBeInTheDocument();
  expect(screen.getByText('Active todo 2')).toBeInTheDocument();
  expect(screen.queryByText('Completed todo')).not.toBeInTheDocument();

  // Test "Completed" filter
  await user.click(screen.getByRole('button', { name: /completed/i }));
  expect(screen.queryByText('Active todo 1')).not.toBeInTheDocument();
  expect(screen.queryByText('Active todo 2')).not.toBeInTheDocument();
  expect(screen.getByText('Completed todo')).toBeInTheDocument();

  // Test "All" filter
  await user.click(screen.getByRole('button', { name: /all/i }));
  expect(screen.getByText('Active todo 1')).toBeInTheDocument();
  expect(screen.getByText('Active todo 2')).toBeInTheDocument();
  expect(screen.getByText('Completed todo')).toBeInTheDocument();
});

#Testing Local Storage Persistence

// Mock localStorage
const localStorageMock = (() => {
  let store = {};
  return {
    getItem: jest.fn((key) => store[key] || null),
    setItem: jest.fn((key, value) => {
      store[key] = value.toString();
    }),
    clear: jest.fn(() => {
      store = {};
    }),
    removeItem: jest.fn((key) => {
      delete store[key];
    }),
  };
})();

Object.defineProperty(window, 'localStorage', { value: localStorageMock });

test('persists todos to localStorage', async () => {
  const user = userEvent.setup();
  localStorageMock.clear();
  localStorageMock.setItem.mockClear();

  render(<TodoApp />);

  // Add a todo
  await user.type(screen.getByPlaceholderText(/add/i), 'Persisted todo');
  await user.click(screen.getByRole('button', { name: /add/i }));

  // Should save to localStorage
  expect(localStorageMock.setItem).toHaveBeenCalled();
  const savedData = JSON.parse(localStorageMock.setItem.mock.calls[0][1]);
  expect(savedData).toContainEqual(expect.objectContaining({ text: 'Persisted todo' }));
});

test('loads todos from localStorage', () => {
  // Pre-populate localStorage
  const initialTodos = [
    { id: '1', text: 'Saved todo 1', completed: false },
    { id: '2', text: 'Saved todo 2', completed: true },
  ];

  localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(initialTodos));

  render(<TodoApp />);

  // Todos should load from storage
  expect(screen.getByText('Saved todo 1')).toBeInTheDocument();
  expect(screen.getByText('Saved todo 2')).toBeInTheDocument();
});

#13. Real-World Project: E-Commerce Product Page

#Project Overview

Test a product page with:

  • Product display (image, name, price)
  • Quantity selector
  • Add to cart functionality
  • Related products
  • API integration

#Testing Product Display

// ProductPage.test.jsx
import { render, screen } from '@testing-library/react';
import ProductPage from '../ProductPage';

const mockProduct = {
  id: 1,
  name: 'Test Product',
  price: 29.99,
  description: 'This is a test product',
  imageUrl: '/test-image.jpg',
};

test('displays product information correctly', () => {
  render(<ProductPage product={mockProduct} />);

  // Check product name
  expect(screen.getByRole('heading', { name: 'Test Product' })).toBeInTheDocument();

  // Check price
  expect(screen.getByText('$29.99')).toBeInTheDocument();

  // Check description
  expect(screen.getByText('This is a test product')).toBeInTheDocument();

  // Check image
  const image = screen.getByRole('img', { name: /test product/i });
  expect(image).toHaveAttribute('src', '/test-image.jpg');
});

test('shows loading state', () => {
  render(<ProductPage isLoading={true} />);

  expect(screen.getByText(/loading/i)).toBeInTheDocument();
  expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});

test('shows error state', () => {
  render(<ProductPage error="Failed to load product" />);

  expect(screen.getByText(/failed to load product/i)).toBeInTheDocument();
});

#Testing Quantity Selection

test('quantity selector works correctly', async () => {
  const user = userEvent.setup();
  render(<ProductPage product={mockProduct} />);

  // Find quantity input
  const quantityInput = screen.getByLabelText(/quantity/i);
  expect(quantityInput).toHaveValue(1);

  // Increment button
  const incrementButton = screen.getByRole('button', { name: /\+/i });
  await user.click(incrementButton);
  expect(quantityInput).toHaveValue(2);

  // Decrement button
  const decrementButton = screen.getByRole('button', { name: /-/i });
  await user.click(decrementButton);
  expect(quantityInput).toHaveValue(1);

  // Can't go below 1
  await user.click(decrementButton);
  expect(quantityInput).toHaveValue(1);

  // Manual input
  await user.clear(quantityInput);
  await user.type(quantityInput, '5');
  expect(quantityInput).toHaveValue(5);

  // Invalid input handled
  await user.clear(quantityInput);
  await user.type(quantityInput, 'abc');
  expect(quantityInput).toHaveValue(1); // Should revert or handle gracefully
});

#Testing Add to Cart

test('adds product to cart with correct quantity', async () => {
  const user = userEvent.setup();
  const mockAddToCart = jest.fn();

  render(<ProductPage product={mockProduct} onAddToCart={mockAddToCart} />);

  // Set quantity
  const quantityInput = screen.getByLabelText(/quantity/i);
  await user.clear(quantityInput);
  await user.type(quantityInput, '3');

  // Click add to cart
  const addButton = screen.getByRole('button', { name: /add to cart/i });
  await user.click(addButton);

  // Verify callback
  expect(mockAddToCart).toHaveBeenCalledWith(mockProduct.id, 3);

  // Success feedback
  expect(await screen.findByText(/added to cart/i)).toBeInTheDocument();
});

test('shows confirmation dialog for bulk quantities', async () => {
  const user = userEvent.setup();
  render(<ProductPage product={mockProduct} />);

  // Set large quantity (over threshold)
  const quantityInput = screen.getByLabelText(/quantity/i);
  await user.clear(quantityInput);
  await user.type(quantityInput, '100');

  // Click add to cart
  await user.click(screen.getByRole('button', { name: /add to cart/i }));

  // Confirmation dialog should appear
  expect(screen.getByRole('dialog')).toBeInTheDocument();
  expect(screen.getByText(/confirm bulk order/i)).toBeInTheDocument();

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

  // Dialog should close
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

#Testing API Integration

// Mock API module
jest.mock('../api', () => ({
  fetchProduct: jest.fn(),
  addToCart: jest.fn(),
}));

import { fetchProduct, addToCart } from '../api';
import ProductPageContainer from '../ProductPageContainer';

test('fetches product on mount', async () => {
  fetchProduct.mockResolvedValueOnce(mockProduct);

  render(<ProductPageContainer productId="123" />);

  // Loading state
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // Product loads
  expect(await screen.findByText('Test Product')).toBeInTheDocument();
  expect(fetchProduct).toHaveBeenCalledWith('123');
});

test('handles API error gracefully', async () => {
  fetchProduct.mockRejectedValueOnce(new Error('Network error'));

  render(<ProductPageContainer productId="123" />);

  expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
  expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();

  // Retry functionality
  fetchProduct.mockResolvedValueOnce(mockProduct);
  await userEvent.click(screen.getByRole('button', { name: /retry/i }));

  expect(await screen.findByText('Test Product')).toBeInTheDocument();
});

test('calls API when adding to cart', async () => {
  const user = userEvent.setup();
  addToCart.mockResolvedValueOnce({ success: true });
  fetchProduct.mockResolvedValueOnce(mockProduct);

  render(<ProductPageContainer productId="123" />);

  // Wait for product to load
  await screen.findByText('Test Product');

  // Add to cart
  await user.click(screen.getByRole('button', { name: /add to cart/i }));

  expect(addToCart).toHaveBeenCalledWith('123', 1);
  expect(await screen.findByText(/added successfully/i)).toBeInTheDocument();
});

This guide covers the essential aspects of React Testing Library from beginner to advanced. Practice by building and testing the real-world projects, and refer to the official documentation for deeper dives. Happy testing!

Share this post