CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-testing-library--react

Simple and complete React DOM testing utilities that encourage good testing practices

Pending
Overview
Eval results
Files

async.mddocs/

Async Utilities

Wait for asynchronous changes with automatic act() wrapping.

API

waitFor Function

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

waitForElementToBeRemoved Function

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

findBy* Queries

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[]>;

Common Patterns

Wait for Element to Appear

// 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 Element to Disappear

// Wait for removal
await waitForElementToBeRemoved(() => screen.getByText(/loading/i));

// Or use waitFor with queryBy
await waitFor(() => {
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

Wait for Multiple Conditions

await waitFor(() => {
  expect(screen.getByText('Title')).toBeInTheDocument();
  expect(screen.getByText('Content')).toBeInTheDocument();
  expect(screen.queryByText('Loading')).not.toBeInTheDocument();
});

Custom Timeout

await waitFor(
  () => expect(screen.getByText('Slow load')).toBeInTheDocument(),
  { timeout: 5000 }  // 5 seconds
);

Testing Patterns

API Data Loading

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();
});

Async State Updates

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();
  });
});

Modal Closing

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();
});

Form Validation

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');
});

Polling/Retries

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();
});

Advanced Patterns

Wait for Multiple Elements

test('loads all items', async () => {
  render(<ItemList />);

  const items = await screen.findAllByRole('listitem');
  expect(items).toHaveLength(5);
});

Async Callback Return Value

test('returns value from waitFor', async () => {
  render(<Component />);

  const element = await waitFor(() => screen.getByText('Value'));
  expect(element).toHaveAttribute('data-loaded', 'true');
});

Custom Error Messages

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}`);
      },
    }
  );
});

Waiting for Hook Updates

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();
});

findBy* vs waitFor

Use findBy* when:

  • Waiting for single element to appear
  • Simple query without complex assertions
const button = await screen.findByRole('button');

Use waitFor when:

  • Multiple assertions needed
  • Complex conditions
  • Checking element properties
await waitFor(() => {
  const button = screen.getByRole('button');
  expect(button).toBeEnabled();
  expect(button).toHaveTextContent('Submit');
});

Common Pitfalls

❌ Wrong: Using getBy without waiting

// Will fail if element not immediately present
fireEvent.click(button);
expect(screen.getByText('Success')).toBeInTheDocument();  // ❌

✅ Correct: Use findBy or waitFor

fireEvent.click(button);
const success = await screen.findByText('Success');  // ✅
expect(success).toBeInTheDocument();

❌ Wrong: Return value instead of assertion

await waitFor(() => element !== null);  // ❌ Never retries

✅ Correct: Use assertions that throw

await waitFor(() => {
  expect(element).toBeInTheDocument();  // ✅ Throws until true
});

❌ Wrong: waitFor for synchronous state

fireEvent.click(button);
await waitFor(() => {  // ❌ Unnecessary
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

✅ Correct: Direct assertion for sync updates

fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();  // ✅

Configuration

Global Timeout

import { configure } from '@testing-library/react';

configure({ asyncUtilTimeout: 2000 });  // 2 seconds default

Per-Test Timeout

test('slow operation', async () => {
  await waitFor(
    () => expect(screen.getByText('Done')).toBeInTheDocument(),
    { timeout: 10000 }  // Override for this test
  );
});

Debugging

Print State on Timeout

await waitFor(
  () => {
    screen.debug();  // Print current DOM
    expect(screen.getByText('Expected')).toBeInTheDocument();
  },
  {
    onTimeout: (error) => {
      screen.debug();  // Print final state
      return error;
    },
  }
);

Important Notes

Automatic act() Wrapping

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();
});

Assertions in waitFor

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;
});

Default Timeouts

  • Default timeout: 1000ms (1 second)
  • Default interval: 50ms
  • Can be configured globally via configure() or per-call via options

waitFor vs findBy

Both 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();
});

Error Messages

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();
});

MutationObserver

waitFor uses MutationObserver to efficiently wait for DOM changes. It will check the callback:

  1. After every DOM mutation
  2. At the specified interval (default 50ms)
  3. Until timeout is reached (default 1000ms)

This makes it efficient for most async scenarios without constant polling.

Best Practices

  1. Prefer findBy* for simple waits
  2. Use waitFor for complex conditions
  3. Set realistic timeouts - default 1s usually sufficient
  4. Don't waitFor sync operations - fireEvent updates are synchronous
  5. Assert in callbacks - waitFor needs thrown errors to retry
  6. Use queryBy in waitFor - for checking absence
  7. Avoid waitFor for everything - only use when actually async

Install with Tessl CLI

npx tessl i tessl/npm-testing-library--react

docs

async.md

configuration.md

events.md

hooks.md

index.md

queries.md

rendering.md

tile.json