Modern Web · React

React 19 Testing Patterns

What actually changes when you upgrade a React 18 test suite to 19: the act() environment flag, awaiting Actions and useActionState, and suspending components correctly in tests.

John Kihiu12 min read

Upgrading a test suite to React 19 usually breaks fewer tests than people expect, but the ones that do break tend to be the ones testing your most important user flows — forms, async loading, anything that used to be a simple "render, click, assert" test and now involves a pending state your test needs to wait for. Here's what actually changed and what to do about it.

The act() environment flag

React 19 tightened up how it decides whether it's running inside a test environment. Historically, React Testing Library set global.IS_REACT_ACT_ENVIRONMENT = true (or relied on older heuristics) so React would wrap updates in act() automatically and warn you about updates that happen outside of it. If you're on an old RTL version pinned from the React 18 days, upgrading to React 19 without bumping @testing-library/react to a version that supports 19 will surface a wall of "not wrapped in act" warnings, or worse, silently miss updates. The fix is boring: bump @testing-library/react to a version that lists React 19 as a peer dependency, and check that nothing in your test setup file is manually managing the old act environment global in a way that conflicts with what RTL now does itself.

Check your setup file, not just package.json

Some React 18 test setups set globalThis.IS_REACT_ACT_ENVIRONMENT directly to work around older RTL bugs. If that's still in your vitest.setup.ts or jest.setup.js, it can mask real problems after the upgrade. Delete it and let the current RTL version manage it.

Testing Actions and useActionState

A component using useActionState has a pending state that flips synchronously the moment you call the action, then flips back once the returned promise settles. A test that does fireEvent.click(submitButton) and immediately asserts on the success message will fail intermittently (or always, depending on how fast the mock resolves) unless you actually wait for the pending state to clear.

TSX · TESTING useActionState
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { SignupForm } from './SignupForm';

test('shows a success message after submit', async () => {
  render(<SignupForm />);

  fireEvent.change(screen.getByLabelText('Email'), {
    target: { value: 'jane@example.com' },
  });
  fireEvent.click(screen.getByRole('button', { name: /sign up/i }));

  // The action is async — don't assert right after the click.
  // waitFor polls until the pending state resolves and the DOM updates.
  await waitFor(() => {
    expect(screen.getByText(/welcome, jane/i)).toBeInTheDocument();
  });
});

The pattern generalizes: anywhere a component reads isPending from useActionState or useFormStatus, your test needs an await waitFor(...) or findBy* query instead of a synchronous getBy* assertion right after firing the event. This isn't really new — it's the same discipline any test for an async fetch call needed — but Actions make it easy to forget because the trigger (a form submit) looks synchronous at the call site.

Testing Suspense boundaries and use()

Components that call use() on a promise suspend during render, which means a test that renders one directly needs a Suspense boundary somewhere above it, exactly like production code. If your test renders the inner component in isolation without wrapping it, you'll get an error about a promise being thrown during render instead of a clean loading-then-content assertion.

TSX · TESTING A SUSPENDED COMPONENT
import { Suspense } from 'react';
import { render, screen } from '@testing-library/react';
import { ProfileCard } from './ProfileCard';

test('renders the user name once the promise resolves', async () => {
  const userPromise = Promise.resolve({ name: 'Jane' });

  render(
    <Suspense fallback="Loading…">
      <ProfileCard userPromise={userPromise} />
    </Suspense>
  );

  expect(screen.getByText('Loading…')).toBeInTheDocument();
  expect(await screen.findByText('Jane')).toBeInTheDocument();
});

Note the mix: a synchronous getByText for the fallback (it's there immediately), then an awaited findByText for the resolved content. If the component can also suspend on a rejected promise, wrap it in an error boundary in the test too — otherwise the test runner reports an uncaught render error instead of a clean assertion failure.

Practical steps for upgrading from React 18

In order: bump react and react-dom to 19, bump @testing-library/react to a version that declares React 19 support, run the suite and triage failures into two buckets — act() warnings (usually a stale RTL version or a leftover manual act-environment flag) and pending-state timing failures (usually missing await waitFor/`findBy*` around an Action or a suspended component). Don't try to silence act() warnings by wrapping everything in a blanket act(async () => {...}); it papers over exactly the timing bugs you want the warning to catch.

Mock the promise, not the network layer, for use() tests

For components using use(), it's simpler to pass a pre-resolved or pre-rejected promise straight into the component under test than to mock fetch and hope the timing works out. It also matches how the component receives the promise in production — as a prop, not something it constructs itself.

Wrapping up

None of this requires rewriting your test suite. It requires updating one dependency (React Testing Library) and adding await in the handful of places where a click now triggers a pending state instead of an instant re-render. The tests that were already disciplined about async assertions — using findBy* instead of getBy* after anything that touches the network — mostly pass unchanged. The ones that assumed everything in React was synchronous are the ones that need the fixes above.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.