Skip to content

Latest commit

 

History

History
756 lines (533 loc) · 32.5 KB

File metadata and controls

756 lines (533 loc) · 32.5 KB

React Testing Library

React Testing Library (RTL) has fundamentally changed how the industry tests React applications. It enforces a philosophy that drastically reduces fragile tests and improves confidence in your code.

Here is a comprehensive deep dive into React Testing Library, how it differs from older paradigms, and how to master it with Jest.

The Core Philosophy: What is RTL?

React Testing Library is a lightweight utility built on top of react-dom/test-utils.

Its guiding principle is simple but revolutionary:

"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds (Creator of RTL)

RTL forces you to test your application from the perspective of the user. A real user does not know what your component's internal state is. They do not know what the handleClick method does. A user only knows what they see on the screen (text, images) and what they can interact with (buttons, textboxes). RTL restricts you to testing exactly those things.

The Paradigm Shift: RTL vs. Enzyme

Before RTL, the industry standard was Airbnb's Enzyme. The shift from Enzyme to RTL represents a massive change in how developers think about testing.

Enzyme: Testing the Implementation

Enzyme allowed developers to "shallow render" components (rendering a component without rendering its children) and directly access the component's internal state, props, and class methods.

  • The Flaw: If you refactored your component from a Class to a Functional Component with Hooks, your UI might look and work exactly the same, but your Enzyme tests would completely shatter because the internal implementation changed.

RTL: Testing the Behavior

RTL renders the component deeply into a virtual DOM (JSDOM). It completely hides the component's internals. You cannot check state or props. You can only query the DOM for text, roles (buttons, links), and labels.

  • The Benefit: You can completely rewrite the underlying code of your component. As long as the user experience remains the same, your tests will pass.
      ENZYME ARCHITECTURE (Implementation)       RTL ARCHITECTURE (Behavior)
      ------------------------------------       ---------------------------
      [ Test File ]                              [ Test File ] 
           |                                          |
           |-- Checks: state.isOpen === true          |-- Checks: screen.getByRole('dialog')
           |-- Checks: wrapper.find('ChildComp')      |-- Checks: screen.getByText('Welcome')
           |-- Action: wrapper.instance().toggle()    |-- Action: userEvent.click(button)
           V                                          V
      [ React Component Internals ]              [ Rendered DOM (Simulated Browser) ]

How to Use RTL with Jest

RTL and Jest work as a team:

  • RTL renders the components and provides methods to find elements (e.g., render, screen.getByText).
  • Jest is the test runner that executes the code and provides the assertion framework (e.g., describe, it, expect(...).toBeInTheDocument()).

A standard RTL test follows the AAA Pattern (Arrange, Act, Assert).

Usage Example: A Complete Test Flow

import React from 'react';
import { render, screen } from '@testing-library/react'; // RTL imports
import userEvent from '@testing-library/user-event';    // Simulates user actions
import '@testing-library/jest-dom';                     // Adds Jest matchers like toBeInTheDocument
import { LoginForm } from './LoginForm';

describe('LoginForm Component', () => {
  
  it('should display validation error when submitting empty fields', async () => {
    // 1. ARRANGE: Render the component into the virtual DOM
    render(<LoginForm />);
    
    // Find the button using accessibility roles (how a screen reader finds it)
    const submitButton = screen.getByRole('button', { name: /login/i });
    
    // 2. ACT: Simulate real user interactions
    const user = userEvent.setup();
    await user.click(submitButton);
    
    // 3. ASSERT: Check the expected outcome
    // We use findBy because validation might take a fraction of a second to render
    const errorMessage = await screen.findByText(/username is required/i);
    expect(errorMessage).toBeInTheDocument();
  });
});

The RTL Query System Matrix

To master RTL, you must understand its query system. RTL provides three prefixes for finding elements on the screen. Choosing the wrong one will crash your tests.

      RTL QUERY BEHAVIOR MATRIX
      -------------------------
      Query Prefix  | 0 Matches | 1 Match | >1 Matches | Is Async?
      ==============================================================
      getBy...      | THROWS    | Node    | THROWS     | No
      queryBy...    | Null      | Node    | THROWS     | No
      findBy...     | THROWS    | Node    | THROWS     | Yes (Returns Promise)

1. getBy... (The Default)

Use this when you expect the element to be on the screen immediately. If it isn't there, the test fails instantly.

// Finds a heading with the exact text "Dashboard"
const header = screen.getByRole('heading', { name: 'Dashboard' });
expect(header).toBeInTheDocument();

2. queryBy... (For Non-Existence)

Use this only when you are asserting that an element is NOT on the screen. If you use getBy and the element isn't there, the test crashes before it even reaches your expect statement.

// ✅ GOOD: Returns null, so the assertion passes
const loadingSpinner = screen.queryByTestId('spinner');
expect(loadingSpinner).not.toBeInTheDocument();

// ❌ BAD: Crashes immediately, test fails before assertion
const badSpinner = screen.getByTestId('spinner'); 

3. findBy... (For Asynchronous UI)

Use this when an element will appear after an API call, a timer, or a state update. It automatically retries for up to 1 second.

// Component mounts -> makes API call -> displays user
render(<UserProfile id="123" />);

// Await the element appearing on screen
const userName = await screen.findByText('John Doe');
expect(userName).toBeInTheDocument();

Priority of Queries (Accessibility First)

RTL encourages you to query elements exactly how users (especially those using assistive technologies) find them.

  1. getByRole (Highest Priority): Finds buttons, headings, textboxes, dialogs. It ensures your app is accessible.
  2. getByLabelText: The best way to find form inputs.
  3. getByText: For non-interactive text like paragraphs or spans.
  4. getByTestId (Lowest Priority): Use data-testid only when you cannot find an element semantically (e.g., a dynamic SVG chart or a highly custom interactive div).

Tricky Concepts & Gotchas

Gotcha 1: The Infamous act(...) Warning

This is the most common and frustrating warning in React testing:

Warning: An update to Component inside a test was not wrapped in act(...).

Why it happens: React detected that your component's state updated after your test finished executing. Usually, this means you clicked a button that triggered an API call, but your test exited before the API call resolved.

      THE RACE CONDITION CAUSING THE 'ACT' WARNING
      --------------------------------------------
      [ Test Time ]   | [ Component Lifecycle ]
      user.click() -> | triggers fetchUser()
      test ends       | 
      (Jest exits)    | ...fetch resolves...
                      | setState(user) <----- React warns: "You updated state but the test is over!"

How to fix it: Do not wrap things in act() manually. Instead, use await findBy... to force the test to wait until the UI finishes its asynchronous updates.

await userEvent.click(loadDataButton);
// This keeps the test alive until the state update occurs!
expect(await screen.findByText('Data Loaded')).toBeInTheDocument(); 

Gotcha 2: userEvent vs. fireEvent

RTL exports fireEvent, but you should almost always use @testing-library/user-event instead.

  • fireEvent.change(input, { target: { value: 'a' } }) just dispatches a raw, isolated DOM event.
  • userEvent.type(input, 'a') simulates the user actually clicking the input (firing focus events), pressing the key down, and releasing it. It catches bugs related to focus/blur that fireEvent misses entirely.
const user = userEvent.setup(); // Always set it up before acting

// Simulates clicking into the box, typing, and the associated key events
await user.type(screen.getByRole('textbox', { name: /email/i }), 'test@test.com');
await user.tab(); // Simulates pressing the Tab key to move to the next input

Gotcha 3: Multiple Elements Match

If you have three "Edit" buttons on the screen and you write screen.getByRole('button', { name: /edit/i }), the test will crash because getByRole expects exactly ONE match.

The Fix: Use getAllByRole (which returns an array), or narrow down the search by wrapping a specific section of the UI using the within utility.

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

// Find a specific card first
const userCard = screen.getByTestId('user-card-123');

// Only search for the Edit button INSIDE that specific card
const editButton = within(userCard).getByRole('button', { name: /edit/i });

The Ultimate Debugging Tool: screen.debug()

When your test is failing and you don't know why, screen.debug() is your best friend. It acts like console.log for your component's current DOM state.

render(<MyComponent />);
await userEvent.click(button);

// Prints a beautifully formatted HTML structure of your simulated DOM
// to the terminal so you can see exactly what RTL sees.
screen.debug(); 

The Core Philosophy: What to Test (and What NOT to Test)

The most important rule in RTL is to avoid testing implementation details. You want your tests to survive code refactors.

      THE RTL TESTING BOUNDARY
      ------------------------
      
   ❌ DO NOT TEST THIS (Implementation)
   [ Internal State (`useState`) ] 
   [ Component Lifecycle Methods ]
   [ Internal Helper Functions ]
             |
             V
   ==================================== THE BOUNDARY
             |
             V
   ✅ TEST THIS (The User Experience)
   [ What renders on the screen (DOM) ]
   [ What the user clicks/types (Events) ]
   [ What the screen reader reads (A11y) ]

✅ What to Test

  • User Workflows: "Can the user fill out the form, click submit, and see a success message?"
  • Conditional Rendering: Does the "Admin Dashboard" link appear for admin users and stay hidden for guest users?
  • Accessibility (A11y): Can a screen reader find your inputs and buttons? (Using getByRole inherently tests this).
  • Error States: If an API call fails, does the user see a user-friendly error message or red validation text?

❌ What NOT to Test

  • Internal State: Do not test that isModalOpen equals true. Test that the Modal element is visible on the screen.
  • Third-Party Libraries: Do not test that Redux updates its store or that React Router changes the URL. Test how your UI reacts when those things happen.
  • CSS / Styling: Do not test expect(button).toHaveStyle('color: red') unless the color is the only way a critical state is conveyed (which is a bad accessibility practice anyway).
  • Pure Functions in Components: If you have complex math or formatting logic, extract it into a standard JavaScript file and write isolated Unit Tests for it, rather than rendering a React component just to test the math.

Test Case Strategy: Count and Types

The Testing Trophy (Integration over Unit)

In traditional backend software, the "Testing Pyramid" says you should have thousands of Unit tests and very few Integration tests. In React UI testing, Kent C. Dodds created the Testing Trophy.

      THE TESTING TROPHY
      ------------------
             _ 
            | |  <-- End-to-End (Cypress/Playwright) (Fewest)
          /     \
         |       | <-- Integration (RTL) (THE MAJORITY)
          \     /
           |   | <-- Unit (Jest for pure JS functions)
           |___| <-- Static (TypeScript / ESLint)

  • Unit Tests (Isolated Components): Test highly reusable, isolated components (like a custom <Button> or <Dropdown>).
  • Integration Tests (Page/Feature level): This should be 80% of your RTL tests. Render an entire <CheckoutPage>, mock the API, and test the user flow across multiple child components working together.
  • Test Count: Do not chase 100% code coverage. 100% coverage often leads to useless, fragile tests. Aim for high confidence. Test the "Happy Path" (everything works) and the major "Sad Paths" (network errors, invalid input).

Best Practices for Test Naming

Your test suites should read like plain English documentation. A new developer should be able to read your test names and understand exactly what the component does.

Follow the pattern: it('should [expected behavior] when [condition]').

Code Example: Naming Conventions

// ❌ BAD: Focuses on implementation details and code names
describe('LoginForm', () => {
  it('changes state on type', () => { ... });
  it('calls handleSubmit', () => { ... });
  it('shows error div', () => { ... });
});

// ✅ GOOD: Focuses on user behavior and outcomes
describe('LoginForm', () => {
  it('should enable the submit button when all required fields are filled', () => { ... });
  it('should display a loading spinner when the authentication API is pending', () => { ... });
  it('should display an "Invalid Credentials" message when the API returns a 401 error', () => { ... });
});

Best Practices for Test Content and Assertions

Every single test you write should follow the AAA Pattern: Arrange, Act, Assert. Keep these three phases visually separated with empty lines to make your tests readable.

      THE AAA WORKFLOW
      ----------------
      1. ARRANGE -> Setup mocks, render component, find elements.
            |
      2. ACT     -> Simulate the user typing or clicking.
            |
      3. ASSERT  -> Verify the DOM changed as expected.

Code Example: A Perfect RTL Test

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

it('should display a validation error when submitting without a ZIP code', async () => {
  // ---------------------------------------------------------
  // 1. ARRANGE
  // ---------------------------------------------------------
  const user = userEvent.setup();
  render(<CheckoutForm />);
  
  // Find elements using accessibility roles
  const submitButton = screen.getByRole('button', { name: /place order/i });
  const emailInput = screen.getByRole('textbox', { name: /email/i });

  // ---------------------------------------------------------
  // 2. ACT
  // ---------------------------------------------------------
  // Fill out the form, but deliberately skip the ZIP code
  await user.type(emailInput, 'test@example.com');
  await user.click(submitButton);

  // ---------------------------------------------------------
  // 3. ASSERT
  // ---------------------------------------------------------
  // Use findBy because validation errors might take a tick to appear
  const errorMessage = await screen.findByText(/zip code is required/i);
  
  // Use jest-dom custom matchers for highly readable assertions
  expect(errorMessage).toBeVisible();
  expect(submitButton).toBeDisabled(); 
});

Assertion Best Practices (Use jest-dom)

Always install and use @testing-library/jest-dom. It provides DOM-specific matchers that yield far better error messages when they fail.

❌ Bad (Standard Jest) ✅ Good (jest-dom) Why it's better
expect(btn.disabled).toBe(true) expect(btn).toBeDisabled() Reads like English.
expect(el.textContent).toBe('Hi') expect(el).toHaveTextContent('Hi') Ignores extra HTML whitespace.
expect(el.className.includes('hide')).toBe(false) expect(el).toBeVisible() Actually checks CSS display and opacity.

Tricky Concepts, Gotchas, and Debugging Mastery

Gotcha 1: waitFor vs findBy

Developers often overuse waitFor when waiting for an element to appear.

  • ❌ Bad: await waitFor(() => expect(screen.getByText('Saved')).toBeInTheDocument());
  • ✅ Good: expect(await screen.findByText('Saved')).toBeInTheDocument();
  • The Rule: Use findBy to wait for an element to appear. Use waitFor ONLY when you are waiting for an element to disappear or for an assertion that isn't tied to a specific element finding (like expecting a mock function to have been called).

Gotcha 2: Checking for Non-Existence

If you want to prove a loading spinner has vanished, you cannot use getBy. getBy throws a fatal error immediately if it cannot find the element, crashing your test.

// ❌ BAD: Test crashes immediately, test fails before assertion
expect(screen.getByTestId('spinner')).not.toBeInTheDocument();

// ✅ GOOD: queryBy returns null safely, which passes the assertion
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();

// ✅ BEST (For Async Disappearance): 
await waitForElementToBeRemoved(() => screen.queryByTestId('spinner'));

Gotcha 3: The act(...) Warning

If you see the warning: "An update to Component inside a test was not wrapped in act(...)", it means your component state updated after your test finished running.

  • The Cause: You clicked a button that made an API call, but your test didn't wait for the API call to resolve. Jest reached the end of the file and exited. A microsecond later, the API resolved and tried to update the React state.
  • The Fix: You are missing an await. Use await screen.findBy... to assert on the final state of the UI so the test stays alive until the asynchronous work finishes.

Gotcha 4: Overusing data-testid

Adding data-testid to your HTML elements is an escape hatch, not a primary tool. If you use it everywhere, you aren't testing accessibility.

  • Bad: <button data-testid="submit-btn">Submit</button> -> screen.getByTestId('submit-btn')
  • Good: <button>Submit</button> -> screen.getByRole('button', { name: /submit/i })

Pro-Tip: Debugging Mastery

When a test fails and you cannot figure out why RTL isn't finding your element, use these two tools:

  1. screen.debug(): Put this anywhere in your test to print the current HTML structure of the virtual DOM to your terminal.
  2. screen.logTestingPlaygroundURL(): This is magic. Put this in your test, and it will output a URL in your terminal. Click it, and it will open a visual representation of your component in the browser, allowing you to click on elements to see exactly which RTL query you should use to select them.

Production-Grade RTL Setup

If you render a component that uses Redux, React Router, or a Theme Provider without wrapping it in those specific providers, your test will instantly crash. Doing this manually for every test file causes massive duplication.

The industry standard is to create a Custom Render Function that automatically wraps every tested component in your application's global context.

Step 1: The Dependency Stack

For a robust setup, you need the core library, the DOM matchers, and the user-event library (which accurately simulates human interaction).

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

Step 2: The Global Setup File

Create a setupTests.js (or .ts) file. This runs once before your test suites and injects custom DOM matchers (like toBeInTheDocument) into Jest.

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

Step 3: The Custom Render Utility (test-utils.jsx)

Create a utility file that overrides RTL's default render method.

      THE CUSTOM RENDER PIPELINE
      --------------------------
      
      [ Your Component in Test ]
                 |
                 V
      [ ThemeProvider (Styled Components/MUI) ]
                 |
                 V
      [ MemoryRouter (React Router) ]
                 |
                 V
      [ Provider (Redux Store) ]
                 |
                 V
      [ JSDOM (Virtual Browser) ]

// src/utils/test-utils.jsx
import React from 'react';
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from '../store/reducers';

// 1. Create a wrapper component with all global providers
const AllTheProviders = ({ children, initialState }) => {
  // We allow passing an initialState to configure the mock store per test
  const store = configureStore({ reducer: rootReducer, preloadedState: initialState });
  
  return (
    <Provider store={store}>
      <MemoryRouter>
        {children}
      </MemoryRouter>
    </Provider>
  );
};

// 2. Create the custom render function
const customRender = (ui, { initialState, ...options } = {}) => {
  return render(ui, {
    wrapper: (props) => <AllTheProviders {...props} initialState={initialState} />,
    ...options,
  });
};

// 3. Re-export everything from RTL so you only need to import from this file
export * from '@testing-library/react';

// 4. Override the default render
export { customRender as render };

Now, your test files look incredibly clean:

// LoginForm.test.jsx
import { render, screen } from '../utils/test-utils'; // Imports the custom wrapper!
import LoginForm from './LoginForm';

it('renders the login form', () => {
  render(<LoginForm />, { initialState: { user: { loggedIn: false } } });
  expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
});

render vs screen

Historically (circa 2018), RTL developers destructured query methods directly from the render function. Today, doing so is considered an anti-pattern. You should always use screen.

What is render?

render is the function that mounts your React component into a simulated DOM (JSDOM) attached to Node.js. It returns an object containing unmount functions, rerender functions, and base elements.

What is screen?

screen is a global object exported by RTL that holds a reference to document.body. Every time you use render, the component is appended to document.body. Therefore, screen can see everything that has been rendered.

      RENDER VS SCREEN MENTAL MODEL
      -----------------------------
      
      [ JSDOM Environment ]
             |
             |-- document.body  <=== `screen` queries start here!
                   |
                   |-- [ Component A ] <=== `render(<Component A />)` puts this here
                   |-- [ Component B ] <=== `render(<Component B />)` puts this here

Why the Shift? (The Anti-Pattern)

// ❌ BAD: Destructuring from render (The Old Way)
// If you add a new query later, you have to scroll up and add it to the destructure list.
const { getByText, getByRole, queryByTestId } = render(<MyComponent />);
expect(getByText('Submit')).toBeInTheDocument();

// ✅ GOOD: Using screen (The Modern Way)
// You render once, and use the global screen object to find anything.
render(<MyComponent />);
expect(screen.getByText('Submit')).toBeInTheDocument();

Common Assertions & Helper Methods

To write effective tests, you need to combine RTL's query methods (screen.getBy...) with jest-dom assertions (expect(...).toBe...) and user interaction helpers.

Core jest-dom Assertions

Assertion Use Case
toBeInTheDocument() Checks if the element exists in the DOM tree.
toBeVisible() Checks if it is visible to the user (not display: none or opacity: 0).
toBeDisabled() Checks if an input or button has the disabled attribute.
toHaveTextContent(text) Checks if an element contains specific text, ignoring extra HTML whitespace.
toHaveValue(val) Checks the current value of a form input (text, number, select).
toHaveAttribute(attr, val) Checks for specific HTML attributes (like href on an anchor tag).

Crucial Helper Methods

1. within (Scoped Queries)

When you have multiple identical elements on a page, screen.getByRole will crash because it finds too many. Use within to restrict your search to a specific container.

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

it('finds the edit button for a specific user', () => {
  render(<UserList />);
  
  // Find the specific user card first
  const userCard = screen.getByTestId('user-123');
  
  // Only search for the button INSIDE that specific card
  const editButton = within(userCard).getByRole('button', { name: /edit/i });
  expect(editButton).toBeInTheDocument();
});

2. userEvent (Simulating Reality)

Always use @testing-library/user-event instead of RTL's built-in fireEvent. fireEvent.change() just dispatches a raw DOM event. userEvent.type() simulates the user clicking the box, pressing keys down, releasing them, and triggering focus/blur events.

import userEvent from '@testing-library/user-event';

it('fills out the form', async () => {
  // Always initialize userEvent setup before interactions
  const user = userEvent.setup();
  render(<Form />);

  const input = screen.getByRole('textbox', { name: /email/i });
  
  // Simulates typing exactly as a user would
  await user.type(input, 'test@example.com');
  
  // Simulates pressing the tab key to move to the next field
  await user.tab(); 
});

3. waitFor (Asynchronous Assertions)

Use waitFor when you need to wait for an element to disappear, or for a mock function to be called after an async action. (Note: To wait for an element to appear, use await screen.findBy... instead).

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

it('removes the spinner and calls the API', async () => {
  const user = userEvent.setup();
  render(<Checkout />);

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

  // Wait for the spinner to leave the DOM
  await waitFor(() => {
    expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
  });

  // Verify the mocked API was called
  await waitFor(() => {
    expect(mockApiCall).toHaveBeenCalledTimes(1);
  });
});

Tricky Concepts & Debugging Mastery

Gotcha 1: The act(...) Warning

This is the most frequent and frustrating warning in React testing. It means your React component updated its state after your test completed.

  • The Cause: You clicked a "Submit" button, which triggered an async API call. The test reached the end of the file and passed. A millisecond later, the API resolved, called setState, and React threw a warning because the testing environment had already closed.
  • The Fix: Make sure your test stays alive until all UI updates settle. Assert on the final state of the UI using findBy.
// ❌ BAD: Test exits before the success message renders
await user.click(submitButton);

// ✅ GOOD: Forces the test to wait until the asynchronous update completes
await user.click(submitButton);
expect(await screen.findByText(/success/i)).toBeInTheDocument();

Gotcha 2: Asserting Non-Existence

If you expect an element to NOT be on the screen, using getBy will crash the test immediately before the assertion runs. You must use queryBy.

// ❌ BAD: Throws a fatal error, test crashes
expect(screen.getByText('Error')).not.toBeInTheDocument();

// ✅ GOOD: queryBy returns null, assertion passes safely
expect(screen.queryByText('Error')).not.toBeInTheDocument();

Gotcha 3: Stale Element References

If an element is removed from the DOM and immediately replaced by an identical-looking element (common during re-renders or optimistic UI updates), holding onto the old variable will cause errors.

// ❌ BAD: Storing the reference too early
const button = screen.getByRole('button', { name: /submit/i });
await user.click(button); 
// If the click caused a full re-render that replaced the button node, 
// the 'button' variable is now a detached DOM node.

// ✅ GOOD: Re-query the DOM if necessary
await user.click(screen.getByRole('button', { name: /submit/i }));

The Ultimate Debugging Tools

When a test fails and you cannot visualize why RTL isn't finding your element, do not guess. Use the debugging tools.

  1. screen.debug(): Put this before your failing assertion. It prints a beautifully formatted HTML tree of your component's current state to the terminal.
  2. screen.logTestingPlaygroundURL(): Insert this in your test. It outputs a URL in your terminal. Ctrl+Click it to open a visual browser interface showing your simulated DOM, allowing you to click on elements to see the exact screen.getBy... query required to select them.