Test Strategy in the Next.js App Router Era: Development Confidence Backed by Jest, RTL, and Playwright

  • jest
    jest
  • nextjs
    nextjs
  • testinglibrary
    testinglibrary
  • playwright
    playwright
Published on 2025/04/22

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 components and client components are 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.

https://github.com/shinagawa-web/next-app-testing-lab

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

Image from Gyazo

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() or jest.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, jsdom was bundled with Jest itself, but it was split out in v28+
  • If you’re using Jest v28+ and specify testEnvironment: 'jsdom', you must explicitly add jest-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.

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)

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/jest provides a function that generates a jest.config optimized for Next.js

const createJestConfig = nextJest({ dir: './' })

  • dir: './' specifies the project root directory (where next.config.js and tsconfig.json live)
  • 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 node individually

moduleNameMapper

  • Makes Jest understand path aliases like @/components/Button
  • Mirrors the "paths" setting in tsconfig.json

Jest setup file (jest.setup.ts)

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:

tsconfig.json
{
  "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

package.json
{
  "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:

components/Button.tsx
'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:

components/Button.test.tsx
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.

Image from Gyazo

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

components/Button.tsx
'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 client enables event handling like onClick().

Test code

components/Button.test.tsx
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 calls click().
    • Finally, toHaveBeenCalledTimes(1) verifies that handleClick was called once.

Form component

Code under test

components/SimpleForm.tsx
'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 useState to 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 submitted to true and switches the display

Test code

components/SimpleForm.test.tsx
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 input
    • userEvent.click: clicks the button (triggers form submission)
    • After submission, submitted becomes true, 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

actions/submitName.ts
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

actions/submitName.test.ts
/**
 * @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 node runs 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

actions/fetchPost.ts
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.

https://jsonplaceholder.typicode.com/

Test code

actions/fetchPost.test.ts
/**
 * @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.

Image from Gyazo

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.

app/dashboard/page.tsx
'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 null if the query doesn’t exist, so we set a default with || 'home'
  • Structure and role of TabContent

    • Switches the display based on the tab query parameter
    • This kind of display switching is a very common pattern when toggling content within the same page

Test code

app/dashboard/__test__/page.test.tsx
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() and useSearchParams() with mocks
    • pushMock is a mock function used to verify whether router.push() was called
    • useSearchParams() 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
  • The home tab is selected on initial display
    • This test ensures that the page is rendered correctly in its initial state
    • render() renders DashboardPage into 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

app/dashboard/layout.tsx
import { Header } from '@/components/Header'

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Header />
      <main>{children}</main>
    </>
  )
}
components/Header.tsx
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

app/dashboard/__test__/layout.test.tsx
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.

Image from Gyazo

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 pages
  • await page.click('text=To Settings Tab') verifies router.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 webServer option in playwright.config.ts, Playwright can automatically start Next.js via dev or start.

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

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/ or e2e/
    • Files like *.spec.ts or *.test.ts in 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 dev multiple times, speeding up local development

Overall flow:

  1. Run npm run dev in the background before tests
  2. Detect when http://localhost:3000 is up
  3. Run tests in each .test.ts file
  4. 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.

tests/dashboard.test.ts
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:

package.json
  "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.

Image from Gyazo

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.

https://github.com/shinagawa-web/next-app-testing-lab

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form