Skip to content

Fix: React Testing Library Not Finding Element — Unable to Find Role or Text

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix React Testing Library query failures — getByRole vs getByText, async queries, accessible names, waitFor patterns, custom queries, and common selector mistakes.

The Problem

React Testing Library throws an error when querying an element:

TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "Submit"

Here are the accessible roles:
  button:
    Name "Cancel":
    <button>Cancel</button>

  button:
    Name "submit form":
    <button aria-label="submit form">...</button>

Or getByText can’t find visible text:

TestingLibraryElementError: Unable to find an element with the text: /submit/i

Or a query fails because the element renders asynchronously:

TestingLibraryElementError: Unable to find an element by: [data-testid="user-list"]

Or waitFor or findBy never resolves.

Why This Happens

React Testing Library (RTL) queries the DOM using accessibility semantics — roles, labels, and visible text — not CSS selectors or component internals. The goal is to make tests resemble how a real user finds elements on the page, which means the library deliberately refuses to expose internal class names, refs, or component state. When a query fails, the cause is almost always a mismatch between what the rendered DOM exposes to assistive technology and what your test is asking for.

The most frequent failure modes fall into two buckets: timing and identity. Timing problems happen because getBy* is synchronous — it runs the query once, immediately, and throws if nothing matches. Anything driven by useEffect, a fetch, a state transition, or a portal mount happens later, so the element does not yet exist when the assertion runs. Identity problems happen because the accessible name you assumed is not the accessible name the browser computes. A button containing both a label span and an icon span has an accessible name built from concatenated text, with whitespace collapsed and ARIA attributes layered in by precedence.

Common failure causes:

  • Wrong accessible namegetByRole('button', { name: 'Submit' }) matches the button’s accessible name, which comes from its text content, aria-label, or aria-labelledby. A mismatch (e.g., the button says “submit form” not “Submit”) causes the query to fail.
  • Element not yet renderedgetBy* queries are synchronous. If the element appears after an async operation (fetch, state update), use findBy* (async) or waitFor.
  • Wrong role — HTML elements have implicit ARIA roles. An <a> without href is a generic role, not a link. A <div> with onClick is not a button role.
  • Hidden elements — RTL skips elements with aria-hidden, display: none, or visibility: hidden by default. Elements hidden from the accessibility tree won’t be found.
  • Text normalization — RTL normalizes whitespace by default. But if the text contains special characters, is split across elements, or has extra whitespace, exact matching fails.

Version History That Changes the Failure Mode

The error message you get from RTL depends heavily on which major version is installed. Many Stack Overflow answers reference behavior that was patched out or made stricter years ago.

  • RTL v9 (Sep 2020) — introduced screen as the recommended import. Older tests passing the container around still work, but new tests should use screen.getByRole(...) so error messages include the rendered DOM.
  • RTL v12 (May 2021) — async queries (findBy*) became the default recommendation for anything inside useEffect. The default findBy* timeout was raised to 1000ms, and waitFor started swallowing non-matcher errors silently — a common source of “test passes locally, fails in CI” issues.
  • RTL v13 (Apr 2022) — bundled the React 18 concurrent rendering changes. act() calls inside userEvent and findBy* are now applied automatically, so manually wrapping interactions in act() produces “update was not wrapped in act” warnings instead of fixing them.
  • @testing-library/jest-dom v5.16 → v6 (Oct 2023) — dropped CommonJS-only imports. If you upgraded jest-dom but left import '@testing-library/jest-dom/extend-expect' in your setup file, matchers silently stop registering. The new import is import '@testing-library/jest-dom'.
  • RTL v14 (May 2023) — requires React 18 and @testing-library/dom v9+. Tests using render from RTL v14 against a React 17 app fail with “Invalid hook call” at module load.
  • RTL v15 (Jul 2024) — switched the hidden query option default and made getByRole stricter about ARIA 1.2 role-name computations. Buttons whose only label was an SVG <title> element stopped matching by their previous accessible name.

Check your installed version with npm ls @testing-library/react before trusting any older guide.

Fix 1: Use the Right Query for the Job

RTL provides multiple query types. Use them in priority order:

// Priority 1 — getByRole (most semantic, preferred)
// Matches by ARIA role + accessible name
getByRole('button', { name: /submit/i })
getByRole('textbox', { name: 'Email' })
getByRole('checkbox', { name: 'Remember me' })
getByRole('heading', { name: 'Sign In', level: 2 })
getByRole('link', { name: 'Learn more' })
getByRole('img', { name: 'Profile photo' })

// Priority 2 — getByLabelText (for form elements)
getByLabelText('Email address')
getByLabelText(/password/i)

// Priority 3 — getByPlaceholderText (fallback for forms)
getByPlaceholderText('Enter your email')

// Priority 4 — getByText (for non-interactive elements)
getByText('Welcome back')
getByText(/loading/i)

// Priority 5 — getByDisplayValue (for current value of inputs)
getByDisplayValue('[email protected]')

// Priority 6 — getByAltText (for images)
getByAltText('Company logo')

// Priority 7 — getByTitle (for title attributes)
getByTitle('Close dialog')

// Last resort — getByTestId (avoid if possible)
// Requires: <div data-testid="user-profile">
getByTestId('user-profile')

Fix 2: Understand Accessible Names

getByRole with name matches the element’s accessible name, not its text content alone:

// Component
function LoginForm() {
  return (
    <form>
      <button>Submit</button>                          {/* accessible name: "Submit" */}
      <button aria-label="submit form">→</button>     {/* accessible name: "submit form" */}
      <button aria-labelledby="btn-label">
        <span id="btn-label">Confirm action</span>
        <span>→</span>
      </button>                                        {/* accessible name: "Confirm action" */}
    </form>
  );
}

// Tests
getByRole('button', { name: 'Submit' })           // ✓ matches first button
getByRole('button', { name: /submit/i })          // ✓ matches both first and second (case-insensitive regex)
getByRole('button', { name: 'submit form' })      // ✓ matches second button
getByRole('button', { name: 'Confirm action' })   // ✓ matches third button
getByRole('button', { name: '→' })                // ✗ '→' is not the accessible name of button 2

Debug accessible names:

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

test('debug accessible roles', () => {
  const { container } = render(<LoginForm />);

  // Print all roles and accessible names in the rendered output
  logRoles(container);

  // Output:
  // button:
  //   Name "Submit":
  //   <button>Submit</button>
  // button:
  //   Name "submit form":
  //   <button aria-label="submit form">→</button>
});

Fix 3: Use findBy* for Async Elements

getBy* is synchronous — it fails immediately if the element isn’t present. Use findBy* for elements that appear asynchronously:

// Component that fetches data
function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch('/api/users').then(r => r.json()).then(setUsers);
  }, []);

  return (
    <ul>
      {users.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  );
}

// WRONG — getBy fails because list is empty on initial render
test('shows users', () => {
  render(<UserList />);
  const items = screen.getByRole('listitem');  // Fails: no listitems yet
});

// CORRECT — findBy waits for the element to appear
test('shows users', async () => {
  render(<UserList />);
  // findByRole waits up to 1000ms for the element to appear
  const firstItem = await screen.findByRole('listitem');
  expect(firstItem).toBeInTheDocument();
});

// CORRECT — waitFor for more complex conditions
test('shows all users', async () => {
  render(<UserList />);
  await waitFor(() => {
    const items = screen.getAllByRole('listitem');
    expect(items).toHaveLength(3);
  });
});

findBy* vs waitFor:

// findBy* = getBy* + waitFor — use for finding a single element
const button = await screen.findByRole('button', { name: 'Submit' });

// waitFor = retry the assertion until it passes — use for complex conditions
await waitFor(() => {
  expect(screen.getAllByRole('listitem')).toHaveLength(5);
});

// waitForElementToBeRemoved — wait for loading state to disappear
await waitForElementToBeRemoved(() => screen.getByText('Loading...'));
// Or
await waitForElementToBeRemoved(screen.getByText('Loading...'));

Fix 4: Mock API Calls Correctly

RTL tests run in jsdom, not a real browser. Fetch calls need to be mocked:

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

// Using MSW (Mock Service Worker) — recommended
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ]);
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('loads and displays users', async () => {
  render(<UserList />);

  // Wait for loading to finish
  await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));

  // Now assert on the loaded state
  expect(screen.getByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('Bob')).toBeInTheDocument();
});

Simple fetch mock with jest.fn():

// Mock global fetch
global.fetch = jest.fn(() =>
  Promise.resolve({
    ok: true,
    json: () => Promise.resolve([{ id: 1, name: 'Alice' }]),
  } as Response)
);

afterEach(() => {
  jest.restoreAllMocks();
});

Fix 5: Test User Interactions

Use @testing-library/user-event for realistic user interactions:

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

test('submits the form', async () => {
  const handleSubmit = jest.fn();
  render(<LoginForm onSubmit={handleSubmit} />);

  // Set up user event (v14+ requires setup)
  const user = userEvent.setup();

  // Type into input
  await user.type(screen.getByLabelText('Email'), '[email protected]');
  await user.type(screen.getByLabelText('Password'), 'password123');

  // Click the submit button
  await user.click(screen.getByRole('button', { name: 'Sign In' }));

  // Assert
  expect(handleSubmit).toHaveBeenCalledWith({
    email: '[email protected]',
    password: 'password123',
  });
});

test('shows validation error on empty submit', async () => {
  render(<LoginForm onSubmit={jest.fn()} />);
  const user = userEvent.setup();

  await user.click(screen.getByRole('button', { name: 'Sign In' }));

  expect(await screen.findByText('Email is required')).toBeInTheDocument();
});

Fix 6: Handle Context Providers

Components that use context (Redux, Theme, Router) need their providers in tests:

// Custom render function with providers
import { render, RenderOptions } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './ThemeContext';

function AllProviders({ children }: { children: React.ReactNode }) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },  // Disable retries in tests
  });

  return (
    <QueryClientProvider client={queryClient}>
      <MemoryRouter>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </MemoryRouter>
    </QueryClientProvider>
  );
}

const customRender = (ui: React.ReactElement, options?: RenderOptions) =>
  render(ui, { wrapper: AllProviders, ...options });

// Export customRender as 'render' — drop-in replacement
export { customRender as render };
export * from '@testing-library/react';
// Test — uses custom render with all providers
import { render, screen } from './test-utils';  // Custom render

test('shows user profile', async () => {
  render(<UserProfile userId={1} />);
  expect(await screen.findByRole('heading', { name: 'Alice' })).toBeInTheDocument();
});

Fix 7: Debug Failing Queries

When a query fails unexpectedly:

test('debug failing query', () => {
  render(<MyComponent />);

  // Print the full rendered DOM
  screen.debug();
  // Or debug a specific element:
  screen.debug(screen.getByRole('main'));

  // Print all accessible roles
  import { logRoles } from '@testing-library/dom';
  const { container } = render(<MyComponent />);
  logRoles(container);

  // Check if an element exists without throwing
  const button = screen.queryByRole('button', { name: 'Submit' });
  console.log('Button found:', button);
  // null = not found, HTMLElement = found

  // See what text is in the document
  console.log(document.body.innerHTML);
});

Use prettyDOM for formatted output:

import { prettyDOM } from '@testing-library/dom';

test('inspect element', async () => {
  render(<UserCard userId={1} />);
  const card = await screen.findByRole('article');
  console.log(prettyDOM(card));
  // Prints formatted HTML of the element
});

Still Not Working?

Portals render outside the container — components that render into document.body via createPortal (modals, tooltips, toasts) are not inside the wrapper RTL returns. Use screen.getByRole(...) instead of container.querySelector(...)screen queries the entire document so portals are reachable.

Asynchronous state from React 18 transitions — wrapping setters in startTransition defers the update. A getByText immediately after the click will miss the new content. Use findByText or await waitFor even when the source code “looks” synchronous.

Custom matcher silently absent — if toBeInTheDocument returns “is not a function” after upgrading jest-dom to v6, your setup file probably imports the removed extend-expect entrypoint. Replace with import '@testing-library/jest-dom' and rerun.

within for scoping queries — if multiple elements have the same role/name, scope the query to a specific container:

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

const nav = screen.getByRole('navigation');
const links = within(nav).getAllByRole('link');
// Only finds links inside the nav element

Matching partial text — use a regex or { exact: false }:

getByText('Submit')            // Exact match
getByText(/submit/i)           // Regex — case-insensitive
getByText('Submit', { exact: false })  // Substring match

act() warnings — if you see “Warning: An update to X inside a test was not wrapped in act(…)”, wrap state updates in act() or use waitFor which wraps automatically:

// RTL's userEvent and findBy/waitFor wrap in act() automatically
// Only manually use act() for direct state updates:
act(() => {
  store.dispatch(someAction());
});

For related React testing issues, see Fix: Jest Async Test Timeout, Fix: Jest Module Mock Not Working, Fix: Jest Setup File Not Working, and Fix: React useEffect Runs Twice.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles