·Pramod Dutta

Flaky Tests in React Testing Library: Async Rendering, Queries, and Fixes

Comprehensive guide to fixing flaky tests in React Testing Library. Covers waitFor pitfalls, findBy vs getBy queries, act() warnings, async component testing, and state update issues with proven solutions.

react testing library flaky testsreact test failureswaitFor react testingasync react testsreact component testing

Flaky Tests in React Testing Library: Async Rendering, Queries, and Fixes

React Testing Library has transformed how developers test React components by encouraging tests that mirror real user behavior. Its query-based API, focus on accessibility, and "test the way users interact" philosophy have made it the dominant testing utility for React applications.

But even with the best tooling, react testing library flaky tests remain one of the most frustrating problems in frontend development. Tests that pass locally but fail in CI. Tests that break when you add an unrelated component. Tests that timeout intermittently with no obvious cause.

The root of most flakiness in React Testing Library comes from one fundamental challenge: React's asynchronous rendering model. Components re-render on state changes, effects fire after paint, and data fetching introduces network-dependent timing. Understanding these mechanics is essential to writing stable tests.

This guide covers every major source of react testing library flaky tests, explains the underlying causes, and provides proven patterns to make your component tests deterministic.

Understanding React's Asynchronous Rendering

React 18+ uses concurrent rendering by default, which means updates can be interrupted, prioritized, and batched in ways that were not possible before. This has direct implications for testing.

When you call setState, React does not immediately re-render. Instead, it schedules an update. In concurrent mode, React may even split rendering into multiple chunks. This means:

  • A state update in your test does not immediately produce new DOM output.
  • Multiple rapid state updates may be batched into a single render.
  • Effects (useEffect) run asynchronously after the browser paints.
  • These behaviors make synchronous assertions unreliable by design.

    The getBy vs findBy vs queryBy Decision

    The query you choose directly impacts test stability. Using the wrong query type is the number one cause of react testing library flaky tests.

    getBy: Synchronous and Immediate

    // getBy throws immediately if the element is not in the DOM
    

    const button = screen.getByRole('button', { name: 'Submit' });

    Use getBy only when you are certain the element is already rendered. If the element appears after an async operation (data fetch, state update, animation), getBy will throw before it has a chance to appear.

    findBy: Async and Retry-Based

    // findBy waits up to 1000ms by default, retrying the query
    

    const button = await screen.findByRole('button', { name: 'Submit' });

    findBy is shorthand for waitFor(() => getBy(...)). It retries the query until the element appears or the timeout expires. Use this for any element that appears after an async operation.

    queryBy: For Asserting Absence

    // queryBy returns null instead of throwing
    

    expect(screen.queryByText('Error')).not.toBeInTheDocument();

    The Flaky Pattern

    // BAD: Using getBy for an element that appears after data fetch
    

    test('displays user profile', async () => {

    render();

    // Component fetches data in useEffect, then renders

    const name = screen.getByText('Alice'); // THROWS: element not yet rendered

    });

    // GOOD: Using findBy to wait for async rendering
    

    test('displays user profile', async () => {

    render();

    const name = await screen.findByText('Alice'); // Waits for element

    expect(name).toBeInTheDocument();

    });

    waitFor Pitfalls and Best Practices

    waitFor is the most powerful tool for handling async behavior in React Testing Library, but it is also the most commonly misused. Misusing waitFor is a leading cause of react testing library flaky tests.

    Pitfall 1: Side Effects Inside waitFor

    // BAD: Performing actions inside waitFor
    

    await waitFor(() => {

    fireEvent.click(screen.getByRole('button')); // Fires on every retry!

    expect(screen.getByText('Submitted')).toBeInTheDocument();

    });

    waitFor retries the callback repeatedly until it passes. If you perform side effects (clicks, API calls) inside the callback, they execute on every retry. This can cause duplicate submissions, state corruption, and unpredictable test behavior.
    // GOOD: Separate actions from assertions
    

    fireEvent.click(screen.getByRole('button'));

    await waitFor(() => {

    expect(screen.getByText('Submitted')).toBeInTheDocument();

    });

    Pitfall 2: Multiple Assertions in waitFor

    // BAD: Multiple independent assertions in one waitFor
    

    await waitFor(() => {

    expect(screen.getByText('Name: Alice')).toBeInTheDocument();

    expect(screen.getByText('Email: alice@example.com')).toBeInTheDocument();

    expect(screen.getByText('Role: Admin')).toBeInTheDocument();

    });

    If the assertions resolve at different times, waitFor restarts from the first assertion on each retry. This can cause timeouts when later assertions take longer to become true.

    // GOOD: Wait for the key indicator, then assert the rest
    

    await screen.findByText('Name: Alice'); // Wait for data to load

    expect(screen.getByText('Email: alice@example.com')).toBeInTheDocument();

    expect(screen.getByText('Role: Admin')).toBeInTheDocument();

    Pitfall 3: Insufficient Timeout

    // BAD: Default 1000ms may not be enough for slow async operations
    

    await waitFor(() => {

    expect(screen.getByText('Analysis Complete')).toBeInTheDocument();

    });

    // GOOD: Increase timeout for known slow operations
    

    await waitFor(

    () => {

    expect(screen.getByText('Analysis Complete')).toBeInTheDocument();

    },

    { timeout: 5000 }

    );

    Pitfall 4: Using waitFor When findBy Suffices

    // UNNECESSARY: waitFor wrapping a single query
    

    await waitFor(() => {

    expect(screen.getByText('Hello')).toBeInTheDocument();

    });

    // SIMPLER: Use findBy directly

    expect(await screen.findByText('Hello')).toBeInTheDocument();

    The act() Warning Problem

    The dreaded act() warning is both a symptom and a cause of flaky tests. When you see "An update was not wrapped in act()", it means React processed a state update outside of a testing-aware context.

    Why act() Warnings Cause Flakiness

    State updates that occur outside act() are not guaranteed to flush before your assertions run. This creates a race condition: sometimes the update completes before the assertion (test passes), sometimes it does not (test fails).

    Common Sources of act() Warnings

    1. Unresolved async effects after unmount:
    // BAD: Component fetches data after test ends
    

    test('renders loading state', () => {

    render();

    expect(screen.getByText('Loading...')).toBeInTheDocument();

    // Test ends, but useEffect's fetch resolves and calls setState

    // React warns: state update on unmounted component

    });

    // GOOD: Wait for the async operation to complete
    

    test('renders loading then data', async () => {

    render();

    expect(screen.getByText('Loading...')).toBeInTheDocument();

    await screen.findByText('Data loaded'); // Wait for fetch to complete

    });

    2. Timers and intervals:
    // BAD: setInterval fires after test assertion
    

    test('shows countdown', () => {

    jest.useFakeTimers();

    render();

    act(() => {

    jest.advanceTimersByTime(3000);

    });

    expect(screen.getByText('7 seconds')).toBeInTheDocument();

    jest.useRealTimers();

    // Remaining intervals may fire and cause act() warnings

    });

    // GOOD: Clean up timers properly
    

    test('shows countdown', () => {

    jest.useFakeTimers();

    const { unmount } = render();

    act(() => {

    jest.advanceTimersByTime(3000);

    });

    expect(screen.getByText('7 seconds')).toBeInTheDocument();

    unmount(); // Component cleans up its interval

    jest.useRealTimers();

    });

    Testing Async Components

    Modern React applications are full of async patterns: data fetching with useEffect, Suspense boundaries, lazy-loaded components, and server-state libraries like React Query or SWR. Each requires specific testing strategies.

    Data Fetching Components

    // Component
    

    function UserList() {

    const [users, setUsers] = useState([]);

    const [loading, setLoading] = useState(true);

    useEffect(() => {

    fetchUsers().then((data) => {

    setUsers(data);

    setLoading(false);

    });

    }, []);

    if (loading) return

    Loading users...

    ;

    return (

      {users.map((u) => (

    • {u.name}
    • ))}

    );

    }

    // Test
    

    test('renders user list after fetch', async () => {

    // Mock the API

    vi.mocked(fetchUsers).mockResolvedValue([

    { id: 1, name: 'Alice' },

    { id: 2, name: 'Bob' },

    ]);

    render();

    // Assert loading state

    expect(screen.getByText('Loading users...')).toBeInTheDocument();

    // Wait for data

    const alice = await screen.findByText('Alice');

    expect(alice).toBeInTheDocument();

    expect(screen.getByText('Bob')).toBeInTheDocument();

    // Assert loading is gone

    expect(screen.queryByText('Loading users...')).not.toBeInTheDocument();

    });

    React Query / SWR Components

    When using server-state libraries, wrap your component in the library's provider with a fresh client for each test:

    function renderWithQueryClient(ui: React.ReactElement) {
    

    const queryClient = new QueryClient({

    defaultOptions: {

    queries: {

    retry: false, // Don't retry in tests

    gcTime: 0, // Don't cache between tests

    },

    },

    });

    return render(

    {ui}

    );

    }

    Reusing a QueryClient across tests is a major source of flakiness because cached data from one test leaks into the next.

    Suspense Boundaries

    test('renders lazy component', async () => {
    

    render(

    Loading...

    }>

    );

    // Wait for the lazy component to load

    await screen.findByTestId('dashboard');

    expect(screen.queryByText('Loading...')).not.toBeInTheDocument();

    });

    State Updates and Re-Renders

    React batches state updates in event handlers automatically (and in all updates in React 18). This batching can cause assertions to fail if you check the DOM too early.

    User Events vs fireEvent

    // fireEvent dispatches a single DOM event synchronously
    

    fireEvent.change(input, { target: { value: 'hello' } });

    // userEvent simulates full user interaction (focus, keydown, input, keyup, etc.)

    await userEvent.type(input, 'hello');

    userEvent is more realistic and triggers all the events a real user would produce. It is also async in v14+, which means you must await it. Forgetting to await userEvent calls is a common source of react testing library flaky tests.
    // BAD: Missing await on userEvent
    

    test('search filters results', () => {

    render();

    userEvent.type(screen.getByRole('textbox'), 'apple'); // Not awaited!

    expect(screen.getByText('Apple')).toBeInTheDocument(); // Flaky

    });

    // GOOD: Await userEvent

    test('search filters results', async () => {

    render();

    await userEvent.type(screen.getByRole('textbox'), 'apple');

    expect(screen.getByText('Apple')).toBeInTheDocument();

    });

    Mocking Network Requests

    Unmocked or improperly mocked network requests are a frequent source of flakiness. Tests that hit real APIs are inherently non-deterministic.

    Using MSW (Mock Service Worker)

    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({ onUnhandledRequest: 'error' }));

    afterEach(() => server.resetHandlers());

    afterAll(() => server.close());

    The onUnhandledRequest: 'error' option is critical -- it ensures any unmocked network request throws immediately, making hidden network dependencies visible.

    Test Cleanup and Isolation

    React Testing Library's cleanup function unmounts components and clears the DOM between tests. It runs automatically in most setups, but there are edge cases where manual cleanup is needed.

    Global State Providers

    // BAD: Shared store instance across tests
    

    const store = createStore();

    test('test 1', () => {

    render();

    fireEvent.click(screen.getByText('Increment'));

    expect(screen.getByText('Count: 1')).toBeInTheDocument();

    });

    test('test 2', () => {

    render();

    // Store still has count=1 from previous test!

    expect(screen.getByText('Count: 0')).toBeInTheDocument(); // FAILS

    });

    // GOOD: Fresh store for each test
    

    function renderWithStore(ui: React.ReactElement) {

    const store = createStore();

    return render({ui});

    }

    Debugging Flaky React Tests

    The screen.debug() Method

    test('debugging example', async () => {
    

    render();

    screen.debug(); // Prints current DOM to console

    // screen.debug(screen.getByRole('form')); // Print specific element

    });

    The logRoles Utility

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

    test('find accessible roles', () => {

    const { container } = render();

    logRoles(container); // Prints all ARIA roles in the DOM

    });

    Playground Integration

    React Testing Library provides a playground to help you write better queries:

    screen.logTestingPlaygroundURL(); // Generates a URL to the testing playground
    

    A Checklist for Fixing React Testing Library Flaky Tests

    When debugging react testing library flaky tests, work through this checklist:

  • Replace getBy with findBy for any element that renders asynchronously.
  • Ensure every userEvent call is awaited.
  • Move side effects out of waitFor callbacks.
  • Use a single assertion (or tightly coupled assertions) inside waitFor.
    1. Create fresh store/client/provider instances for each test.
    2. Mock all network requests with MSW or similar.
  • Clean up timers and subscriptions with proper afterEach hooks.
    1. Wait for all async operations to complete before the test ends.

    Automate Flaky Test Detection with DeFlaky

    Chasing flaky React tests manually is a massive time sink. Every hour spent debugging an intermittent waitFor timeout or hunting down a shared state leak is an hour not spent building features.

    DeFlaky automatically identifies flaky tests in your React test suite, tracks their failure patterns over time, and pinpoints the root cause -- whether it is an async query issue, a state leak, or a timing problem.

    Get instant visibility into your flakiest tests:

    npx deflaky run
    

    DeFlaky analyzes your test history, computes a reliability score for each test, and provides targeted fix recommendations. Stop guessing, start fixing.

    Stop guessing. DeFlaky your tests.

    Detect flaky tests in minutes with a single CLI command.