Most real UI is async: data loads, optimistic updates settle, toasts appear and disappear. Synchronous queries (getBy, queryBy) can't see elements that haven't appeared yet. Three tools handle this:
findBy* — returns a promise that polls until the element appears (default 1s timeout, 50 ms interval). Use when you're waiting for ONE specific element.
waitFor(callback) — runs the callback repeatedly until it doesn't throw, or the timeout expires. Use for assertions that aren't simple presence (a value updated, a mock got called).
waitForElementToBeRemoved(element) — polls until the element is gone. Use for spinners, toasts, modals closing.
Rule of thumb: prefer findBy over waitFor(() => expect(getBy...).toBeInTheDocument()). They do the same thing but findBy reads cleaner and produces better error messages.
The Common Pattern — Loading → Loaded
A page fetches data and shows a spinner, then the data, then maybe an error. The test reads like a screenplay:
Render the component.
Assert the spinner is present (getByRole('progressbar') or similar).
await screen.findByText(/loaded data/i) — wait for the success state to appear.
Optionally: expect(spinner).not.toBeInTheDocument() — assert the spinner is gone.
Async test failures usually mean stale assertions, not slow code. If a test fails with "unable to find element" after findBy timed out, the element you're waiting for is genuinely not appearing — either your mock isn't set up, or the component's loading logic is broken. Don't "fix" by bumping the timeout; investigate.
Custom Render — Where Providers Live
Production React trees are wrapped in providers: theme, router, query client, auth context. The default render doesn't know about these — each test would have to wrap every render manually. A custom render helper centralizes this.
The convention: create src/test-utils.tsx that re-exports everything from @testing-library/react plus a custom render that pre-wraps. Tests import from test-utils instead of @testing-library/react.
One Render Helper, Per-Test Options
The custom render takes optional configuration for routes, initial state, theme mode — whatever the test needs to vary. The default values match the most common case. A specific test that needs theme="dark" or starts at /dashboard passes those options without touching the rest of the suite.
Re-export everything from @testing-library/react. Then nobody has to remember whether to import screen from @testing-library/react or test-utils. One import path, one mental model.
Code
findBy — wait for an element to appear·tsx
// Async — findBy for elements that appear later
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UserProfile } from './user-profile';
describe('<UserProfile />', () => {
it('shows the spinner, then the name', async () => {
render(<UserProfile id={1} />);
// Immediately visible — synchronous query
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Appears after fetch resolves — async query
const name = await screen.findByRole('heading', { name: /pippa choi/i });
expect(name).toBeInTheDocument();
// Optionally assert the spinner is gone
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});
waitFor + waitForElementToBeRemoved — for non-presence async·tsx
// waitFor — for assertions that aren't just 'element appeared'
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './counter';
it('eventually persists the count to localStorage', async () => {
const setItem = vi.spyOn(Storage.prototype, 'setItem');
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
// The persistence is debounced — wait until it actually fires.
await waitFor(() => {
expect(setItem).toHaveBeenCalledWith('count', '1');
});
});
// waitForElementToBeRemoved — for spinners, toasts, modals closing
import { waitForElementToBeRemoved } from '@testing-library/react';
it('hides the loading toast after the request completes', async () => {
render(<UploadForm />);
// ... trigger upload
await waitForElementToBeRemoved(() => screen.queryByText(/uploading/i));
expect(screen.getByText(/upload complete/i)).toBeInTheDocument();
});
Custom render with providers — `src/test-utils.tsx`·tsx
// src/test-utils.tsx — your custom render that knows about providers
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement, ReactNode } from 'react';
import { ThemeProvider } from './theme';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
type CustomRenderOptions = Omit<RenderOptions, 'wrapper'> & {
initialRoute?: string;
theme?: 'light' | 'dark';
};
function AllProviders({
children,
initialRoute = '/',
theme = 'light',
}: { children: ReactNode } & CustomRenderOptions) {
// A fresh QueryClient per test prevents cache bleed between tests.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, // tests shouldn't retry
});
return (
<ThemeProvider mode={theme}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialRoute]}>{children}</MemoryRouter>
</QueryClientProvider>
</ThemeProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
{ initialRoute, theme, ...rtlOptions }: CustomRenderOptions = {}
) {
return render(ui, {
wrapper: ({ children }) => (
<AllProviders initialRoute={initialRoute} theme={theme}>
{children}
</AllProviders>
),
...rtlOptions,
});
}
// Re-export everything else so tests have one import path.
export * from '@testing-library/react';
export { renderWithProviders as render };
Using the custom render — clean tests, no provider boilerplate·tsx
// Tests import from test-utils, get providers for free.
import { describe, it, expect } from 'vitest';
import { render, screen } from './test-utils'; // ← custom, not @testing-library/react
import { Dashboard } from './dashboard';
describe('<Dashboard />', () => {
it('renders the welcome banner', async () => {
render(<Dashboard />);
// ThemeProvider, MemoryRouter, QueryClientProvider all already in scope.
expect(await screen.findByText(/welcome back/i)).toBeInTheDocument();
});
it('shows the dark-mode style when the theme option is dark', () => {
render(<Dashboard />, { theme: 'dark' });
expect(screen.getByRole('main')).toHaveClass('theme-dark');
});
it('starts at /reports when initialRoute is set', async () => {
render(<Dashboard />, { initialRoute: '/reports' });
expect(
await screen.findByRole('heading', { name: /monthly reports/i })
).toBeInTheDocument();
});
});
Build a <UserList /> that fetches /api/users via React Query and renders a list. Set up MSW to return three users. Write tests that: (1) shows a spinner initially (sync query), (2) shows three list items after the fetch resolves (findAllByRole('listitem')), (3) shows an error message when MSW returns 500. Build the custom render from test-utils.tsx so each test starts with a fresh QueryClient. Confirm the tests pass individually AND in any order.
Hint
If a test passes alone but fails when run with others, you're sharing state somewhere — usually the QueryClient or an MSW handler override that's leaking. The custom render + afterEach(() => server.resetHandlers()) combo prevents both.
Progress
Progress is local-only — sign in to sync across devices.