Testing DOM interactions with Jest and Testing Library is often less about simulating user behavior and more about verifying the state of the DOM after user actions.

Let’s see it in action. Imagine we have a simple counter component:

// Counter.js
import React, { useState } from 'react';

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

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

export default Counter;

And here’s a basic Jest/Testing Library test for it:

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

test('increments and decrements the count', () => {
  render(<Counter />);

  // Initial state check
  expect(screen.getByText('Current count: 0')).toBeInTheDocument();

  // Simulate increment
  fireEvent.click(screen.getByRole('button', { name: /increment/i }));
  expect(screen.getByText('Current count: 1')).toBeInTheDocument();

  // Simulate decrement
  fireEvent.click(screen.getByRole('button', { name: /decrement/i }));
  expect(screen.getByText('Current count: 0')).toBeInTheDocument();
});

This test doesn’t simulate a user clicking; it dispatches a click event on the button element. Testing Library’s fireEvent is a thin wrapper around dispatchEvent in the browser’s DOM API. The real magic is in how Testing Library helps you find elements and how Jest asserts on their presence or properties.

The core problem this setup solves is the "brittle test" problem. Traditional testing might involve finding elements by their exact CSS selectors (#my-button-id) or even inspecting internal component state directly. This makes tests break if you refactor the DOM structure or internal implementation details, even if the user experience remains identical. Testing Library, by prioritizing user-facing attributes like accessible roles, labels, and text content, encourages tests that are more resilient to these changes.

Internally, render from @testing-library/react uses ReactDOM.render (or createRoot().render for React 18+) to mount your component into a detached DOM environment provided by jsdom (which Jest uses by default). screen is an object that exposes various query methods (getByText, getByRole, getByLabelText, etc.) to find elements within that rendered DOM. fireEvent allows you to trigger DOM events on those found elements. Jest then provides the expect function with its matchers (like toBeInTheDocument) to assert the expected outcomes.

The most surprising true thing about Testing Library is its deliberate avoidance of implementation details. It actively discourages you from querying elements by CSS class names or data-testid attributes unless absolutely necessary. The philosophy is that if a user can’t directly interact with or perceive an element’s distinguishing feature, your test shouldn’t rely on it either. This means you’re testing your component from the perspective of accessibility and user interaction, which naturally leads to more robust and maintainable tests.

The next concept you’ll want to explore is asynchronous interactions, like handling API calls or animations, which require waitFor or findBy queries.

Want structured learning?

Take the full Jest course →