React Testing

The Testing Pyramid in React

  • Unit Tests: Verify a single component or utility function in isolation. (Jest + RTL)
  • Integration Tests: Verify multiple components working together (e.g., a form submitting to a list). (Jest + RTL)
  • End-to-End (E2E) Tests: Verify the entire application from the user’s browser down to the database. (Cypress, Playwright)

Key Libraries

1. Jest

The test runner and assertion framework. It provides the environment for running tests (test, describe) and functions to verify results (expect).

2. React Testing Library (RTL)

The industry standard for rendering components. Its core philosophy is: “The more your tests resemble the way your software is used, the more confidence they can give you.” It encourages querying components by roles and labels rather than CSS selectors or internal state.

Core Concepts

1. Testing Behavior over Implementation

Older libraries allowed testing a component’s internal state. RTL shifts focus to the DOM. If a user clicks a button and a message appears, that’s what you test. This ensures that if you refactor your component’s internal logic (e.g., moving from useState to useReducer), your tests won’t break as long as the UI remains consistent.

2. Query Priority

Always search for elements in this order of preference:

  1. getByRole: The most accessible way (e.g., heading, button, alert).
  2. getByLabelText: Best for form inputs.
  3. getByText: For static text content.
  4. getByTestId: Use ONLY when the above are not applicable.

Practice Exercise

Write a test suite for a Counter component that increments a number on button click.

Answer

1. The Component (Counter.jsx)

import React, { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h1>Counter App</h1>
      <p>Current count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

2. The Test Suite (Counter.test.js)

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

describe("Counter Component", () => {
  test("renders initial state with count at 0", () => {
    render(<Counter />);

    // Check for role-based accessibility
    const heading = screen.getByRole("heading", { name: /counter app/i });
    const paragraph = screen.getByText(/current count: 0/i);

    expect(heading).toBeInTheDocument();
    expect(paragraph).toBeInTheDocument();
  });

  test("increments the count on button click", async () => {
    const user = userEvent.setup();
    render(<Counter />);

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

    // Act
    await user.click(button);

    // Assert
    const updatedCount = screen.getByText(/current count: 1/i);
    expect(updatedCount).toBeInTheDocument();

    await user.click(button);
    expect(screen.getByText(/current count: 2/i)).toBeInTheDocument();
  });
});

Key Takeaways

  1. userEvent.setup(): It is best practice to initialize userEvent before rendering for more consistent event handling.
  2. await user.click(): Interactions are asynchronous and should be awaited.
  3. Accessibility: By using getByRole, your tests also verify that your application is accessible to screen readers.