Test Strategy in the Next.js App Router Era: Development Confidence Backed by Jest, RTL, and Playwright
Introduction
Why the “Next.js App Router + Testing” setup matters now
The “App Router” introduced in Next.js 13 and later brought major changes to directory structure and design philosophy compared to the previous pages-directory-based routing. With the introduction of layout.tsx, template.tsx, and server components, application development gained flexibility and performance, but at the same time many people started asking, “How are we supposed to write tests now?”
In particular, many feel that “the old way no longer works” when it comes to:
- How to mock components that use hooks from
next/navigation - How to think about component separation and test units specific to the
app/directory structure - Rethinking test strategy when
server componentsandclient componentsare mixed
Who this article is for
This blog post is written for people like:
- Those developing with the Next.js App Router but struggling with how to structure tests
- Those who have managed to introduce React Testing Library and Jest but feel uneasy about day-to-day usage
- Those searching for best practices for integration tests and mocking
- Those who want to establish a future-proof test strategy as a team
In this article, we’ll introduce, in a hands-on way, how to structure unit and integration tests in a Next.js App Router environment, common pitfalls, and mocking techniques.
We’ll carefully walk through things step by step with the goal that if you at least base your setup on this structure, you’ll be reasonably safe for now. I hope you’ll stick with me to the end.
Goal of this article
In this blog post, for a project using the Next.js App Router structure, we’ll introduce how to structure and implement tests across three layers:
- Unit tests (Jest × React Testing Library)
- Integration tests (page-level behavior checks)
- E2E tests (actual browser operations with Playwright)
In particular, through concrete examples of how to write tests and mocks that handle App Router–specific constructs (layout.tsx, template.tsx, Server Actions, etc.), we aim to help you move past the “I want to write tests but can’t take the first step” stage.
By the time you finish reading, you should be able to integrate tests into your Next.js app and independently build a setup that lets you develop and refactor with confidence.
The code implemented in this article is stored in the following repository, so please refer to it alongside the article.
Basic structure and assumptions
When combining the Next.js App Router with Jest / React Testing Library to build your test setup, the first design perspectives you should pin down are “what scope to test” and “at what unit to split things.”
The App Router’s characteristic structure is page-level component separation (layout.tsx, template.tsx, page.tsx), which also makes it naturally easy to separate unit tests from integration tests.
Tech stack used
In this article, we’ll proceed with the following setup:
| Category | Library | Purpose |
|---|---|---|
| Framework | Next.js (App Router) | Routing / rendering |
| Testing | Jest | Test runner |
| Testing | React Testing Library | Behavioral testing of components |
| Helper | @testing-library/jest-dom | Extended DOM assertions |
| Helper (as needed) | MSW / next-auth, react-query, etc. | Mock APIs / state management helpers |
We’ll use SWC as the bundler (Next.js default settings).
Why Jest + React Testing Library?
Even with the App Router structure, React Testing Library’s philosophy of “testing behavior first” matches extremely well.
- Accessible selectors like
getByRole,getByText,getByLabelText - Strong integration with Jest’s mocking features (mocking
next/navigation,next-auth, etc.) - Easy verification of DOM state and component output
This makes it very natural for developers to confirm “how the UI should behave” from a user’s perspective, which is the biggest appeal.
Test file layout patterns in the App Router
First, based on the following assumptions, we’ll look at ideal ways to place test files depending on the situation.
components/→ Reusable UI parts (mostly Client Components)app/→ Pages and layouts (including Server Components)tests/or__tests__/→ Can be split out for management once the project grows
Pattern ①: Place tests next to each component
components/
├── Button.tsx
├── Button.test.tsx ← ★Placed in the same directory
├── Header.tsx
├── Header.test.tsx
Best suited for:
- Small to medium-sized sets of UI components
- Mostly self-contained
client components - Easy to work with when integrating with Storybook
Pattern ②: Group tests under app/ in __tests__ directories
app/
├── dashboard/
│ ├── page.tsx
│ └── __tests__/
│ └── page.test.tsx ← ★Page-level tests
├── layout.tsx
└── __tests__/
└── layout.test.tsx ← ★Common layout tests
Best suited for:
- Structures that explicitly separate
layout.tsx,page.tsx,template.tsx, etc. - When you want to verify integrated behavior like user navigation and page rendering results
- When you want to vary the granularity of mocks between tests
Pattern ③: Centralize everything under tests/pages and tests/components
tests/
├── components/
│ └── Button.test.tsx
├── pages/
│ └── dashboard.test.tsx
├── layout/
│ └── root-layout.test.tsx
Best suited for:
- When you want a clear separation between individual components and whole pages
- CI setups that scan directories and run tests
- Large-scale applications where directory structures become complex
Summary of test file layout patterns
For personal projects or small teams, a combination of “① + ②” is the easiest to handle and is recommended. This article will also proceed with the “① + ②” combination.
| Test target | Recommended pattern |
|---|---|
| Reusable UI (e.g., Button) | Pattern ① (adjacent) |
| Rendering / behavior of each page | Pattern ② (app subdirectories with __tests__) |
| When you want to organize for CI or team division of work | Pattern ③ (centralized under tests) |
Setting up the test environment (Next.js App Router + Jest + RTL)
There are a few caveats when using Jest and React Testing Library with a Next.js App Router setup. In this section, we’ll carefully organize the steps to set up a minimal working test environment.
Create a Next.js project
npx create-next-app@latest your-app-name
Install the required packages
npm install --save-dev jest @types/jest ts-jest ts-node \
@testing-library/react @testing-library/jest-dom jest-environment-jsdom \
@testing-library/user-event
jest
- Test runner
- Core library that executes tests
- Provides functions like
describe,test,expect - Rich snapshot testing and mocking features!
@types/jest
- TypeScript type definitions for Jest
- Adds types when using
expect()orjest.fn()in TypeScript
ts-jest
- A transpiler that lets Jest handle TypeScript files directly
- Allows Jest to work directly with TypeScript code
@testing-library/react
- Library specialized for testing React behavior
- Provides
render(),screen.getByText(),fireEvent(), etc. - Philosophy: test “how it behaves” rather than “how it looks”
@testing-library/jest-dom
- Adds custom matchers like
toBeInTheDocument(),toHaveClass(), etc. - Enables more expressive assertions
- Virtually essential when using Jest + RTL
jest-environment-jsdom
- Up to Jest v27,
jsdomwas bundled with Jest itself, but it was split out in v28+ - If you’re using Jest v28+ and specify
testEnvironment: 'jsdom', you must explicitly addjest-environment-jsdom
@testing-library/user-event
- Reproduces more realistic user actions (keyboard input, mouse operations, etc.)
- Allows operations closer to real user behavior than
fireEvent()- e.g.,
userEvent.click(),userEvent.type(input, 'text'), etc.
- e.g.,
Summary
| Package | Role |
|---|---|
| jest | Test runner (core test execution) |
| @types/jest | Type definitions for Jest (for TS) |
| ts-jest | Jest transpiler for TypeScript support |
| @testing-library/react | React behavior testing library |
| @testing-library/jest-dom | Additional DOM assertion matchers |
| @testing-library/user-event | Simulation of more realistic user interactions |
Jest config file (jest.config.ts)
import nextJest from 'next/jest'
const createJestConfig = nextJest({
dir: './',
})
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testEnvironment: 'jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1', // tsconfig path support
},
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
}
export default createJestConfig(customJestConfig)
import nextJest from 'next/jest'
- A Jest config helper utility provided officially by Next.js
next/jestprovides a function that generates a jest.config optimized for Next.js
const createJestConfig = nextJest({ dir: './' })
dir: './'specifies the project root directory (wherenext.config.jsandtsconfig.jsonlive)- Explicitly specifying this lets Jest correctly read various Next.js config files
setupFilesAfterEnv
- Specifies setup files that are loaded once before each test
- Common contents: loading @testing-library/jest-dom, MSW setup, etc.
testEnvironment
- Runs tests in a browser-like environment (virtual DOM) instead of pure Node.js
- Essential for testing React components
- For tests that don’t need any DOM at all (e.g., Server Components), you can specify
nodeindividually
moduleNameMapper
- Makes Jest understand path aliases like
@/components/Button - Mirrors the
"paths"setting intsconfig.json
Jest setup file (jest.setup.ts)
import '@testing-library/jest-dom'
This enables convenient custom assertions like toBeInTheDocument().
tsconfig types
If Jest doesn’t recognize the types from @testing-library/jest-dom, configure the following:
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
},
+ "types": ["@testing-library/jest-dom"],
},
Add scripts
{
"name": "next-app-testing-lab",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
+ "test": "jest",
"lint": "next lint"
},
Create test code for a quick check
We’ll create a component and its test code to confirm that the setup for jest and others works correctly.
Create a typical button component:
'use client'
type Props = {
label: string
onClick: () => void
}
export const Button = ({ label, onClick }: Props) => {
return (
<button
type="button"
onClick={onClick}
className="rounded bg-blue-500 px-4 py-2 text-white"
>
{label}
</button>
)
}
And the corresponding test code:
import { render, screen } from '@testing-library/react'
import { Button } from './Button'
test('renders button', () => {
render(<Button label="Hello" onClick={() => {}} />)
expect(screen.getByText('Hello')).toBeInTheDocument()
})
This test checks whether the button component is rendered correctly.
Run the test with the following command:
npm run test
One test ran and finished successfully. Now you can run tests for client components in your Next.js project.
Check the directory structure
We’ve created several files, so here’s the current directory structure for reference:
tree -I node_modules -I .git -I public -I .swc --dirsfirst -a
.
├── app
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components
│ ├── Button.test.tsx
│ └── Button.tsx
├── .gitignore
├── README.md
├── eslint.config.mjs
├── jest.config.ts
├── jest.setup.ts
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── postcss.config.mjs
└── tsconfig.json
Basics of unit testing: Client Components
In a project using the Next.js App Router, the roles of server components and client components are clearly separated. In this section, we’ll introduce the basic way to write unit tests for components with "use client" (i.e., client components) using React Testing Library and Jest.
What does it mean to test a Client Component?
Client components handle browser-side user interactions such as button clicks and form input. Therefore, tests generally focus on:
- Does the component render correctly?
- Are events (clicks, input) handled correctly?
- Does the display change correctly according to state?
Button component
Code under test
'use client'
type Props = {
label: string
onClick: () => void
}
export const Button = ({ label, onClick }: Props) => {
return (
<button
type="button"
onClick={onClick}
className="rounded bg-blue-500 px-4 py-2 text-white"
>
{label}
</button>
)
}
- Specifying
use clientenables event handling likeonClick().
Test code
import { render, screen } from '@testing-library/react'
import { Button } from './Button'
test('Test ①: The label is displayed correctly', () => {
render(<Button label="Click me" onClick={() => {}} />)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
test('Test ②: The handler is called when clicked', () => {
const handleClick = jest.fn()
render(<Button label="Click me" onClick={handleClick} />)
screen.getByRole('button').click()
expect(handleClick).toHaveBeenCalledTimes(1)
})
- Test ①: The label is displayed correctly
- This test verifies that the passed label is displayed on the button.
screen.getByText('Click me')gets the element that has the text “Click me” on the screen.toBeInTheDocument()confirms that the element actually exists in the DOM.
- Test ②: The handler is called when clicked
- This test verifies that the component correctly handles the click event.
jest.fn()creates a mock function whose call count and arguments can be inspected.render()renders the Button with the mock function passed in.screen.getByRole('button')gets the<button>element and callsclick().- Finally,
toHaveBeenCalledTimes(1)verifies thathandleClickwas called once.
Form component
Code under test
'use client'
import { useState } from 'react'
export const SimpleForm = () => {
const [name, setName] = useState('')
const [submitted, setSubmitted] = useState(false)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (name.trim()) {
setSubmitted(true)
}
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Name:</label>
<input
id="name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="submit">Submit</button>
{submitted && <p>Hello, {name}!</p>}
</form>
)
}
- Uses
useStateto hold “entered name” and “whether the form has been submitted” - Uses
e.preventDefault()to prevent the browser’s default page reload - Uses
name.trim()to prevent submission when the field is empty (simple validation) - If valid input is present, sets
submittedtotrueand switches the display
Test code
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SimpleForm } from './SimpleForm'
describe('SimpleForm', () => {
it('Test ①: Entering a name and submitting shows a message', async () => {
render(<SimpleForm />)
const input = screen.getByLabelText('Name:')
const button = screen.getByRole('button', { name: 'Submit' })
await userEvent.type(input, 'Taro')
await userEvent.click(button)
expect(screen.getByText('Hello, Taro!')).toBeInTheDocument()
})
it('Test ②: Submitting an empty field does not show a message', async () => {
render(<SimpleForm />)
const button = screen.getByRole('button', { name: 'Submit' })
await userEvent.click(button)
expect(screen.queryByText(/Hello, /)).not.toBeInTheDocument()
})
})
- Test ①: Entering a name and submitting shows a message
userEvent.type: types “Taro” into the input in a way close to real keyboard inputuserEvent.click: clicks the button (triggers form submission)- After submission,
submittedbecomestrue, and we verify that<p>Hello, Taro!</p>is displayed
- Test ②: Submitting an empty field does not show a message
- Clicks the button without entering anything (empty submission)
/Hello, /is a regex that matches “Hello, 〇〇!”- Verifies that nothing is displayed (i.e., the form submission is not accepted)
Basics of unit testing: Server Actions
Server Actions are a new asynchronous mechanism available in the Next.js App Router. They allow you to pass actions like form submissions or button clicks directly from the client to server functions.
However, since they are just async functions called from components using use client, you can treat them as normal logic in tests.
A (dummy) function registered on the server side
Code under test
export async function submitName(name: string): Promise<string> {
if (!name.trim()) {
throw new Error('Name is empty')
}
// Actual code would perform DB writes or similar here
return `Hello, ${name}!`
}
- Throws an error if the received string is empty.
- Otherwise returns a string starting with “Hello~”.
Test code
/**
* @jest-environment node
*/
import { submitName } from './submitName'
describe('submitName (Server Action)', () => {
it('Returns a message when a valid name is submitted', async () => {
const result = await submitName('Taro')
expect(result).toBe('Hello, Taro!')
})
it('Throws an error when an empty string is submitted', async () => {
await expect(submitName('')).rejects.toThrow('Name is empty')
})
})
@jest-environment noderuns the tests in a Node environment..rejects.toThrow()lets you test exception handling in async functions.
A server-side function that fetches data from an external API
Code under test
export async function fetchPost(postId: number): Promise<string> {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)
if (!res.ok) {
throw new Error('Failed to fetch post')
}
const post = await res.json()
return post.title
}
- Uses JSON Placeholder to fetch a post.
- Throws an error if the fetch fails.
Test code
/**
* @jest-environment node
*/
import { fetchPost } from './fetchPost'
// Mock fetch
global.fetch = jest.fn()
describe('fetchPost (Server Action with external API)', () => {
beforeEach(() => {
jest.resetAllMocks()
})
it('Returns the title when a post ID is specified', async () => {
const mockJson = jest.fn().mockResolvedValue({ id: 1, title: 'mock title' })
;(fetch as jest.Mock).mockResolvedValue({
ok: true,
json: mockJson,
})
const result = await fetchPost(1)
expect(result).toBe('mock title')
expect(fetch).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/posts/1')
})
it('Throws an error when the API response is an error', async () => {
;(fetch as jest.Mock).mockResolvedValue({ ok: false })
await expect(fetchPost(99)).rejects.toThrow('Failed to fetch post')
})
})
| Point | Explanation |
|---|---|
global.fetch = jest.fn() |
Overrides fetch with a Jest (or Vitest) mock |
mockResolvedValue() |
Simulates the resolved value of an async function |
mockJson() |
res.json() is also async, so it needs its own mock |
.rejects.toThrow() |
Safe way to test error cases in async functions |
resetAllMocks() |
Initializes mocks so state doesn’t leak between tests |
Run the tests with:
npm run test
All 4 test files finished successfully.
Integration test setup: App Router and page-level tests
In a Next.js App Router setup, it’s common to manage files like layout.tsx, page.tsx, and template.tsx per route. This clarifies the overall structure of the application, but at the same time many people struggle with “how to design page-level integration tests.”
In this section, we’ll introduce the basic approach to page-level integration tests in an App Router setup and some commonly used mocking patterns.
What is the purpose of integration tests?
While unit tests verify the “behavior of individual components,” integration tests mainly check:
- Whether the page component and multiple child components work together correctly
- Whether display changes involving client components and state management behave as expected
- Whether integrated behavior with routing (
useRouter,useSearchParams) and server actions matches expectations
The goal is to “test that when a user opens a page, the display, state, and behavior all work correctly end-to-end.”
page.tsx
Code under test
We’ll create a new page that assumes a dashboard. This component is a dashboard page that switches its display based on the tab query parameter. Clicking the “Go to settings tab” button changes the URL to /dashboard?tab=settings and updates the display.
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
const TabContent = ({ tab }: { tab: string }) => {
switch (tab) {
case 'home':
return <p>Home tab content</p>
case 'settings':
return <p>Settings tab content</p>
default:
return <p>Unknown tab</p>
}
}
export default function DashboardPage() {
const router = useRouter()
const searchParams = useSearchParams()
const tab = searchParams.get('tab') || 'home'
const goToSettings = () => {
router.push('/dashboard?tab=settings')
}
return (
<main>
<h1>Dashboard</h1>
<button onClick={goToSettings} className='cursor-pointer'>To Settings Tab</button>
<Suspense fallback={<p>Loading...</p>}>
<TabContent tab={tab} />
</Suspense>
</main>
)
}
-
useRouter()- Hook that controls client-side routing
- You can change the URL with
router.push()(without a full page transition)
-
useSearchParams()- Hook that reads the current URL’s query parameters (e.g.,
?tab=home) - Returns
nullif the query doesn’t exist, so we set a default with|| 'home'
- Hook that reads the current URL’s query parameters (e.g.,
-
Structure and role of TabContent
- Switches the display based on the
tabquery parameter - This kind of display switching is a very common pattern when toggling content within the same page
- Switches the display based on the
Test code
const pushMock = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: pushMock,
}),
useSearchParams: () => new URLSearchParams('tab=home'),
}))
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DashboardPage from '../page'
describe('DashboardPage', () => {
it('The home tab is selected on initial display', () => {
render(<DashboardPage />)
expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument()
expect(screen.getByText('Home tab content')).toBeInTheDocument()
})
it('Clicking the "To Settings Tab" button calls router.push', async () => {
render(<DashboardPage />)
const button = screen.getByRole('button', { name: 'To Settings Tab' })
await userEvent.click(button)
expect(pushMock).toHaveBeenCalledWith('/dashboard?tab=settings')
expect(pushMock).toHaveBeenCalledTimes(1)
})
})
jest.mock('next/navigation'- Purpose: Replace Next.js’s
useRouter()anduseSearchParams()with mocks pushMockis a mock function used to verify whetherrouter.push()was calleduseSearchParams()is fixed to always return"home"to simulate the URL query?tab=home- Since we don’t need actual router behavior in tests, a mocking strategy of “pretend it behaves this way and verify” is effective
- Purpose: Replace Next.js’s
- The home tab is selected on initial display
- This test ensures that the page is rendered correctly in its initial state
render()rendersDashboardPageinto the virtual DOM- Confirms that an
<h1>element with the text “Dashboard” is present - Verifies that the content for
"tab=home"is displayed correctly
- Clicking the “To Settings Tab” button calls router.push
- This test ensures that “the intent to change the URL via user interaction is correctly triggered”
- Gets the button labeled “To Settings Tab”
- Simulates a click with
userEvent.click() - Verifies that
router.push('/dashboard?tab=settings')was called correctly - Checks that it was called only once (also helps prevent double execution)
layout.tsx
The Next.js App Router’s layout.tsx generally has the following roles:
- Render common layout (Header, Sidebar, Footer, etc.)
- Receive and render children as a slot
- Wrap global providers (theme, auth, QueryClient, etc.)
In other words, it’s essentially just a wrapper component that includes props.children. Therefore, it can be unit-tested like any other React component.
Code under test
import { Header } from '@/components/Header'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
<main>{children}</main>
</>
)
}
import Link from 'next/link'
export function Header() {
return (
<header role="banner" className="bg-gray-800 text-white px-4 py-2">
<h1 className="text-xl font-bold">My App</h1>
<nav>
<ul className="flex gap-4 mt-2">
<li><Link href="/">Home</Link></li>
<li><Link href="/dashboard">Dashboard</Link></li>
</ul>
</nav>
</header>
)
}
Test code
import { render, screen } from '@testing-library/react'
import RootLayout from '../layout'
describe('RootLayout', () => {
it('Displays the header and children', () => {
render(
<RootLayout>
<p>This is test content</p>
</RootLayout>
)
// Header is displayed
expect(screen.getByRole('banner')).toBeInTheDocument()
// Children are rendered
expect(screen.getByText('This is test content')).toBeInTheDocument()
})
})
Run the tests with:
npm run test
All 6 test files finished successfully.
E2E testing with Playwright
Benefits of testing a Next.js App with Playwright
1. Verify behavior in a real browser, close to production
- Playwright can automatically test in Chrome / Firefox / Safari (WebKit).
- It can accurately verify parts that depend on browser behavior, such as App Router dynamic routing, Server Actions, and layout structures.
This helps prevent issues like “It worked during development, but clicks don’t work in production.”
2. Test routing and page transitions end-to-end
page.goto('/dashboard?tab=home')lets you directly access Next.js pagesawait page.click('text=To Settings Tab')verifiesrouter.push()toHaveURL()can verify URL changes
This enables “page-level behavior guarantees” that unit tests alone can’t provide.
3. Verify end-to-end flows including Server Actions and API responses
- Input →
form action={submit}→ result display - You can verify this entire flow at the browser level.
4. Easy integration with CI/CD
- You can integrate with GitHub Actions, CircleCI, Vercel Preview Deploy, etc., to automatically run E2E tests.
- If you set the
webServeroption inplaywright.config.ts, Playwright can automatically start Next.js viadevorstart.
This lets you build a system where you can release with confidence without slowing down development.
5. Catch bugs that unit tests alone might miss
| Bugs that unit tests may miss | Why Playwright can catch them |
|---|---|
| Display not updating after router.push() | You can actually click the button and check if the URL changes |
| Layout / transition breakage | Rendering actually runs in a real browser |
| Server Action errors going unnoticed | You can actually submit and verify whether messages are displayed |
Setup steps
Install required packages
npx playwright install
npm install --save-dev playwright @playwright/test
npx playwright install- Installs browser runtimes locally
- Extracts binaries and dependencies needed for Playwright to run
Configure playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://localhost:3000',
video: 'retain-on-failure',
},
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: !process.env.CI,
},
})
testDir- Directory (relative path) where test files are placed
- Typically
tests/ore2e/ - Files like
*.spec.tsor*.test.tsin this directory are automatically recognized
video: 'retain-on-failure'- Records a video when a test fails so you can review it later
- Makes it easier to see what happened when the test failed
webServer- Starts the server that Playwright will interact with
command- Command to start the Next.js app (dev server) before tests
port- Port where the app will be running
reuseExistingServer- Reuse the server locally, start a new one in CI
- Avoids starting
npm run devmultiple times, speeding up local development
Overall flow:
- Run
npm run devin the background before tests - Detect when
http://localhost:3000is up - Run tests in each
.test.tsfile - Stop the server when done (or reuse it)
.gitignore
Exclude the video output directory in .gitignore:
# playwright
/test-results
Actual test code
We’ve created a dashboard screen, so let’s test it.
import { test, expect } from '@playwright/test'
test('The dashboard shows the My App header', async ({ page }) => {
await page.goto('/dashboard')
// header with role="banner" is visible
await expect(page.getByRole('banner')).toBeVisible()
// If you also want to check the "My App" text in h1, this is fine too
await expect(page.getByRole('heading', { name: 'My App' })).toBeVisible()
})
test('Clicking "To Settings Tab" changes the URL', async ({ page }) => {
await page.goto('/dashboard?tab=home')
await page.getByRole('button', { name: 'To Settings Tab' }).click()
await expect(page).toHaveURL('/dashboard?tab=settings')
})
Add scripts
Add a command to run Playwright:
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"test": "jest",
+ "test:e2e": "playwright test",
"lint": "next lint"
},
Run the tests with:
npm run test:e2e
Two tests finished successfully.
Conclusion
We’ve covered the basics of unit, integration, and E2E testing in a Next.js App Router project, along with concrete implementation methods using React Testing Library, Jest, and Playwright.
In the App Router, Server Components, Server Actions, and routing design have become more flexible, but that also means the test setup needs to be carefully designed to match. Precisely because of this, writing tests strategically at different granularities—“verify details with unit tests,” “guarantee structure with integration tests,” and “test the overall experience with E2E tests”—can greatly boost your app’s reliability.
The goal of testing is not to “write perfect tests.” It’s enough to gradually adopt tests to provide the level of confidence your app needs right now and to improve the development experience.
I hope your Next.js app becomes a product you can operate with greater peace of mind. Thank you very much for reading all the way to the end.
The code implemented in this article is stored in the following repository, so please refer to it alongside the article.
Questions about this article 📝
If you have any questions or feedback about the content, please feel free to contact us.Go to inquiry form
Related Articles
Test Automation with Jest and TypeScript: A Complete Guide from Basic Setup to Writing Type-Safe Tests
2023/09/13Implementing Essential UI Component Tests for Frontend Development with React Testing Library
2023/09/20Implement E2E tests with Playwright to achieve user-centric testing including inter-system integration
2023/10/02Complete Guide to Web Accessibility: From Automated Testing with Lighthouse / axe and Defining WCAG Criteria to Keyboard Operation and Screen Reader Support
2023/11/21Introduction to Automating Development Work: A Complete Guide to ETL (Python), Bots (Slack/Discord), CI/CD (GitHub Actions), and Monitoring (Sentry/Datadog)
2024/02/12Chat App (with Image/PDF Sending and Video Call Features)
2024/07/15Practical Component Design Guide with React × Tailwind CSS × Emotion: The Optimal Approach to Design Systems, State Management, and Reusability
2024/11/22Management Dashboard Features (Graph Display, Data Import)
2024/06/02




