Testing the Next.js App Router: Field Notes on Server Components, Mocking fetch(), and Where Playwright Takes Over
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Key takeaways
- React Testing Library can't render an async Server Component directly: RTL mounts through react-dom, and Server Components only run through the RSC renderer, which serializes a stream RTL has no code path for.
- Vitest runs under Node's default module resolution, not the react-server condition Next.js uses to swap in server-only modules — importing a Server Component that touches headers(), cookies(), or the server-only package into a test file throws or silently resolves the wrong module.
- The setup we settled on: extract data fetching and business logic into plain exported async functions, unit-test those with Vitest and Mock Service Worker (msw), and leave the Server Component itself as thin wiring.
- A 'use server' Server Action is a plain exported async function once compiled — we call it directly in a Vitest test with a constructed FormData, no browser required.
- Playwright isn't optional for routes with loading.tsx or error.tsx boundaries: streaming order and Suspense fallback timing only exist once Next's real RSC runtime renders the route end to end.
Why can't we render a Next.js Server Component with React Testing Library?
Because React Testing Library's render() calls into react-dom, and a Server Component is never executed by react-dom — it's executed by the RSC renderer (react-server-dom-webpack under the hood), which serializes a component tree into a stream that RTL has no code path for. An async function component that awaits a database call or fetch() also isn't something render() is built to await; RTL expects a component that returns synchronously on its first pass.
At Devya we hit the same wall the whole team eventually hits: Vitest runs under Node's default module resolution, not the react-server condition Next.js uses to swap in server-only modules like next/headers or anything wrapped in the server-only package. A test that imports a Server Component either throws on the guarded import or silently resolves the wrong module — the second failure mode is worse, because the test stays green against code that would crash in production.
How do we actually unit test a Server Component's data fetching?
We pull data fetching and transforms out of the component into plain exported async functions, then test those functions directly. Nothing about a function like getInvoice(id) needs the RSC renderer once it's separated from JSX — it becomes an ordinary async function Vitest can import and await like any other module. The page component itself gets left thin enough that we stop unit-testing it at all; that coverage moves to Playwright.
Should we mock fetch() directly or intercept it with MSW?
We intercept with Mock Service Worker (msw) rather than replacing the fetch function itself. Mocking at the network boundary survives refactors that change how a function calls fetch, while a vi.fn() stub breaks the moment an argument order changes or the client switches. We set msw's onUnhandledRequest to 'error', which fails a test the moment code makes a real network call we forgot to mock, instead of quietly hitting a live API. For a single one-off call, vi.stubGlobal('fetch', vi.fn()) is still fine — we reach for msw once more than one function in a file calls fetch.
Do we need a browser to test a Server Action?
No. A 'use server' function compiles down to a plain exported async function, so a Vitest test can import it and call it directly with a FormData object it constructs itself. The part that trips people up is that redirect() and revalidatePath() both throw when called outside an actual request — mocking next/navigation and next/cache in the test turns a request-scoped function into something plain enough for Node to execute directly.
When does Playwright replace unit tests entirely?
For any behavior that only exists once Next's real RSC runtime streams a route to a browser: Suspense fallback order from loading.tsx, error.tsx boundaries catching a thrown error, redirects that depend on cookies or headers, and the actual HTML a crawler or an LLM answer engine would see. None of that is observable from a unit test, because unit tests never run the streaming renderer. We use Playwright's page.route() with an artificial delay to make streaming testable at all — without it, a fast local mock resolves before a loading skeleton ever gets a frame to paint.
What does a practical App Router test pyramid look like?
Unit tests with Vitest cover extracted data functions, transforms, Client Components, and Server Action logic. An integration layer with Vitest plus msw covers a function's behavior against a realistic but mocked network. End-to-end tests with Playwright cover streaming order, Suspense and error boundaries, redirects, auth-gated routes, and the real HTML output — we never skip this layer for an App Router page that matters, because it's the only layer that runs the real renderer.
The layer we dropped almost entirely is the one most tutorials center on: mounting a Server Component in something jsdom-shaped and asserting on its output. It's the layer Next's own architecture makes unreliable, so we stopped fighting it and moved that coverage to Playwright instead.
FAQ
Q: Can we use React Testing Library to render a Next.js Server Component?
A: Not directly. RTL's render() executes through react-dom, and a Server Component only runs through the RSC renderer, so async components that call headers(), cookies(), or await a database will throw or hang under RTL. Extract the logic into plain functions or cover the component with Playwright instead.
Q: How do we mock fetch() in a Next.js test?
A: We use Mock Service Worker's msw/node setupServer to intercept requests at the network layer, or vi.stubGlobal('fetch', vi.fn()) for a single-call stub inside a small test. Intercepting at the network boundary survives refactors better than mocking the function that calls fetch.
Q: Do we need a browser to test a Next.js Server Action?
A: No. A 'use server' function is a plain exported async function once compiled, so we import it into a Vitest test and call it directly with a constructed FormData — mocking next/navigation and next/cache handles any redirect() or revalidatePath() call inside it.
Q: Why does Vitest fail on server-only imports that work fine in the app?
A: Vitest resolves modules through Node's default conditions, not the react-server condition Next.js uses to separate server and client graphs, so packages guarded by the server-only package can throw or resolve incorrectly under plain Vitest. We keep server-only logic in files we test directly instead of importing it through a Server Component.
Q: Should we still write Playwright tests if unit test coverage is high?
A: Yes, for any route with a loading.tsx or error.tsx boundary, streaming order, or auth-gated redirect — that behavior only exists once Next's real RSC runtime renders the route, and no unit test executes that runtime.
Further Reading
Developer Tooling
Turborepo in a Real Monorepo: Field Notes on Cache Misses, NEXT_PUBLIC Poisoning, and the tasks Key That Replaced pipeline
Turborepo is a task runner that hashes each script's inputs and replays cached outputs instead of re-running the work. We moved a two-app monorepo onto Turborepo 2 — here is why the cache never hit, why a cache hit once shipped staging URLs to production, and why --affected silently builds everything on a shallow CI clone.
Developer Tooling
Testing Without Jest: How We Use the Native Node.js Test Runner
Node.js ships a built-in test runner that needs no dependencies and no config. How our team uses node:test, node:assert, and built-in mocking, runs TypeScript tests with no build step, and where we still keep Vitest.