Tech Verse Logo
Enable dark mode
Testing React Components: Role Queries & Async State

Testing React Components: Role Queries & Async State

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

4 min read

Querying by Accessible Role First

Most broken test suites suffer from the same flaw: they test implementation details instead of user experience. When you query by CSS selector, component class, or internal DOM hierarchy, any small refactor breaks your test suite. Your application still works, but your CI pipeline turns red. Using react testing library properly means querying elements the same way assistive technology reads them: by accessible role and accessible name.

The getByRole query is your primary tool. It forces you to write accessible HTML while simultaneously writing resilient tests. If a button lacks an accessible label or a form control isn't linked to its label element using htmlFor, getByRole fails immediately. That failure is a feature, not a bug.

In React 19 and Next.js 16 applications, form controls and interactive components rely on implicit ARIA roles provided by standard HTML5 tags. You don't need to add explicit role attributes to standard button or input elements; standard HTML tags expose their semantic roles to the accessibility tree automatically.

// UserProfileForm.jsx
import { useState, useTransition } from 'react';

export function UserProfileForm({ onSave }) {
  const [name, setName] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleSubmit = (e) => {
    e.preventDefault();
    startTransition(async () => {
      await onSave({ name });
    });
  };

  return (
    <form onSubmit={handleSubmit} aria-label="Edit Profile">
      <label htmlFor="user-name">Display Name</label>
      <input
        id="user-name"
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Saving...' : 'Save Changes'}
      </button>
    </form>
  );
}

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

test('submits updated profile name', async () => {
  const user = userEvent.setup();
  const handleSave = jest.fn().mockResolvedValue(true);

  render(<UserProfileForm onSave={handleSave} />);

  const input = screen.getByRole('textbox', { name: /display name/i });
  const button = screen.getByRole('button', { name: /save changes/i });

  await user.type(input, 'Jane Doe');
  await user.click(button);

  expect(handleSave).toHaveBeenCalledWith({ name: 'Jane Doe' });
});

When getByRole isn't sufficient—such as when dealing with dynamic text blocks that lack natural roles—fall back to getByText or getByLabelText. Save getByTestId as a last resort. Reaching for data-testid attributes everywhere turns your test file into a shadow representation of your DOM hierarchy, completely missing accessibility bugs and structural regressions.

Avoiding Implementation Details with userEvent

A major mistake in React test suites is using fireEvent from @testing-library/react instead of userEvent from @testing-library/user-event. The fireEvent utility dispatches synthetic DOM events directly on an element. It doesn't simulate real browser behavior. If you trigger a click with fireEvent, it dispatches a single click event. It doesn't trigger hover, pointer down, focus, or pointer up events that actual browsers emit in sequence.

This difference matters when testing complex components like custom dropdowns, comboboxes, or forms built on top of modern UI frameworks. The userEvent library (version 14 and up) simulates full event chains, enforcing realistic browser interactions including focus management, keyboard navigation, and file upload behaviors.

Always call userEvent.setup() before rendering your component. Do not call it inside helper functions after render, as event listener setup can miss early lifecycle hooks and lead to flaky event bubbling.

Handling Async Assertions and React 19 Transitions

React 19 changed how state updates, server actions, and transition hooks queue microtasks. Testing asynchronous interactions requires precision to avoid non-deterministic test failures and console warnings about unwrapped state updates.

Testing Library provides three distinct query prefixes for async flows: getBy*, queryBy*, and findBy*. Use getBy* when an element must exist synchronously in the DOM. Use queryBy* when asserting that an element is absent from the DOM. Use findBy* when an element appears after an asynchronous operation like a network fetch or state transition.

// AsyncNotification.jsx
import { useState } from 'react';

export function AsyncNotification({ fetchStatus }) {
  const [status, setStatus] = useState(null);

  const handleCheck = async () => {
    const result = await fetchStatus();
    setStatus(result.message);
  };

  return (
    <div>
      <button type="button" onClick={handleCheck}>
        Check Status
      </button>
      {status ? <p role="status">{status}</p> : null}
    </div>
  );
}

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

test('displays status message after fetch resolves', async () => {
  const user = userEvent.setup();
  const mockFetch = jest.fn().mockResolvedValue({ message: 'System Operational' });

  render(<AsyncNotification fetchStatus={mockFetch} />);

  expect(screen.queryByRole('status')).not.toBeInTheDocument();

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

  const notification = await screen.findByRole('status');
  expect(notification).toHaveTextContent('System Operational');
});

Common Async Pitfalls and Gotchas

One frequent mistake is wrapping userEvent actions inside a waitFor callback. The waitFor function runs its callback repeatedly until it stops throwing errors or times out (defaulting to 1000ms). Wrapping an async user interaction inside waitFor can trigger multiple click or keypress actions sequentially, corrupting component state and causing unpredictable test behavior.

Another issue is placing multiple assertions inside a single waitFor block. If the first assertion succeeds but the second fails, waitFor loops back and executes the entire callback again. If the first assertion mutated state or triggered a side effect, you will trigger unexpected side effects on retry. Keep exactly one assertion inside each waitFor block.

When dealing with timing-sensitive logic like debounced inputs or polling intervals, use Vitest or Jest fake timers alongside userEvent.setup({ advanceTimers: jest.advanceTimersByTime }). Passing fake timer control directly into userEvent keeps user interactions in sync with virtual clock advances, preventing hanging promises and flaky test runs.

Testing Component Behavior Over State

Focusing on user-visible output keeps tests green through refactoring. Don't assert on component state variables, prop values, or internal class names. Assert on accessible labels, text content, and DOM visibility. When you upgrade React or switch underlying UI libraries, tests written against roles and accessibility attributes continue passing without modification.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3

    Laravel LLM Integration: Queues and Real-Time Frontend UI

    Laravel LLM Integration: Queues and Real-Time Frontend UI