Simple and complete React DOM testing utilities that encourage good testing practices
—
Wait for asynchronous changes with automatic act() wrapping.
Waits for a condition to be met, repeatedly calling the callback until it succeeds or times out. Useful for waiting for asynchronous state updates, API calls, or side effects.
/**
* Wait for a condition to be met by repeatedly calling the callback
* @param callback - Function to call repeatedly until it doesn't throw
* @param options - Configuration options
* @returns Promise resolving to callback's return value
* @throws When timeout is reached or onTimeout callback returns error
*/
function waitFor<T>(
callback: () => T | Promise<T>,
options?: {
/**
* Maximum time to wait in milliseconds (default: 1000)
*/
timeout?: number;
/**
* Time between callback calls in milliseconds (default: 50)
*/
interval?: number;
/**
* Custom error handler when timeout is reached
*/
onTimeout?: (error: Error) => Error;
/**
* Suppress timeout errors for debugging (default: false)
*/
mutationObserverOptions?: MutationObserverInit;
}
): Promise<T>;Waits for an element to be removed from the DOM. Useful for testing loading states, modals closing, or elements being unmounted.
/**
* Wait for element(s) to be removed from the DOM
* @param callback - Element, elements, or function returning element(s) to wait for removal
* @param options - Configuration options
* @returns Promise that resolves when element(s) are removed
* @throws When timeout is reached
*/
function waitForElementToBeRemoved<T>(
callback: T | (() => T),
options?: {
/**
* Maximum time to wait in milliseconds (default: 1000)
*/
timeout?: number;
/**
* Time between checks in milliseconds (default: 50)
*/
interval?: number;
/**
* Suppress timeout errors for debugging (default: false)
*/
mutationObserverOptions?: MutationObserverInit;
}
): Promise<void>;Async query variants that wait for elements to appear. These are convenience wrappers around waitFor + getBy* queries.
/**
* Async queries that wait for elements to appear
* All getBy* queries have findBy* async equivalents
*/
findByRole(role: string, options?: ByRoleOptions): Promise<HTMLElement>;
findAllByRole(role: string, options?: ByRoleOptions): Promise<HTMLElement[]>;
findByLabelText(text: string | RegExp, options?: SelectorMatcherOptions): Promise<HTMLElement>;
findAllByLabelText(text: string | RegExp, options?: SelectorMatcherOptions): Promise<HTMLElement[]>;
findByPlaceholderText(text: string | RegExp, options?: MatcherOptions): Promise<HTMLElement>;
findAllByPlaceholderText(text: string | RegExp, options?: MatcherOptions): Promise<HTMLElement[]>;
findByText(text: string | RegExp, options?: SelectorMatcherOptions): Promise<HTMLElement>;
findAllByText(text: string | RegExp, options?: SelectorMatcherOptions): Promise<HTMLElement[]>;
findByDisplayValue(value: string | RegExp, options?: MatcherOptions): Promise<HTMLElement>;
findAllByDisplayValue(value: string | RegExp, options?: MatcherOptions): Promise<HTMLElement[]>;
findByAltText(text: string | RegExp, options?: MatcherOptions): Promise<HTMLElement>;
findAllByAltText(text: string | RegExp, options?: MatcherOptions): Promise<HTMLElement[]>;
findByTitle(title: string | RegExp, options?: MatcherOptions): Promise<HTMLElement>;
findAllByTitle(title: string | RegExp, options?: MatcherOptions): Promise<HTMLElement[]>;
findByTestId(testId: string | RegExp, options?: MatcherOptions): Promise<HTMLElement>;
findAllByTestId(testId: string | RegExp, options?: MatcherOptions): Promise<HTMLElement[]>;// Using findBy* (recommended for single element)
const message = await screen.findByText(/loaded/i);
// Using waitFor (for complex assertions)
await waitFor(() => {
expect(screen.getByText(/loaded/i)).toBeInTheDocument();
});// Wait for removal
await waitForElementToBeRemoved(() => screen.getByText(/loading/i));
// Or use waitFor with queryBy
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});await waitFor(() => {
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Content')).toBeInTheDocument();
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
});await waitFor(
() => expect(screen.getByText('Slow load')).toBeInTheDocument(),
{ timeout: 5000 } // 5 seconds
);test('loads user data', async () => {
render(<UserProfile userId="123" />);
// Initially loading
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data
const name = await screen.findByText(/john doe/i);
expect(name).toBeInTheDocument();
// Loading gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});test('updates after async operation', async () => {
render(<AsyncCounter />);
fireEvent.click(screen.getByRole('button', { name: /increment/i }));
// Wait for state update
await waitFor(() => {
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});test('closes modal', async () => {
render(<App />);
const closeButton = screen.getByRole('button', { name: /close/i });
const modal = screen.getByRole('dialog');
fireEvent.click(closeButton);
await waitForElementToBeRemoved(modal);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});test('shows validation after submit', async () => {
render(<Form />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
const error = await screen.findByRole('alert');
expect(error).toHaveTextContent('Email is required');
});test('retries failed request', async () => {
render(<DataFetcher />);
// Shows initial error
expect(await screen.findByText(/error/i)).toBeInTheDocument();
// Retries and succeeds
const data = await screen.findByText(/success/i, {}, { timeout: 5000 });
expect(data).toBeInTheDocument();
});test('loads all items', async () => {
render(<ItemList />);
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(5);
});test('returns value from waitFor', async () => {
render(<Component />);
const element = await waitFor(() => screen.getByText('Value'));
expect(element).toHaveAttribute('data-loaded', 'true');
});test('provides context on timeout', async () => {
render(<Component />);
await waitFor(
() => expect(screen.getByText('Expected')).toBeInTheDocument(),
{
timeout: 2000,
onTimeout: (error) => {
screen.debug();
return new Error(`Element not found after 2s: ${error.message}`);
},
}
);
});test('hook updates async state', async () => {
const { result } = renderHook(() => useAsyncData());
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeDefined();
});const button = await screen.findByRole('button');await waitFor(() => {
const button = screen.getByRole('button');
expect(button).toBeEnabled();
expect(button).toHaveTextContent('Submit');
});// Will fail if element not immediately present
fireEvent.click(button);
expect(screen.getByText('Success')).toBeInTheDocument(); // ❌fireEvent.click(button);
const success = await screen.findByText('Success'); // ✅
expect(success).toBeInTheDocument();await waitFor(() => element !== null); // ❌ Never retriesawait waitFor(() => {
expect(element).toBeInTheDocument(); // ✅ Throws until true
});fireEvent.click(button);
await waitFor(() => { // ❌ Unnecessary
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument(); // ✅import { configure } from '@testing-library/react';
configure({ asyncUtilTimeout: 2000 }); // 2 seconds defaulttest('slow operation', async () => {
await waitFor(
() => expect(screen.getByText('Done')).toBeInTheDocument(),
{ timeout: 10000 } // Override for this test
);
});await waitFor(
() => {
screen.debug(); // Print current DOM
expect(screen.getByText('Expected')).toBeInTheDocument();
},
{
onTimeout: (error) => {
screen.debug(); // Print final state
return error;
},
}
);All async utilities automatically wrap operations in React's act(), ensuring state updates are properly flushed:
// No manual act() needed
await waitFor(() => {
expect(screen.getByText('Updated')).toBeInTheDocument();
});The callback passed to waitFor should contain assertions or throw errors. waitFor will retry until the callback doesn't throw:
// CORRECT: Assertion that throws
await waitFor(() => {
expect(element).toBeInTheDocument();
});
// WRONG: Always returns true, never retries
await waitFor(() => {
return element !== null;
});configure() or per-call via optionsBoth work similarly, but have different use cases:
// findBy: Convenient for single queries
const element = await screen.findByText('Hello');
// waitFor: Better for complex assertions
await waitFor(() => {
expect(screen.getByText('Hello')).toBeInTheDocument();
expect(screen.getByText('World')).toBeInTheDocument();
});When waitFor times out, it includes the last error thrown by the callback:
// Timeout error will include "Expected element to be in document"
await waitFor(() => {
expect(screen.getByText('Missing')).toBeInTheDocument();
});waitFor uses MutationObserver to efficiently wait for DOM changes. It will check the callback:
This makes it efficient for most async scenarios without constant polling.
Install with Tessl CLI
npx tessl i tessl/npm-testing-library--react@16.3.2