Back/Module P-8 Testing Next.js Applications
Module P-8·24 min read

Testing Server Components that call cookies()/headers() outside request context, AsyncLocalStorage-aware test wrappers, mocking the Data Cache layer for deterministic tests, testing optimistic UI race conditions, E2E with Playwright, and the five test patterns that catch real production bugs.

P-8 — Testing Next.js Applications

Who this is for: Engineers who write tests for regular React apps but find that Server Components, Route Handlers, and Server Actions break their existing test setup in confusing ways. This module covers the correct testing strategy for each piece of the Next.js App Router, from unit tests that actually work to E2E flows that catch what unit tests miss.


Why Next.js Testing Is Harder Than You'd Expect

Testing a plain React component is straightforward. Render it, fire some events, assert on the output. The mental model is simple because components are synchronous functions that return JSX.

Server Components break this model in three ways. First, they are async functions — async function Page() — and React Testing Library's render() doesn't handle async component trees without extra ceremony. Second, they use server-only APIs: cookies(), headers(), redirect(), and notFound() from next/headers and next/navigation. These throw at import time in a Jest environment unless you mock them. Third, modules that contain 'use server' or import from next/server often fail to load in Jest's Node.js environment because they reference APIs that only exist in a Next.js server context.

The first instinct is to mock everything aggressively. That instinct is right for unit tests. But it also means your unit tests are testing your component in isolation from the actual Next.js runtime, which is why a passing unit test suite and a broken production app can coexist. You need both unit tests and E2E tests, and knowing which to use where is the main skill this module builds.


Unit Testing Server Components

The correct approach for testing an async Server Component with Jest and React Testing Library is to await the component before rendering:

tsx
// app/blog/[slug]/page.test.tsx import { render, screen } from '@testing-library/react' import BlogPage from './page' jest.mock('@/lib/posts', () => ({ getPost: jest.fn().mockResolvedValue({ title: 'Hello World', content: '<p>Some content</p>', author: { name: 'Alice' }, }), })) it('renders the post title', async () => { const component = await BlogPage({ params: Promise.resolve({ slug: 'hello-world' }) }) render(component) expect(screen.getByRole('heading', { name: 'Hello World' })).toBeInTheDocument() })

The key insight: instead of passing <BlogPage /> to render(), you call BlogPage() directly as an async function, await it to get the resolved JSX, and then pass that JSX to render(). This sidesteps the async component rendering problem entirely.

It feels slightly wrong — you're not rendering the component in the traditional sense — but it works and it correctly exercises your component's logic. The alternative, patching React's internals to handle async components in tests, is fragile and constantly breaks across React versions.


Mocking next/navigation

Any component that calls useRouter(), usePathname(), or useSearchParams() will fail in tests without a mock. The standard pattern:

ts
// __mocks__/next/navigation.ts (or inside jest.mock in each test file) jest.mock('next/navigation', () => ({ useRouter: jest.fn(() => ({ push: jest.fn(), replace: jest.fn(), back: jest.fn(), prefetch: jest.fn(), pathname: '/', })), usePathname: jest.fn(() => '/'), useSearchParams: jest.fn(() => new URLSearchParams()), useParams: jest.fn(() => ({})), redirect: jest.fn(), notFound: jest.fn(), }))

Put this in a jest.setup.ts file and import it in your Jest config under setupFilesAfterFramework, and every test gets it automatically. If you need different behavior per test, you can reach into the mock: (usePathname as jest.Mock).mockReturnValue('/dashboard').


Mocking next/headers

cookies() and headers() from next/headers work similarly. They throw if called outside a Next.js request context, so they need mocks in tests:

ts
jest.mock('next/headers', () => ({ cookies: jest.fn(() => ({ get: jest.fn((name: string) => ({ name, value: '' })), set: jest.fn(), delete: jest.fn(), has: jest.fn(() => false), getAll: jest.fn(() => []), })), headers: jest.fn(() => new Headers()), }))

For tests that care about specific cookie values, override in the individual test:

ts
const { cookies } = jest.requireMock('next/headers') cookies.mockReturnValue({ get: (name: string) => name === 'session' ? { value: 'abc123' } : undefined, })

Testing Route Handlers

Route Handlers in Next.js 15 are just functions that take a Request and return a Response. You can test them without a running server by constructing a NextRequest and calling the handler directly:

ts
// app/api/users/route.test.ts import { GET, POST } from './route' import { NextRequest } from 'next/server' it('returns a list of users', async () => { const req = new NextRequest('http://localhost/api/users') const res = await GET(req) const data = await res.json() expect(res.status).toBe(200) expect(data).toHaveProperty('users') }) it('creates a user', async () => { const req = new NextRequest('http://localhost/api/users', { method: 'POST', body: JSON.stringify({ name: 'Bob', email: 'bob@example.com' }), headers: { 'Content-Type': 'application/json' }, }) const res = await POST(req) expect(res.status).toBe(201) })

This is clean and fast. No supertest, no server setup, no port management. The handler is a pure async function and you test it like one. Mock your data layer with jest.mock and you can test all the edge cases — malformed input, missing fields, database errors — without touching a real database.


Testing Server Actions

Server Actions are async functions. In tests, you call them as async functions. That's it:

ts
// app/actions/createPost.test.ts import { createPost } from './createPost' jest.mock('@/lib/db', () => ({ db: { insert: jest.fn().mockResolvedValue({ id: '123' }), }, })) it('creates a post and returns the id', async () => { const result = await createPost({ title: 'Test Post', content: 'Some content', }) expect(result.id).toBe('123') }) it('validates required fields', async () => { await expect(createPost({ title: '', content: '' })) .rejects.toThrow('Title is required') })

The 'use server' directive is a build-time transformation, not a runtime behavior. In tests, it's ignored. Your action is a function; test it like one.


Vitest as a Jest Alternative

Vitest is worth knowing about. It's faster than Jest for large TypeScript codebases because it uses Vite's native ESM handling rather than Babel transforms. It has near-identical API surface to Jest, so migration is usually a find-and-replace of jest. to vi.. The config is simpler:

ts
// vitest.config.ts import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import path from 'path' export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], globals: true, }, resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, })

The main tradeoff: Vitest's ecosystem is smaller, and some Jest-specific plugins (like jest-axe for accessibility testing) have Vitest alternatives that are less mature. For greenfield projects it's a strong choice. For projects with an existing Jest setup, the migration cost is real.


E2E Testing with Playwright

Playwright is the right tool for testing complete user flows that involve navigation, cookies, form submissions, and things that cross the server/client boundary. It runs against a real running instance of your app.

ts
// e2e/auth.spec.ts import { test, expect } from '@playwright/test' test('user can log in and see dashboard', async ({ page }) => { await page.goto('/login') await page.fill('[name="email"]', 'alice@example.com') await page.fill('[name="password"]', 'password123') await page.click('[type="submit"]') await expect(page).toHaveURL('/dashboard') await expect(page.getByRole('heading', { name: 'Welcome, Alice' })).toBeVisible() })

For flows that require an authenticated user, create the auth state once and reuse it across tests with storageState:

ts
// e2e/setup/auth.ts (run once in global setup) import { chromium } from '@playwright/test' async function globalSetup() { const browser = await chromium.launch() const page = await browser.newPage() await page.goto('/login') await page.fill('[name="email"]', 'alice@example.com') await page.fill('[name="password"]', 'password123') await page.click('[type="submit"]') await page.waitForURL('/dashboard') await page.context().storageState({ path: 'e2e/.auth/user.json' }) await browser.close() } export default globalSetup

Then reference this state in your test config so authenticated tests skip the login step:

ts
// playwright.config.ts use: { storageState: 'e2e/.auth/user.json', }

Run Playwright against a production build, not the dev server. next build && next start gives you the real runtime behavior — static optimization, caching, route prerendering — that the dev server skips. Bugs that only appear in production builds are often caught this way.


MSW for External API Mocking

If your Server Components or Route Handlers call external APIs, you need those calls to be predictable in tests. MSW (Mock Service Worker) handles this cleanly in both unit and E2E contexts:

ts
// mocks/handlers.ts import { http, HttpResponse } from 'msw' export const handlers = [ http.get('https://api.stripe.com/v1/customers', () => { return HttpResponse.json({ data: [{ id: 'cus_123', email: 'alice@example.com' }] }) }), http.post('https://api.sendgrid.com/v3/mail/send', () => { return new HttpResponse(null, { status: 202 }) }), ]

In Jest, use setupServer from msw/node. In Playwright, use the page.route() API instead — MSW's browser integration is designed for client-side code and doesn't intercept server-side fetch calls in E2E contexts.


The Testing Pyramid for Next.js

The classic testing pyramid (many unit tests, fewer integration tests, few E2E tests) applies to Next.js but with a specific mapping.

Unit tests cover Server Component rendering logic, Server Action validation and business logic, Route Handler request/response handling, utility functions and data transformation, and custom hooks in Client Components. These run fast (milliseconds each) and give you granular failure signals.

E2E tests cover authentication and session flows, checkout and payment flows, multi-step form submissions, navigation behavior and redirects, and anything where the correct behavior requires multiple things working together. These run slowly (seconds each) and give you confidence that the user experience is correct.

The mistake most teams make is trying to write unit tests for things that are only meaningful as E2E tests (like "does the login page actually set a session cookie"), and writing E2E tests for things that should be unit tests (like "does this helper function format a date correctly"). Know which layer each test belongs to before writing it.


Code Coverage

Coverage is useful as a hygiene metric, not as a goal. A 100% covered codebase can still have catastrophic bugs if the tests are testing the wrong things.

With Jest, configure c8 or istanbul in your jest.config.ts:

ts
collectCoverageFrom: [ 'src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/*.stories.tsx', '!src/app/**/(page|layout|loading|error).tsx', // framework files ], coverageThreshold: { global: { branches: 70, functions: 80, lines: 80, }, },

Exclude framework entry points like page.tsx and layout.tsx from coverage reporting — they're often thin wrappers that are better tested via E2E. Focus coverage requirements on business logic modules: data fetchers, action handlers, validation utilities.

The engineers who build reliable Next.js applications don't write the most tests — they write the right tests for the right layer of the stack. That distinction is worth more than any coverage percentage.


Testing Server Components That Use Dynamic APIs

Here's the problem the testing docs skip entirely: cookies(), headers(), auth(), and connection() throw when called outside a request context. Your Server Component calls cookies(). Your test runs outside a request. The test crashes with:

Error: cookies() was called outside a request scope.

This is because Next.js uses AsyncLocalStorage internally to provide request-scoped values. There is no request in a Jest test environment — so the storage is empty, and the call throws.

The fix: mock the entire module.

ts
// __tests__/ProductPage.test.tsx import { render } from '@testing-library/react' import { ProductPage } from '@/app/products/[id]/page' // Mock next/headers before anything else jest.mock('next/headers', () => ({ cookies: jest.fn(() => ({ get: jest.fn((name) => { if (name === 'session') return { value: 'test-session-token' } return undefined }), getAll: jest.fn(() => []), has: jest.fn(() => false), })), headers: jest.fn(() => new Headers({ 'x-forwarded-for': '127.0.0.1', 'user-agent': 'jest-test', })), })) jest.mock('next/navigation', () => ({ notFound: jest.fn(() => { throw new Error('NOT_FOUND') }), redirect: jest.fn((url) => { throw new Error(`REDIRECT:${url}`) }), useRouter: jest.fn(() => ({ push: jest.fn(), replace: jest.fn() })), usePathname: jest.fn(() => '/products/123'), useSearchParams: jest.fn(() => new URLSearchParams()), })) it('renders product details', async () => { const jsx = await ProductPage({ params: Promise.resolve({ id: '123' }) }) const { getByText } = render(jsx) expect(getByText('Product 123')).toBeInTheDocument() })

The pattern: mock next/headers and next/navigation at the top of every Server Component test file, before any imports that might trigger the module. The mock returns a controlled, predictable shape.

For auth() from Auth.js, mock the entire auth module:

ts
jest.mock('@/lib/auth', () => ({ auth: jest.fn().mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com', role: 'user' }, expires: new Date(Date.now() + 3600000).toISOString(), }), }))

This gives every test in the file an authenticated session by default. Override per-test where needed:

ts
it('redirects unauthenticated users', async () => { const { auth } = require('@/lib/auth') auth.mockResolvedValueOnce(null) // unauthenticated for this test only await expect(DashboardPage()).rejects.toThrow('REDIRECT:/login') })

Mocking the Data Cache Layer

Tests that hit the real Data Cache produce non-deterministic results. The first test run warms the cache; subsequent runs hit the cache and don't exercise the data fetching logic. If the cache is populated from a previous test, your mock fetch function doesn't get called and your assertion on it fails.

The fix: mock unstable_cache and cache() to be pass-throughs in tests.

ts
// jest.setup.ts (runs before every test file) jest.mock('next/cache', () => ({ unstable_cache: jest.fn((fn) => fn), // returns the function directly, no caching revalidatePath: jest.fn(), revalidateTag: jest.fn(), }))

For the 'use cache' directive (Next.js 15+), the cache is transparent to Jest since it's a compiler transform — it just works as a normal async function in test environments. You don't need to mock it.

For React.cache() (request-level memoisation), the memoisation only applies within a single request context. In Jest, each test is its own context, so memoisation doesn't persist between tests. No action needed.

Testing Optimistic UI and Race Conditions

useOptimistic is the hard one to test because it requires triggering both the optimistic update and the async Server Action, then verifying the transition between the two states.

ts
// __tests__/LikeButton.test.tsx import { render, screen, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { LikeButton } from '@/components/LikeButton' // Mock the Server Action jest.mock('@/actions/posts', () => ({ likePost: jest.fn(), })) it('shows optimistic like count immediately, confirms after action', async () => { const { likePost } = require('@/actions/posts') // Action takes 100ms to resolve likePost.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ likes: 43 }), 100)) ) render(<LikeButton postId="post-1" initialLikes={42} />) expect(screen.getByText('42 likes')).toBeInTheDocument() // Click — optimistic update should be immediate await userEvent.click(screen.getByRole('button', { name: /like/i })) // Before the action resolves, optimistic count should show expect(screen.getByText('43 likes')).toBeInTheDocument() // After action resolves, count should still be 43 (confirmed) await act(async () => { await new Promise(resolve => setTimeout(resolve, 150)) }) expect(screen.getByText('43 likes')).toBeInTheDocument() expect(likePost).toHaveBeenCalledWith('post-1') }) it('rolls back optimistic update on action failure', async () => { const { likePost } = require('@/actions/posts') likePost.mockRejectedValueOnce(new Error('Network error')) render(<LikeButton postId="post-1" initialLikes={42} />) await userEvent.click(screen.getByRole('button', { name: /like/i })) expect(screen.getByText('43 likes')).toBeInTheDocument() // optimistic await act(async () => { await new Promise(resolve => setTimeout(resolve, 50)) }) expect(screen.getByText('42 likes')).toBeInTheDocument() // rolled back })

The rollback test is the critical one. Optimistic UI that doesn't roll back on error corrupts the UI state permanently. Test it explicitly.

Testing Route Handlers

Route Handlers take a NextRequest and return a Response. They're plain async functions — no framework magic required to test them.

ts
// __tests__/api/products.test.ts import { GET, POST } from '@/app/api/products/route' import { NextRequest } from 'next/server' it('GET returns paginated products', async () => { const req = new NextRequest('http://localhost:3000/api/products?page=2&limit=10') const response = await GET(req) expect(response.status).toBe(200) const body = await response.json() expect(body).toHaveProperty('data') expect(body.data).toHaveLength(10) expect(body).toHaveProperty('nextCursor') }) it('POST validates request body', async () => { const req = new NextRequest('http://localhost:3000/api/products', { method: 'POST', body: JSON.stringify({ name: '' }), // invalid — name too short headers: { 'Content-Type': 'application/json' }, }) const response = await POST(req) expect(response.status).toBe(422) const body = await response.json() expect(body.errors).toHaveProperty('name') }) it('POST rejects unauthenticated requests', async () => { // auth mock returns null for this test const { auth } = require('@/lib/auth') auth.mockResolvedValueOnce(null) const req = new NextRequest('http://localhost:3000/api/products', { method: 'POST', body: JSON.stringify({ name: 'New Product' }), headers: { 'Content-Type': 'application/json' }, }) const response = await POST(req) expect(response.status).toBe(401) })

NextRequest is a real class you can instantiate directly. No HTTP server needed. The test runs in milliseconds.

The Five Tests That Catch Real Production Bugs

After shipping and debugging enough Next.js applications in production, these are the test cases that have caught actual incidents — the ones most teams skip:

1. The unauthenticated route access test. For every protected page or Route Handler, write a test that verifies it returns 401/403 when auth() returns null. Authentication bugs ship because developers test the happy path (logged in) and forget the sad path.

2. The missing generateStaticParams entry test. For dynamic routes with generateStaticParams, write a test that calls the page with a slug that is NOT in the generated list. Verify notFound() fires. Production 500 errors happen when a new route param arrives (a new product, a new user) that wasn't pre-rendered and the fallback handling is wrong.

3. The Server Action validation test. For every Server Action, write a test that sends malformed input — missing required fields, oversized strings, wrong types. Verify the action returns a validation error, not a 500. Unvalidated Server Action inputs cause database errors that bubble up as ugly 500s.

4. The cache invalidation test. After a mutation (Server Action or Route Handler that calls revalidatePath), write a test that verifies revalidatePath was called with the correct path. Not testing this is how stale caches ship — the mutation succeeds but the UI never updates.

5. The redirect-on-unauthenticated test. For pages that redirect unauthenticated users, write a test that verifies the redirect destination is correct and the redirect is a 307 (temporary, preserves method) not a 301 (permanent, cached by browsers forever). A 301 redirect to /login is cached by browsers — if you change the auth flow later, users with the cached redirect can't access the new login flow.

These five tests don't catch every bug. They catch the category of bugs that cause 3am incidents.

Discussion