React Testing Library in 2026: Queries, Events, Async Patterns
Master React Testing Library in 2026 — the right queries, user-event v14 patterns, async gotchas, and how to actually write tests that don't break on refactors.
Why React Testing Library Still Wins in 2026
Testing Library has been the default React testing tool since roughly 2019 and, honestly, it's only gotten more important. Playwright and Cypress handle end-to-end flows. Vitest handles unit logic. But the layer in between — component behavior from a user's perspective — that's where RTL lives, and nothing else does it as cleanly.
The core philosophy hasn't changed: test what the user sees, not how you implemented it. Query by role, text, or label. Don't poke at internal state. This sounds obvious until you've maintained a 400-test suite where every test broke because someone renamed a CSS class — that's what happens when you test implementation details.
In 2026, RTL 15.x ships with better TypeScript inference out of the box, @testing-library/user-event v14 is the standard for interactions, and the async story is significantly cleaner than it was in v12. Worth noting: if you're still running fireEvent everywhere and wondering why your tests feel brittle, this guide is for you.
One more thing — the ecosystem around RTL has matured. jest-dom matchers are universally adopted, @testing-library/react-hooks is deprecated in favor of renderHook built directly into the main package, and React 19's concurrent features don't require any special ceremony to test.
The Query Priority Order (and Why It Actually Matters)
RTL gives you a lot of query options. getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, getByAltText, getByTitle, getByTestId. Most tutorials reach for getByTestId immediately. That's the wrong instinct — data-testid should be your last resort, not your first.
The official priority exists for a reason. getByRole is king. It queries by ARIA role, which means it only finds elements that are actually exposed to assistive technology. If your button isn't queryable by getByRole('button', { name: /submit/i }), that's a signal your markup is inaccessible — the test failure is doing you a favor.
// Bad — couples tests to implementation
const btn = screen.getByTestId('submit-btn');
// Good — queries what a screen reader sees
const btn = screen.getByRole('button', { name: /submit order/i });
// Also good — for form fields
const input = screen.getByLabelText(/email address/i);
// For non-interactive text content
const heading = screen.getByRole('heading', { name: /checkout/i });In practice, you'll use getByRole for 70-80% of your queries. getByLabelText covers form inputs. getByText handles static content like paragraphs and list items. getByAltText for images. That leaves maybe 5% of cases where getByTestId is genuinely the right call — usually for complex custom widgets that don't map cleanly to an ARIA role.
Quick aside: when you get stuck on what role an element has, call screen.logTestingPlaygroundURL() in a failing test. It opens the Testing Playground with your rendered HTML pre-loaded and suggests the optimal query for each element. I use this constantly.
getBy vs queryBy vs findBy — Stop Confusing Them
Three query families, three different contracts. If you mix them up you get either false positives or confusing errors. Get this straight and a whole class of flaky tests disappears.
getBy* — throws immediately if the element isn't found. Use this when the element must be there right now. If it's missing, the test fails with a clear error message showing your rendered HTML. This is your default for synchronous assertions.
queryBy* — returns null if not found instead of throwing. Use this exclusively when asserting an element does NOT exist. expect(screen.queryByRole('alert')).not.toBeInTheDocument() is the canonical pattern.
findBy* — returns a Promise. It retries internally until the element appears or times out (default 1000ms in RTL 15, was 1000ms in earlier versions too). Use this for anything async — data fetching, animations, deferred renders.
// Synchronous — element must exist immediately
const title = screen.getByRole('heading', { name: /profile/i });
// Asserting absence — use queryBy
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
// After an async action — use findBy
await userEvent.click(screen.getByRole('button', { name: /load/i }));
const item = await screen.findByRole('listitem', { name: /first result/i });Honestly, the most common mistake I see in code reviews is await screen.findByText(...) on elements that are already in the DOM synchronously. It works, but it's slow and signals a misunderstanding. Save findBy for when you actually need to wait for something.
user-event v14: The Right Way to Simulate Interactions
If you're still importing fireEvent from @testing-library/react for user interactions, stop. fireEvent is a thin wrapper around DOM events that bypasses the browser event model. It doesn't dispatch the full chain of events a real user triggers — no pointerover, no focus, no beforeinput. @testing-library/user-event v14 does all of that.
The v14 API introduced userEvent.setup() — you call it once at the top of your test (or in a beforeEach) and reuse the instance. This matters because the setup instance maintains internal state: pointer position, keyboard modifier keys, clipboard contents. Tests that share a setup instance behave much closer to a real browser session.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('LoginForm', () => {
// Setup once per test — v14 pattern
const user = userEvent.setup();
it('submits credentials on button click', async () => {
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// Type into fields — fires real input events
await user.type(screen.getByLabelText(/email/i), 'dev@example.com');
await user.type(screen.getByLabelText(/password/i), 'hunter2');
// Click — fires pointer + mouse + click chain
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'dev@example.com',
password: 'hunter2',
});
});
});Note every interaction is awaited. This is non-negotiable with v14 — the methods return Promises and skipping the await means events fire out of order. Your tests will pass inconsistently and you'll waste an afternoon debugging it.
That said, there are still legitimate uses for fireEvent. Custom drag-and-drop with precise coordinates, clipboard paste with specific MIME types, or simulating obscure keyboard shortcuts that user-event doesn't expose cleanly. For everything else — typing, clicking, selecting, tabbing — use userEvent.
Async Testing Patterns That Don't Flake
Async tests are where most RTL pain lives. You're testing a component that fetches data, shows a loading spinner, then renders results. Do it wrong and you get intermittent failures, act() warnings in your console, or tests that pass locally but fail in CI.
The golden rule: always wait for the final state, not intermediate states. Don't assert that a spinner appears and then separately wait for it to disappear. Just wait for the data to show up — RTL's findBy handles the polling for you.
// Component fetches users from /api/users
it('renders user list after fetch', async () => {
// Mock the fetch before rendering
server.use(
http.get('/api/users', () =>
HttpResponse.json([{ id: 1, name: 'Ada Lovelace' }])
)
);
render(<UserList />);
// Don't assert the spinner — wait for the result
const item = await screen.findByRole('listitem', { name: /ada lovelace/i });
expect(item).toBeInTheDocument();
});
// Testing error state
it('shows error message on failed fetch', async () => {
server.use(
http.get('/api/users', () => new HttpResponse(null, { status: 500 }))
);
render(<UserList />);
await screen.findByRole('alert');
expect(screen.getByRole('alert')).toHaveTextContent(/failed to load/i);
});The example above uses MSW (Mock Service Worker) v2, which is the correct tool for mocking HTTP in component tests. Avoid mocking fetch or axios directly — you end up coupling tests to implementation. MSW intercepts at the network level and works the same way in jsdom, Vitest, and real browsers.
One more thing — waitFor is your escape hatch for cases where findBy isn't enough. Use it when you're asserting that something eventually becomes false, or when you need to assert multiple things that all become true after an async operation. But don't wrap everything in waitFor as a crutch. If you're waiting for something to appear, findBy is cleaner and has better error messages.
Testing Custom Hooks with renderHook
Custom hooks are just functions that use React state and effects. Testing them directly with renderHook (built into RTL since v13) is cleaner than wrapping them in a throwaway component — and you get the same async support.
import { renderHook, act, waitFor } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('increments count', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('fetches initial value from API', async () => {
server.use(
http.get('/api/counter', () => HttpResponse.json({ value: 42 }))
);
const { result } = renderHook(() => useCounter());
await waitFor(() => {
expect(result.current.count).toBe(42);
});
});
});Notice the synchronous state update is wrapped in act(). This tells React to flush all pending state updates and effects before you read result.current. Skip it and you'll either read stale state or get an act() warning. For async hooks, waitFor handles the flushing for you so you don't need to wrap it manually.
Look, if you're building a UI component library — whether that's an internal design system or something like Empire UI — hook tests are where you get the most bang for your testing buck. Business logic in hooks, presentation in components, integration tests for the combination. That separation makes everything more testable.
Setup, Config, and Getting the Most Out of Your Test Suite
A few configuration decisions will affect every test you write. Get them right upfront rather than refactoring 200 tests later.
First: Vitest over Jest in 2026. If you're starting a new project, Vitest is faster, has first-class ESM support, and works out of the box with Vite-based projects. RTL works identically with both. The jsdom environment still applies — add // @vitest-environment jsdom at the top of test files or configure it globally in vitest.config.ts.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
});
```
```ts
// src/test/setup.ts
import '@testing-library/jest-dom';
import { server } from './mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());The onUnhandledRequest: 'error' flag is one of the best practices that most codebases skip. It makes MSW throw if a component makes a network request you didn't explicitly mock — which catches missing mocks immediately instead of silently returning undefined data and giving you confusing test failures downstream.
Quick aside: a custom render wrapper is almost always worth it. Wrap the default RTL render with your providers — React Query's QueryClientProvider, your theme provider, your router. Every test starts with a consistent environment and you never forget to add a provider.
FAQ
getBy throws immediately if the element isn't found — use it for synchronous assertions. queryBy returns null instead of throwing — use it only to assert an element doesn't exist. findBy returns a Promise and retries until the element appears — use it for anything async.
Use @testing-library/user-event v14 for all standard interactions — typing, clicking, selecting, tabbing. It dispatches the full browser event chain. fireEvent is still fine for edge cases like precise drag coordinates or custom MIME clipboard events.
You're reading component state before React has flushed pending updates or effects. Wrap synchronous state changes in act(), and use findBy or waitFor for async ones — both handle the flushing internally.
Yes — RTL works identically with Vitest. Set environment: 'jsdom' in your Vitest config, import @testing-library/jest-dom in your setup file, and everything works the same. Vitest is noticeably faster for large test suites.