Fixing Flaky React Testing Library Tests That Fail Intermittently With "act" Warnings
A test passes locally, passes on a second run, then fails in CI with "Warning: An update to Component inside a test was not wrapped in act(...)" — and the next CI run it passes again. The component isn't broken and the test assertion is correct; the test is finishing and making assertions before an asynchronous state update the component triggered has actually settled.
The Problem
A test written with React Testing Library renders a component, interacts with it, and asserts on the result. It passes reliably on a developer's machine and often passes in CI too — but occasionally fails there with a console warning like "Warning: An update to Component inside a test was not wrapped in act(...)", sometimes alongside an assertion failure, sometimes with the assertion still passing despite the warning. Re-running the exact same test, unmodified, often makes it pass. The component works correctly in the actual app; the flakiness is specific to the test environment's timing relative to the component's own asynchronous behavior.
Why It Happens
The warning exists because React needs to know when all updates from an interaction are actually finished
act() is React's way of telling the testing environment "everything triggered by this code should be fully processed — state updates applied, effects run — before you look at the result." When a component triggers a state update asynchronously (a fetch resolving later, a setTimeout, a promise chain) outside of an act()-wrapped block, React can't guarantee the DOM reflects that update by the time the test makes its assertion — the warning is React's way of flagging that the test's assertion and the component's actual settled state aren't provably synchronized.
Testing Library's async utilities already wrap in act(), but using the wrong one — or none — leaves gaps
Functions like findBy* queries and waitFor internally handle the act() wrapping and retry until the expected condition appears, but a test using a synchronous getBy* query immediately after an action that triggers an async update, or firing an event without awaiting the appropriate helper, skips that handling entirely — the test proceeds to assert before React has had a chance to flush the pending update.
CI environments are often slower or more variable than local development machines, which changes which race the test happens to win
A test that "accidentally" passes locally because the async operation resolves fast enough, relative to the test's synchronous code, to sneak in before the assertion runs can lose that race under CI's different timing characteristics (shared CPU, different Node version, container overhead) — the same code genuinely can produce different pass/fail outcomes depending on execution environment timing, not because the test logic itself is nondeterministic.
Mocked timers or fetches that don't resolve in the same tick as the real implementation can introduce exactly this kind of mismatch
A mocked fetch that resolves synchronously, or resolves after a different number of microtask ticks than the real network call, changes exactly where in the test's execution the async update lands relative to when assertions run — a mock designed for simplicity can inadvertently make a test's timing behavior diverge from what the component does in production, surfacing timing bugs the mock itself introduced.
The Fix
1. Use findBy* queries (not getBy*) for anything that appears after an async operation
// Instead of: expect(screen.getByText("Loaded")).toBeInTheDocument();
// immediately after a click that triggers an async fetch
const loadedText = await screen.findByText("Loaded"); // waits and wraps in act() internally
expect(loadedText).toBeInTheDocument();
findBy* queries retry until the element appears (or a timeout elapses) and handle the act() wrapping internally, which is the correct tool specifically for anything that doesn't exist synchronously right after the triggering interaction — reaching for getBy* here is the single most common source of this exact warning.
2. Wrap user interactions that trigger async updates with userEvent's async API, awaited properly
import userEvent from "@testing-library/user-event";
test("submits the form", async () => {
const user = userEvent.setup();
render(
Modern @testing-library/user-event's API is promise-based specifically so interactions that trigger async component behavior can be properly awaited, rather than using the older, synchronous fireEvent API for an interaction that's genuinely going to cause an asynchronous update.
3. Use waitFor explicitly when asserting on a condition that isn't tied to a specific query
import { waitFor } from "@testing-library/react";
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(expectedData);
});
For assertions that aren't about an element appearing (a mock function being called, a piece of external state changing) rather than a DOM query, waitFor gives the same retry-and-act()-wrap behavior for an arbitrary assertion, rather than needing a query-shaped workaround to get the same safety.
4. Make mocks resolve asynchronously in a way that matches real timing, not synchronously for convenience
// Instead of a mock that resolves synchronously or in the same tick:
jest.spyOn(api, "fetchData").mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(mockData), 0))
);
// This at least crosses a real microtask/macrotask boundary like the actual fetch would
Making a mock genuinely asynchronous — resolving on a later tick rather than immediately — keeps the test's timing characteristics closer to what the real implementation produces, which surfaces act() issues during development against the mock rather than only in CI against slightly different real-world timing.
Why This Works
Each fix closes a different gap between when a test asserts and when the component's async update has actually settled. findBy* and awaited userEvent calls handle the common interaction-then-async-result pattern directly; waitFor extends the same safety to non-DOM-query assertions; and making mocks asynchronous in a realistic way prevents the mock itself from masking timing issues that would otherwise only surface against real async behavior or under CI's different timing.
Conclusion
A test failing intermittently with an act() warning isn't a flaky component — it's a race between the test's assertion and an asynchronous update the component triggers, one that happens to resolve differently depending on environment timing. Use findBy* queries for anything appearing after an async operation, await userEvent's interactions properly rather than firing events synchronously, wrap non-query assertions in waitFor, and make mocks resolve asynchronously in a way that mirrors real timing rather than resolving instantly for convenience.
