Home/Learn/Next.js/Testing a Next Application

Testing a Next Application

Advanced
Production

Async server components do not fit unit test runners well. The practical answer is fewer unit tests, more integration tests, and Playwright for anything that involves the server.

Overview

The App Router made testing simpler in one way and harder in another. Simpler, because most logic moved into plain async functions that are trivial to test. Harder, because an async server component is not something Testing Library can render — support is experimental, and the workarounds are usually worse than the alternative. So the shape that works is: unit-test the data functions and pure logic directly, component-test the client components as usual, and use Playwright for anything where the server actually has to run.

What Each Tool Covers

The split, and where the boundary falls.

Unit the functions, component the client, E2E the server
// UNIT — plain functions. The easiest and highest-value tests, and
// the App Router pushes more logic here than the Pages Router did.
import { calculateReadiness } from '@/lib/scoring'
expect(calculateReadiness(attempts)).toEqual({ score: 26 })

// Data functions with the database mocked, or against a test DB:
vi.mock('@/lib/db')
expect(await getProblemsForUser()).toHaveLength(3)

// COMPONENT (Vitest + Testing Library) — client components only
import { render, screen } from '@testing-library/react'
render(<ProblemFilters onChange={fn} />)         // 'use client'

// Async SERVER components do not render in Testing Library. You can
// await the function and inspect the returned element, but you are
// then testing an object, not behaviour:
const el = await ProblemsPage({ params: Promise.resolve({ slug: 'x' }) })
// Possible; rarely worth it.

// E2E (Playwright) — anything involving the server: server
// components, server actions, middleware, auth, redirects, caching.
// This is where the App Router's real behaviour is testable at all.

Playwright Against a Real Build

The setup that actually reflects production.

build && start, and assert that revalidation happened
// playwright.config.ts
export default defineConfig({
  webServer: {
    command: 'npm run build && npm start',      // NOT npm run dev
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
  use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry' },
})
// Testing against dev tests a different application: no prerendering,
// no route cache, different error output.

test('publishing a problem shows it in the list', async ({ page }) => {
  await page.goto('/admin/problems/new')
  await page.getByLabel('Title').fill('Two Sum')
  await page.getByRole('button', { name: 'Publish' }).click()

  await expect(page.getByRole('heading', { name: 'Two Sum' })).toBeVisible()
  await page.getByRole('link', { name: 'All problems' }).click()
  await expect(page.getByText('Two Sum')).toBeVisible()   // revalidation worked
})
// That last assertion is the one unit tests structurally cannot make:
// it proves revalidatePath actually invalidated the list.

// Sign in once and reuse the storage state, rather than logging in
// per test:
use: { storageState: 'playwright/.auth/user.json' }

What Is Worth Testing Here

The framework-specific behaviours that break silently.

Auth, redirects, revalidation, status codes, metadata
// Worth an E2E test, because nothing else catches them:
//   a server action's authorisation — call it as the wrong user
//   redirects and guards — visit a protected route signed out
//   revalidation — mutate, then check the list updated
//   a 404 route returning a real 404 status
//   metadata — assert the title and canonical on a dynamic page
await expect(page).toHaveTitle(/Two Sum/)

// Testing a server action directly is possible and useful for the
// authorisation checks, since it is just an async function:
await expect(deleteProblem('id-not-mine')).rejects.toThrow('Forbidden')

// Do not test:
//   that Next routes correctly — that is the framework's job
//   the exact HTML of a server component
//   caching internals

// MSW for external APIs so tests do not depend on a live backend, and
// a seeded test database rather than production data.

// And keep it in CI on every PR. A suite nobody runs gets deleted the
// first time it goes red.

Key Points to Remember

  • 1Async server components cannot be rendered by Testing Library — test the data functions directly instead
  • 2Run Playwright against a production build, since the dev server prerenders and caches differently
  • 3Only an end-to-end test can prove that revalidation actually invalidated the right pages
  • 4Server actions are plain async functions, so their authorisation checks can be unit tested directly
  • 5Test auth, redirects, revalidation, status codes and metadata — not that the framework routes correctly

Interview Questions

Sign in to ask Aria
1

Why are async server components hard to unit test?

Hard
2

Why should E2E tests run against a build rather than the dev server?

Medium
3

How would you test that a mutation invalidated the right cached pages?

Hard

Ask Aria about Testing a Next Application

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…