Skip to content

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

FixDevs ·

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. 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.

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?

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 and Fix: Jest Module Mock Not Working.

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