Fix: React Testing Library Not Finding Element — Unable to Find Role or Text
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/iOr 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 name —
getByRole('button', { name: 'Submit' })matches the button’s accessible name, which comes from its text content,aria-label, oraria-labelledby. A mismatch (e.g., the button says “submit form” not “Submit”) causes the query to fail. - Element not yet rendered —
getBy*queries are synchronous. If the element appears after an async operation (fetch, state update), usefindBy*(async) orwaitFor. - Wrong role — HTML elements have implicit ARIA roles. An
<a>withouthrefis a generic role, not a link. A<div>withonClickis not a button role. - Hidden elements — RTL skips elements with
aria-hidden,display: none, orvisibility: hiddenby 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
screenas the recommended import. Older tests passing the container around still work, but new tests should usescreen.getByRole(...)so error messages include the rendered DOM. - RTL v12 (May 2021) — async queries (
findBy*) became the default recommendation for anything insideuseEffect. The defaultfindBy*timeout was raised to 1000ms, andwaitForstarted 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 insideuserEventandfindBy*are now applied automatically, so manually wrapping interactions inact()produces “update was not wrapped in act” warnings instead of fixing them. @testing-library/jest-domv5.16 → v6 (Oct 2023) — dropped CommonJS-only imports. If you upgraded jest-dom but leftimport '@testing-library/jest-dom/extend-expect'in your setup file, matchers silently stop registering. The new import isimport '@testing-library/jest-dom'.- RTL v14 (May 2023) — requires React 18 and
@testing-library/domv9+. Tests usingrenderfrom RTL v14 against a React 17 app fail with “Invalid hook call” at module load. - RTL v15 (Jul 2024) — switched the
hiddenquery option default and madegetByRolestricter 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 2Debug 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 elementMatching partial text — use a regex or { exact: false }:
getByText('Submit') // Exact match
getByText(/submit/i) // Regex — case-insensitive
getByText('Submit', { exact: false }) // Substring matchact() 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Mapbox GL JS Not Working — Map Not Rendering, Markers Missing, or Access Token Invalid
How to fix Mapbox GL JS issues — access token setup, React integration with react-map-gl, markers and popups, custom layers, geocoding, directions, and Next.js configuration.
Fix: React PDF Not Working — PDF Not Rendering, Worker Error, or Pages Blank
How to fix react-pdf and @react-pdf/renderer issues — PDF viewer setup, worker configuration, page rendering, text selection, annotations, and generating PDFs in React.
Fix: React Three Fiber Not Working — Canvas Blank, Models Not Loading, or Performance Dropping
How to fix React Three Fiber (R3F) issues — Canvas setup, loading 3D models with useGLTF, lighting, camera controls, animations with useFrame, post-processing, and Next.js integration.