Testing React Components Like a User: Queries, user-event, Async Tests, Context and Hooks

Key takeaways

React Testing Library encourages testing components the way users interact with them. It provides utilities to query elements by accessibility attributes and simulate user behavior.

Introduction

React Testing Library (RTL) is a testing utility that encourages good testing practices. It focuses on testing components from the user’s perspective rather than implementation details.

I’ve inherited more than one Enzyme test suite that looked comprehensive on paper — high coverage numbers, dozens of passing tests — and turned out to be nearly worthless the first time a component got refactored. Renaming an internal state variable, switching from a class component to a hook, or restructuring how a piece of derived data was computed broke tests that had nothing to do with what actually changed from a user’s point of view, because those tests were asserting on wrapper.state() and internal instance methods instead of on what rendered. RTL’s entire design is a reaction to that specific failure mode, and it’s worth understanding as the guide’s actual thesis, not just a tooling swap: a test suite should tell you when you’ve broken something a user would notice, and stay silent when you’ve only changed how the component happens to be implemented internally.

Bad Practice (Enzyme-style)

// Testing implementation details
const wrapper = shallow(<Counter />);
expect(wrapper.state('count')).toBe(0);
wrapper.instance().increment();
expect(wrapper.state('count')).toBe(1);

This test reads internal state and calls an instance method directly, so it breaks as soon as the component is refactored, even when nothing on screen changes.

Good Practice (RTL)

// Testing user behavior
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();

This version only checks what is rendered and interacts through the button’s accessible role, so it keeps passing through a refactor as long as the visible behavior is the same.

Note also that fireEvent.click here is a simplification worth flagging early, before it becomes habit — it’s a low-level DOM event dispatch, whereas user-event (covered in Section 4) simulates the full sequence of real events a browser fires for a genuine user click (pointerdown, mousedown, focus, mouseup, click, in the right order). For a plain click on a plain button they behave identically, but the moment a component’s behavior depends on focus, hover states, or pointerdown specifically, fireEvent.click alone can pass a test that would fail for a real user — worth keeping in mind as the guide reaches user-event, which is the officially recommended default in current RTL docs.

Installation

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

Basic Test

// Counter.tsx
export function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}
// Counter.test.tsx
import { render, screen } from '@testing-library/react';
import { Counter } from './Counter';

test('increments count on button click', () => {
  render(<Counter />);
  
  // Initial state
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
  
  // Click button
  const button = screen.getByRole('button', { name: /increment/i });
  button.click();
  
  // Updated state
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

Worth noting button.click() here is calling the browser’s native DOM method directly, not an RTL or user-event API — it works for this simple case (React’s synthetic event system still picks it up), but it’s the same shortcut flagged above: it doesn’t go through the full simulated-user-interaction pipeline user.click() does. It’s shown in this basic example specifically because it needs zero extra setup, but the pattern that scales to real forms and interactive components is user-event, covered next.

Queries

Priority Order

  1. getByRole (preferred)
  2. getByLabelText (forms)
  3. getByPlaceholderText (forms)
  4. getByText (non-interactive)
  5. getByDisplayValue (forms)
  6. getByAltText (images)
  7. getByTitle (tooltips)
  8. getByTestId (last resort)

This priority order isn’t arbitrary preference — it’s ranked by how closely each query mirrors how a real user (including one using a screen reader) actually finds an element on the page. A sighted mouse user finds a button by its visible role and label; a screen reader user does too, via the accessibility tree, which is exactly what getByRole queries against. data-testid, by contrast, is invisible to every real user and every assistive technology — reaching for it first is a sign the component might not be properly accessible in the first place, not just a query-style preference, which is why it’s demoted to “last resort” rather than banned outright: some elements (a decorative wrapper div with no semantic role) genuinely have no accessible way to target them, and that’s the legitimate case for data-testid.

getByRole

// Most accessible query
screen.getByRole('button', { name: /submit/i });
screen.getByRole('heading', { name: /welcome/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('checkbox', { name: /accept terms/i });

The name option here isn’t matching an HTML name attribute — it’s matching the element’s accessible name, which for a button is typically its visible text content, but can also come from aria-label, aria-labelledby, or (for form elements) an associated <label>. Getting a getByRole query wrong most often means the element genuinely lacks an accessible name a screen reader could announce, and the fix belongs in the component’s markup (adding an aria-label, associating a label) — the failing test is doing its job by surfacing a real accessibility gap, not just being finicky about query syntax.

getByLabelText

// For form inputs
render(
  <label>
    Email
    <input type="email" />
  </label>
);

screen.getByLabelText('Email');

getByLabelText works via the same label-input association a browser (and a screen reader) actually uses — wrapping the input in the <label> element, or connecting them with htmlFor/id — which means a form built without a real, connected label (a placeholder standing in for a label, or a visually-styled <div> pretending to be one) fails this query even if it looks labeled to a sighted user. This has genuinely caught real accessibility bugs in forms I’ve worked on: a design that used placeholder text as the only “label” looked fine visually but left screen reader users with no way to know what a field was for, and getByLabelText failing loudly in the test suite was what actually surfaced it before it shipped.

getByText

// For text content
screen.getByText('Hello, World!');
screen.getByText(/hello/i); // Case-insensitive regex
screen.getByText((content, element) => {
  return element?.tagName === 'P' && content.startsWith('Hello');
});

The callback form (matching on content and element) exists for the case where text is split across multiple DOM nodes — <p>Hello, <strong>World</strong>!</p> doesn’t have any single node whose full text content is exactly "Hello, World!", so a plain string match against getByText would fail to find it even though that’s what a user visually reads. This is a genuinely common real-world gotcha: text that looks like one phrase to a user is frequently split by inline formatting (bold, links, icons) in the actual markup, and the callback form is the escape hatch for matching text that spans that split.

Query Variants

// getBy - throws error if not found
screen.getByRole('button');

// queryBy - returns null if not found
screen.queryByRole('button'); // null or element

// findBy - async, waits for element
await screen.findByRole('button'); // Promise<element>

// getAllBy, queryAllBy, findAllBy - multiple elements
screen.getAllByRole('listitem');

Picking the wrong variant is one of the most common RTL mistakes I’ve seen, and it produces two very different failure shapes worth knowing apart. Using getBy* to assert something is absent is the first: getByRole('alert') throws immediately if the element isn’t there yet, which is wrong when you’re checking “this error message shouldn’t be showing” — queryBy* (returning null instead of throwing) is the correct tool for asserting non-presence, since expect(screen.queryByRole('alert')).not.toBeInTheDocument() needs a query that can fail to find something without erroring out the whole test. The second is using getBy* for something that appears after an async operation — a getBy* call runs synchronously once, at the exact moment it’s called, so if the element isn’t in the DOM yet (still waiting on that fetch from the async example later in this guide), it throws immediately rather than waiting; findBy*, which polls and retries for up to a default 1-second timeout, is what’s actually needed there.

User Events

npm install --save-dev @testing-library/user-event
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('submits form', async () => {
  const user = userEvent.setup();
  render(<LoginForm />);
  
  // Type into inputs
  await user.type(screen.getByLabelText('Email'), '[email protected]');
  await user.type(screen.getByLabelText('Password'), 'password123');
  
  // Click button
  await user.click(screen.getByRole('button', { name: /login/i }));
  
  // Assert
  expect(await screen.findByText('Welcome!')).toBeInTheDocument();
});

userEvent.setup() returning an instance that every interaction method is called on (user.type, user.click) rather than calling static methods directly (userEvent.type(...), the older API style) is worth using consistently — it’s what lets user-event correctly track state across a sequence of interactions in the same test, like whether an element already has focus from a previous step. Every one of these methods is async and needs await, which trips people up coming from fireEvent’s synchronous API: user-event deliberately models realistic timing (the delay between keystrokes when typing, for instance), so skipping the await can produce a test that passes by accident — the assertion running before the simulated interaction has actually finished — rather than a clean failure, which is a genuinely confusing class of flaky test to debug.

User Event Methods

const user = userEvent.setup();

// Typing
await user.type(input, 'Hello');
await user.clear(input);

// Clicking
await user.click(button);
await user.dblClick(button);

// Selecting
await user.selectOptions(select, 'option1');
await user.selectOptions(select, ['option1', 'option2']);

// Uploading files
await user.upload(fileInput, file);

// Keyboard
await user.keyboard('{Shift>}A{/Shift}'); // Types "A"
await user.keyboard('{Enter}');

user.type specifically fires real per-character keyboard events in sequence — keydown, keypress, input, keyup for each character — rather than setting the input’s value all at once the way directly assigning input.value = 'Hello' would. This is what makes it catch bugs a value-assignment approach completely misses: an onKeyDown handler that intercepts specific keys, an input mask that reformats as you type, a character limit enforced live — none of that logic runs if the value is just set directly, but all of it runs correctly under user.type, which is exactly the class of bug I’ve seen slip through test suites using a more naive fill-the-input approach.

Async Testing

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);
  
  if (!user) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}
import { render, screen, waitFor } from '@testing-library/react';

test('loads and displays user', async () => {
  render(<UserProfile userId={1} />);
  
  // Initially loading
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  // Wait for user to load
  expect(await screen.findByText('Alice')).toBeInTheDocument();
  
  // Alternative with waitFor
  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument();
  });
});

findByText and the waitFor block below it are functionally equivalent here — findBy* is genuinely implemented as waitFor wrapped around a getBy* query internally — but findBy* is the right default because it’s shorter and communicates intent directly (“find this, waiting if necessary”) rather than the more general “retry this assertion until it passes.” Reach for the explicit waitFor form specifically when you need to wait on something that isn’t a single query — asserting a mock function was called a certain number of times, or checking multiple conditions together — situations findBy*’s single-query shape can’t express.

Testing Forms

function SignupForm({ onSubmit }: { onSubmit: (data: any) => void }) {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    onSubmit(Object.fromEntries(formData));
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <label>
        Email
        <input name="email" type="email" required />
      </label>
      
      <label>
        Password
        <input name="password" type="password" required />
      </label>
      
      <label>
        <input name="terms" type="checkbox" required />
        Accept terms
      </label>
      
      <button type="submit">Sign Up</button>
    </form>
  );
}

This component reads the submitted values via FormData rather than controlled useState inputs — a deliberate choice worth noticing, since it means the test below genuinely has to fill in real, functioning form fields for FormData to pick up the right values, rather than a test that could pass by only asserting against React state that happens to be wired up separately from what actually gets submitted. Testing an uncontrolled form this way is closer to testing the real contract users experience: what you type is what gets submitted, full stop, with no state-management layer in between that a test could accidentally validate in isolation from the actual DOM.

test('submits form with valid data', async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();
  
  render(<SignupForm onSubmit={onSubmit} />);
  
  await user.type(screen.getByLabelText('Email'), '[email protected]');
  await user.type(screen.getByLabelText('Password'), 'password123');
  await user.click(screen.getByRole('checkbox', { name: /accept terms/i }));
  await user.click(screen.getByRole('button', { name: /sign up/i }));
  
  expect(onSubmit).toHaveBeenCalledWith({
    email: '[email protected]',
    password: 'password123',
    terms: 'on',
  });
});

terms: 'on' rather than terms: true is worth explaining rather than treating as a typo — that’s the actual, slightly surprising value a checked HTML checkbox produces in a native FormData object ("on" is the browser’s default checkbox value when no explicit value attribute is set), not a boolean. This is exactly the sort of platform quirk a test written against real DOM behavior surfaces automatically and a test written against a hand-rolled controlled-state mock might not — one more concrete case of RTL’s “test what actually happens” philosophy paying off.

Testing with Context

function UserGreeting() {
  const { user } = useAuth();
  return <div>Hello, {user.name}!</div>;
}
test('displays user name from context', () => {
  const mockUser = { name: 'Alice' };
  
  render(
    <AuthContext.Provider value={{ user: mockUser }}>
      <UserGreeting />
    </AuthContext.Provider>
  );
  
  expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
});

Wrapping the component directly in <AuthContext.Provider> inline like this works fine for a single, one-off test, but it doesn’t scale — the moment three or four different providers (auth, theme, routing, a query client) all need to wrap every test in an app, repeating that nesting in every single test file becomes real, error-prone boilerplate, and it’s easy for a test to accidentally omit one provider a component actually needs. The custom render helper below is the standard fix for exactly that problem.

Custom Render Helper

// test-utils.tsx
import { render } from '@testing-library/react';
import { AuthProvider } from './AuthProvider';

export function renderWithAuth(ui: React.ReactElement, options = {}) {
  return render(ui, {
    wrapper: ({ children }) => (
      <AuthProvider>{children}</AuthProvider>
    ),
    ...options,
  });
}

// Usage
import { renderWithAuth } from './test-utils';

test('test', () => {
  renderWithAuth(<UserGreeting />);
});

RTL’s render accepting a wrapper option is exactly the extension point this pattern relies on — renderWithAuth is just a thin function that calls the real render with a pre-configured wrapper, which is why it composes cleanly and still returns everything the normal render result does (screen queries, rerender, etc.). On a real project this usually grows into one shared test-utils.tsx re-exporting a render that wraps every app-wide provider at once (theme, router, query client, auth), and every test file imports render from that shared module instead of directly from @testing-library/react — a small convention that quietly prevents an entire class of “works in isolation, breaks when actually mounted in the app” test gaps.

Testing Hooks

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

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = () => setCount(c => c + 1);
  return { count, increment };
}
test('increments counter', () => {
  const { result } = renderHook(() => useCounter(0));
  
  expect(result.current.count).toBe(0);
  
  act(() => {
    result.current.increment();
  });
  
  expect(result.current.count).toBe(1);
});

renderHook exists because RTL’s core render needs a component to mount — testing a hook directly means wrapping it in a throwaway component internally, which renderHook does for you, exposing the hook’s return value via result.current instead of anything visible in a DOM. act() (imported from react, not shown in this snippet but required) is worth understanding rather than treating as required ceremony: it’s what flushes React’s state updates and effects synchronously before the next assertion runs, so result.current.count reflects the state after increment()’s setCount call has actually been processed, not a stale value read before React got around to re-rendering. Skipping act() around a state-updating call is a common source of the “Warning: An update to TestComponent was not wrapped in act(…)” message, which is React’s way of flagging that an assertion might be reading state before it’s actually settled.

Accessibility Testing

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

test('button is accessible', () => {
  render(<button>Click me</button>);
  
  // Can be found by role
  expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});

test('image has alt text', () => {
  render(<img src="logo.png" alt="Company logo" />);
  
  expect(screen.getByAltText('Company logo')).toBeInTheDocument();
});

test('form is accessible', () => {
  render(
    <form>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" />
    </form>
  );
  
  expect(screen.getByLabelText('Email')).toBeInTheDocument();
});

Worth being honest about what these three tests actually verify, and what they don’t: they confirm an element is reachable via role, alt text, or label association — genuine, real accessibility signals — but passing all three says nothing about color contrast, focus order, keyboard-only navigability through a complex interactive widget, or ARIA usage beyond what RTL’s queries happen to check. RTL is a meaningfully useful accessibility smoke test (and it’s caught real issues in things I’ve worked on, as noted earlier), but for anything beyond “can this element be found the way an assistive technology would find it,” a dedicated tool like axe-core/jest-axe running automated WCAG rule checks against the rendered output is the more complete answer, layered on top of RTL rather than replacing it.

Further reading

When a test feels awkward to write with Testing Library — you reach for container.querySelector, wrap things in act() by hand, or assert on component state — it is usually one of the patterns catalogued in Kent C. Dodds’ Common mistakes with React Testing Library, which is worth reading once after the basics above. The official documentation has the full query priority list.