+1 (781) 556-6029

Testing a React app in 2026: Vitest, Testing Library, and the Server Component gap

Test suites are the part of a React codebase that ages worst. The application moves to hooks, then to TypeScript, then to the App Router; the tests stay on the runner and the assertions someone chose in 2019. By the time we see them in a review they are slow enough that engineers skip them locally, brittle enough that a third of failures are false, and — since Server Components arrived — silent about a growing share of the rendering code.

This is the strategy we use when a client asks what to do about it. It is not a testing philosophy. It is an allocation problem: which layer answers which question, at what cost per run.

First, measure what the suite actually costs

Before changing runners or writing new tests, get three numbers.

  1. Wall-clock time of the full suite in CI, and separately on a developer laptop. The laptop number is the one that decides whether anybody runs tests before pushing.
  2. Flake rate: over the last hundred CI runs, how many failed and then passed on a retry with no code change. Anything above a few percent means your team has already learned to re-run red builds instead of reading them, which makes the suite worse than useless as a signal.
  3. What breaks when you change an implementation detail. Rename a prop or swap a component's internal state library on a branch and count the failing tests. Tests that fail on a refactor that changes no behavior are measuring the wrong thing.

Those numbers tell you whether you have a runner problem, an assertion-style problem, or a coverage-shape problem. They are usually not the same problem, and the fixes are independent.

The runner: Jest to Vitest, and when not to bother

Most React suites we meet still run on Jest with babel-jest or ts-jest. The common complaint is speed, and the common cause is transform cost: every test file, and everything it imports, is compiled by Babel or the TypeScript compiler on the way in. Vitest uses the same transform pipeline as your dev server (esbuild via Vite) and reuses the module graph, so the compile step mostly disappears and watch-mode reruns hit only the affected files.

The migration is smaller than it looks for the common case, because Vitest's API is deliberately Jest-shaped. describe, it, expect, and the Testing Library matchers work unchanged. What actually costs time:

  • Mocks. jest.mock becomes vi.mock, and the hoisting rules differ enough to matter. Vitest hoists vi.mock calls too, but factory functions cannot close over outer variables, so patterns that relied on a mutable mock defined above the call need vi.hoisted. This is where most of the migration diff lives.
  • Globals. Vitest does not inject globals unless you set globals: true. Setting it is the low-friction path for a large existing suite; importing { describe, it, expect } explicitly is cleaner for new code. Pick one and do not mix them file by file.
  • Environment. jsdom still works, or happy-dom if you want the speed and can accept narrower API coverage. Test both — a suite that depends on obscure DOM behavior will find the edges of happy-dom within an hour.
  • Timers and modules. jest.useFakeTimers maps to vi.useFakeTimers, but the default set of faked APIs is not identical, and anything relying on jest.requireActual needs importActual with async handling.

Run the two runners side by side on CI for a week before deleting the Jest config. Not because Vitest is unreliable, but because a migration that changes twelve hundred test files should be able to show that the same tests still fail on the same broken code. If your suite is a few hundred fast tests and nobody complains about it, the migration is not worth the week. "Measure first" applies to tooling as much as to rendering.

The assertions: test the behavior a user can observe

Runner speed is worth nothing if the tests are brittle. The recurring pattern in suites that fail on refactors:

// brittle: couples the test to markup and internals
const { container } = render(<CheckoutForm />);
expect(container.querySelector('.submit-btn')).toBeDisabled();
// durable: the same assertion a user would make
render(<CheckoutForm />);
expect(screen.getByRole('button', { name: /place order/i })).toBeDisabled();

The second version survives a class rename, a styling library swap, and a move from a <button> wrapper to a primitive from your design system. It also fails if the button loses its accessible name — which is a real bug that the first version cannot see. Query by role and name first, by label text for form controls, by getByTestId only where there is genuinely no accessible handle.

Two further rules that remove most of the remaining flake:

Use userEvent, not fireEvent. fireEvent.click dispatches one event. A real click produces pointer events, focus changes, and — on a disabled element — nothing at all. userEvent models the sequence, which means tests catch the focus and keyboard bugs that fireEvent walks straight past.

Never assert after an arbitrary wait. await waitFor(() => expect(...)) and findBy* queries retry until the assertion passes or the timeout expires. A setTimeout(500) in a test is a flake with a delay fuse; it will fire on the one CI runner that is under load.

For anything with network behavior, intercept at the network boundary with MSW rather than mocking your data-fetching hook. Mocking the hook tests that you called your own abstraction. Intercepting the request tests that the component handles a 500, an empty list, and a slow response — the three cases that actually reach production.

The gap: Server Components

Here is the part teams get wrong by omission. An async Server Component cannot be rendered by React Testing Library today. render() expects a client-side render; an async function component returns a promise, and the supported path for unit-testing it does not exist yet. The React team has said as much, and the practical guidance from the Next.js side is the same: use end-to-end tests for Server Components.

So the coverage shape changes, and it should change deliberately:

Push logic out of the Server Component. Data access, authorization checks, and shape transformation belong in plain async functions that the component calls. Those functions are ordinary units — test them in Vitest with no React involved at all. What remains in the component is composition, which is cheap to cover elsewhere. This is the single highest-value change, and it is good architecture independent of testing.

Keep Client Components unit-testable. A 'use client' component is a normal React component and tests exactly as before. If your interactive logic is concentrated behind client boundaries — which it should be for other reasons — most of your behavioral coverage stays in the fast layer.

Cover the composed page with Playwright. One end-to-end test per critical route, run against a production build, asserting the rendered output and the primary interaction. These are slow and you will not have many, so choose them by consequence: checkout, auth, the flow that generates revenue. A Playwright test against a real build also catches the class of failure unit tests structurally cannot — a Server Component that throws during streaming, a serialization error crossing the client boundary, a hydration mismatch.

Put a serialization test where the boundary is. Props crossing from a Server to a Client Component must be serializable. A small test that imports the server module and asserts the shape it passes down will catch the accidental Date, Map, or function before a reviewer does.

If someone proposes a library that promises unit-testing for async Server Components, read what it does before adopting it. The workable ones render in an environment that approximates a server request; they are an improvement on nothing, but they are not the same thing as the framework's own render, and a passing test in them is weaker evidence than a Playwright run.

What the allocation looks like

For a mid-sized product app, the shape we recommend:

  • Many fast unit tests over pure functions: data transforms, validation, formatting, reducers, server-side data helpers. Milliseconds each, no DOM.
  • A solid layer of Testing Library tests over Client Components and hooks, with MSW at the network boundary. This is where most of the behavioral value sits.
  • A deliberate few Playwright tests over composed routes, including every Server Component page that matters, run against a production build in CI.
  • Optionally, a visual-regression or accessibility scan step if design regressions or WCAG obligations are a recurring cost for you. Add it because you have evidence of that cost, not because the tooling exists.

What we do not recommend is a coverage percentage target. Coverage tells you which lines executed, not whether any assertion was meaningful, and teams under a coverage gate reliably produce tests that execute code and assert nothing.

Where to start on Monday

Do not open a testing epic. Take the flake rate you measured, find the five tests responsible for most of it, and fix or delete them — a suite people trust is worth more than a suite that is large. Then, the next time a Server Component page changes, add the Playwright test for that route as part of the same pull request. Then, if the runner numbers justify it, do the Vitest migration on its own branch with both runners green.

Three separate changes, each independently shippable, each with a number you can point at afterwards. If the numbers do not move, you have learned something about where your real risk is, which is also worth the week.