Next.js App Router 時代のテスト戦略:Jest・RTL・Playwrightで支える開発の安心感

  • jest
    jest
  • nextjs
    nextjs
  • testinglibrary
    testinglibrary
  • playwright
    playwright
  • react
    react
2025/04/22に公開

はじめに

なぜ今「Next.js App Router + Testing」の構成が重要なのか

Next.js 13 以降で登場した「App Router」は、それまでの pages ディレクトリベースのルーティングと比べて、ディレクトリ構造や設計思想に大きな変化をもたらしました。layout.tsxtemplate.tsxserver components の導入によって、アプリケーションの構築に柔軟性とパフォーマンスがもたらされた一方で、「どうやってテストを書くべきか?」という悩みが一気に増えたという声も多く耳にします。

特に、

  • next/navigation のHookを使っているコンポーネントのモック方法
  • app/ディレクトリ構造特有のコンポーネント分離とそのテスト単位の考え方
  • server componentclient componentの混在によるテスト戦略の見直し

といった点で、「これまでのやり方が通用しない」と感じている人は少なくないはずです。

本記事の対象読者

このブログ記事は、以下のような方に向けて書いています。

  • Next.jsのApp Routerで開発しているが、テストの構成に悩んでいる方
  • React Testing Library や Jest の導入まではできたけど、運用に不安がある方
  • 統合テストやモックのベストプラクティスを模索している方
  • 今後のテスト戦略をチームで整備していきたい方

本記事では、Next.js App Router環境におけるユニットテスト/統合テストの構成方法や、ハマりやすいポイント、モックのテクニックを実践ベースで紹介していきます。

とりあえずこの構成をベースにすれば、ひとまず安心という状態を目指して、一歩ずつ丁寧に整理していきますので、どうぞ最後までお付き合いください。

今回のゴール

このブログ記事では、Next.js App Router構成のプロジェクトにおいて、

  • ユニットテスト(Jest × React Testing Library)
  • 統合テスト(ページ単位での動作確認)
  • E2Eテスト(Playwrightによる実際のブラウザ操作)

の3つのレイヤーに分けて、テストの構成方法と実装例を紹介します。

特に、App Router固有の構成(layout.tsx, template.tsx, Server Actions など)に対応するための具体的な書き方やモックの方法を通じて、「テストを書きたくても最初の一歩が踏み出せない」状態から抜け出すことを目指します。

この記事を読み終えるころには、あなたのNext.jsアプリにテストを組み込み、安心して開発・リファクタリングできる構成を自力で作れる状態になっているはずです。

今回実装したコードはこちらのリポジトリに格納してありますので合わせてご参照ください。

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

基本構成と前提

Next.js App Router と Jest / React Testing Library を組み合わせてテストを構成する際、まず押さえておきたいのが 「どこまでをテストするのか」と「どの単位で分けるのか」という設計視点です。

App Routerはページ単位のコンポーネント分割(layout.tsx, template.tsx, page.tsx)が特徴的な構成になっており、ユニットテストと統合テストを自然に分離しやすい構造とも言えます。

使用技術スタック

本記事では以下の構成で解説を進めていきます:

分類 ライブラリ名 用途
フレームワーク Next.js(App Router) ルーティング/レンダリング
テスティング Jest テストランナー
テスティング React Testing Library コンポーネントの振る舞いテスト
補助 @testing-library/jest-dom DOMアサーションの拡張
補助(必要に応じて) MSW / next-auth, react-query など モックAPI/状態管理の補助

※ バンドラはSWCを使用します(Next.jsのデフォルト設定)

なぜ Jest + React Testing Library?

App Router構成においても、“振る舞いを中心にテストする” というReact Testing Libraryの思想は非常にマッチしています。

  • getByRole, getByText, getByLabelText によるアクセシブルなセレクター
  • Jestの強力なmock機能との連携(next/navigationnext-authのモックなど)
  • DOMの状態やコンポーネントの出力結果を検証しやすい

これにより、「UIとしてどう振る舞うべきか」を開発者目線で自然に確認できるのが最大の魅力です。

App Routerにおけるテストファイルの構成パターン

まず、下記を大前提としてテストファイルをどう配置するのが理想なのかを状況に応じてご紹介します。

  • components/ → 再利用可能なUIパーツ(Client Componentが多い)
  • app/ → ページとレイアウト(Server Component含む)
  • tests/__tests__/ → 規模が大きくなったら分離管理してもOK

パターン①: Componentごとに隣接させる方式

components/
├── Button.tsx
├── Button.test.tsx ← ★同階層に配置
├── Header.tsx
├── Header.test.tsx

向いてるケース

  • 小〜中規模のUIコンポーネント群
  • client component 中心で完結
  • Storybookと連携する場合にも扱いやすい

パターン②: app/配下は__tests__などにまとめる方式

app/
├── dashboard/
│   ├── page.tsx
│   └── __tests__/
│       └── page.test.tsx ← ★ページ単位でテスト
├── layout.tsx
└── __tests__/
    └── layout.test.tsx ← ★共通layoutのテスト

向いてるケース

  • layout.tsx, page.tsx, template.tsx などを明示的に分けている構成
  • ユーザーの遷移やページ描画結果など、統合的な振る舞いを確認したいとき
  • モックの粒度を切り替えてテストしたいとき

パターン③: tests/pages や tests/components に全て集約する方式

tests/
├── components/
│   └── Button.test.tsx
├── pages/
│   └── dashboard.test.tsx
├── layout/
│   └── root-layout.test.tsx

向いてるケース

  • コンポーネント単体 vs ページ全体をしっかり分離したいとき
  • ディレクトリ内をスキャンしてテストを走らせたいCI構成
  • ディレクトリ構成が複雑になる大規模アプリケーション

テストファイルの構成パターンまとめ

個人開発や小規模PJなら「① + ②の併用」が一番扱いやすくておすすめです。今回の記事も「① + ②の併用」で実装を進めていきます。

テスト対象 推奨パターン
再利用UI(Buttonなど) パターン①(隣接)
各ページの描画・振る舞い パターン②(app配下に__tests__)
CIや分業考慮で整理したい パターン③(tests配下に集約)

テスト環境のセットアップ(Next.js App Router + Jest + RTL)

App Router構成のNext.jsでJestとReact Testing Libraryを使うには、少しだけ注意点があります。
この章では、最小構成で動くテスト環境をセットアップする手順を丁寧に整理します。

Next.jsプロジェクトを作成します。

npx create-next-app@latest your-app-name

Image from Gyazo

必要なパッケージのインストールをします。

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

  • テストランナー
  • テストを実行するコアライブラリ
  • describe, test, expect などの関数を提供
  • スナップショットテストやmock機能も豊富!

@types/jest

  • JestのTypeScript型定義
  • TypeScriptで expect()jest.fn() などを使うときに型が付くようになる

ts-jest

  • TypeScriptファイルをJestで直接扱えるようにするトランスパイラ
  • JestがTypeScriptコードを直接扱えるようになります。

@testing-library/react

  • Reactの振る舞いテストに特化したライブラリ
  • render(), screen.getByText(), fireEvent() などが使える
  • 「見た目」ではなく「どう振る舞うか」をテストする思想

@testing-library/jest-dom

  • toBeInTheDocument(), toHaveClass() などのカスタムマッチャーを追加
  • より表現豊かなアサーションが可能になる
  • Jest + RTLではほぼ必須のセット

jest-environment-jsdom

  • Jest v27までは jest 本体に jsdom がバンドルされていたが、v28以降で分離された
  • Jestをv28以降で使っていて testEnvironment: 'jsdom' を指定している場合は、明示的に jest-environment-jsdom を追加する必要がある

@testing-library/user-event

  • より現実的なユーザー操作(キーボード入力、マウス操作など)を再現できる
  • fireEvent() よりも実際のユーザー行動に近い操作が可能
    • 例:userEvent.click(), userEvent.type(input, 'text') など

まとめ

パッケージ名 役割
jest テストランナー(テスト実行本体)
@types/jest Jestの型定義(TS用)
ts-jest TypeScript対応のためのJestトランスパイラ
@testing-library/react Reactの振る舞いテストライブラリ
@testing-library/jest-dom DOMアサーション用のマッチャー追加
@testing-library/user-event より現実に近いユーザー操作のシミュレート

jest の設定ファイル(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のpaths対応
  },
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },
}

export default createJestConfig(customJestConfig)

import nextJest from 'next/jest'

  • Next.js公式が提供しているJestの設定補助ユーティリティ。
  • next/jest は、Next.jsに最適化された jest.config を生成するための関数を提供する。

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

  • dir: './' は、プロジェクトのルートディレクトリ(Next.jsのnext.config.jstsconfig.jsonがある場所)を指定。
  • ここを明示することで、Next.jsの各種設定ファイルをjestが正しく読み込めるようにする

setupFilesAfterEnv

  • 各テストの前に一度だけ読み込まれる設定ファイルを指定。
  • よくある中身:@testing-library/jest-dom の読み込み、MSWの設定など。

testEnvironment

  • テストを Node.js ではなくブラウザ環境(仮想DOM)で実行する設定。
  • Reactコンポーネントなどのテストでは必須。
  • Server Componentなどで DOM が一切不要なテストに関しては、個別に node を指定。

moduleNameMapper

  • @/components/Button のようなパス指定を、Jestが解釈できるようにするための設定。
  • tsconfig.json"paths" に対応させたもの。

Jest セットアップファイル(jest.setup.ts

jest.setup.ts
import '@testing-library/jest-dom'

これでtoBeInTheDocument()など、便利なカスタムアサーションが使えるようになります。

tsconfigのtypes

Jestが @testing-library/jest-dom の型を認識できていない場合は下記を設定します。

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"],
  },

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"
  },

動作確認用のテストコード作成

セットアップを行なったjest等が正常に動作するか確認するためにコンポーネントの作成とそれのテストコードを作成します。

一般的なボタンコンポーネントを作成します。

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>
  )
}

上記に対応するテストコードとなります。

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()
})

このテストではボタンコンポーネントが正しくレンダリングされるかを確認しています。

下記コマンドでテストを実行します。

npm run test

1件のテストが実行され正常終了しました。
これでNext.jsプロジェクトでクライアントコンポーネントのテストが実行できるようになりました。

Image from Gyazo

ディレクトリ構成の確認

いくつかファイルを作成しましたので念のため現在のディレクトリ構成を記載しておきます。

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

ユニットテストの基本:Client Component編

Next.js の App Router を使ったプロジェクトでは、server componentclient component の役割が明確に分かれています。
このセクションでは、"use client" が付いたコンポーネント(= client component)に対して、React Testing Library と Jest を使ってユニットテストを書く基本的な方法を紹介します。

Client Component をテストするとは?

client component は、ボタンのクリックやフォームの入力といったブラウザ上のユーザー操作に関わる処理を担当します。そのため、テストでは以下のような観点を確認するのが基本となります。

  • コンポーネントが正しくレンダリングされるか?
  • イベント(クリックや入力)が正しく処理されるか?
  • 状態に応じた表示切り替えが行われるか?

Button コンポーネント

テスト対象のコード

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>
  )
}
  • use client を指定することで、onClick() などのイベント処理が有効になります。

テストコード

components/Button.test.tsx
import { render, screen } from '@testing-library/react'
import { Button } from './Button'

test('テスト①: 正しくラベルが表示されるか', () => {
  render(<Button label="Click me" onClick={() => {}} />)
  expect(screen.getByText('Click me')).toBeInTheDocument()
})

test('テスト②: クリックされたときにハンドラが呼ばれるか', () => {
  const handleClick = jest.fn()
  render(<Button label="Click me" onClick={handleClick} />)

  screen.getByRole('button').click()
  expect(handleClick).toHaveBeenCalledTimes(1)
})
  • テスト①: 正しくラベルが表示されるか
    • このテストは、渡した label がボタンに表示されているかどうかを検証している。
    • screen.getByText('Click me') は、画面上に "Click me" というテキストを持つ要素を取得。
    • toBeInTheDocument() は、実際にその要素がDOMに存在していることを確認するためのアサーション。
  • テスト②: クリックされたときにハンドラが呼ばれるか
    • コンポーネントがクリックイベントを正しく処理しているかどうかを検証している。
    • jest.fn() は モック関数。呼び出し回数・引数などを確認できる。
    • render() でモック関数を渡した Button を描画。
    • screen.getByRole('button')<button> 要素を取得し、click() を実行。
    • 最後に handleClick が1回呼ばれたことを toHaveBeenCalledTimes(1) で検証。

Form コンポーネント

テスト対象のコード

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">名前:</label>
      <input
        id="name"
        name="name"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <button type="submit">送信</button>

      {submitted && <p>こんにちは、{name}さん!</p>}
    </form>
  )
}
  • useStateで「入力された名前」「フォームが送信されたかどうかの状態」を保持
  • e.preventDefault() によって、ブラウザ標準のページリロードを抑止
  • 空欄で送信されないように name.trim() をチェック(簡易バリデーション)
  • 正常に入力があれば submittedtrue にして表示を切り替える

テストコード

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('テスト①: 名前を入力して送信するとメッセージが表示される', async () => {
    render(<SimpleForm />)

    const input = screen.getByLabelText('名前:')
    const button = screen.getByRole('button', { name: '送信' })

    await userEvent.type(input, '太郎')
    await userEvent.click(button)

    expect(screen.getByText('こんにちは、太郎さん!')).toBeInTheDocument()
  })

  it('テスト②: 空欄で送信してもメッセージは表示されない', async () => {
    render(<SimpleForm />)

    const button = screen.getByRole('button', { name: '送信' })
    await userEvent.click(button)

    expect(screen.queryByText(/こんにちは、/)).not.toBeInTheDocument()
  })
})
  • テスト①: 名前を入力して送信するとメッセージが表示される
    • userEvent.type: 実際のユーザーがキーボード入力するのに近い挙動で input に「太郎」と入力
    • userEvent.click: ボタンをクリック(= フォームの送信をトリガー)
    • 送信後に submittedtrue になり、<p>こんにちは、太郎さん!</p> が表示されることを検証
  • テスト② 空欄で送信してもメッセージは表示されない
    • 何も入力せずにボタンだけをクリック(空欄送信)
    • /こんにちは、/ は「こんにちは、〇〇さん!」にマッチする正規表現
      -「何も表示されていないこと(フォーム送信がされていない)」を確認

ユニットテストの基本:Server Actions 編

Server Actionsは、Next.js App Routerで使える新しい非同期処理の仕組み。
フォーム送信やボタンクリックなどのアクションを、クライアントから直接サーバーの関数に渡せるという特徴があります。

ただし、これはuse clientを使ったコンポーネントから呼び出される「async 関数」なので、テストでは普通のロジックとして扱えます。

サーバー側で登録する(仮)関数

テスト対象のコード

actions/submitName.ts
export async function submitName(name: string): Promise<string> {
  if (!name.trim()) {
    throw new Error('名前が空です')
  }

  // 実際はここにDB登録などの処理が入る

  return `こんにちは、${name}さん!`
}
  • 受け取った文字列が空の場合はエラーが投げられます。
  • それ以外の場合は「こんにちは〜」から始まる文字列を返します。

テストコード

actions/submitName.test.ts
/**
 * @jest-environment node
 */
import { submitName } from './submitName'

describe('submitName (Server Action)', () => {
  it('正しい名前を送信したとき、メッセージが返る', async () => {
    const result = await submitName('太郎')
    expect(result).toBe('こんにちは、太郎さん!')
  })

  it('空欄を送信したとき、エラーが投げられる', async () => {
    await expect(submitName('')).rejects.toThrow('名前が空です')
  })
})
  • @jest-environment nodeで、node環境でテストを動かします。
  • .rejects.toThrow()で非同期関数の例外処理をテストできます。

サーバー側で外部APIにアクセスしてデータを取得する関数

テスト対象のコード

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('投稿の取得に失敗しました')
  }

  const post = await res.json()
  return post.title
}
  • JSON Placeholderを使って投稿を取得します。
  • 取得できなかった場合はエラーを投げます。

https://jsonplaceholder.typicode.com/

テストコード

actions/fetchPost.test.ts
/**
 * @jest-environment node
 */
import { fetchPost } from './fetchPost'

// fetch をモックする
global.fetch = jest.fn()

describe('fetchPost (Server Action with external API)', () => {
  beforeEach(() => {
    jest.resetAllMocks()
  })

  it('投稿IDを指定するとタイトルが返る', 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('APIのレスポンスがエラーだった場合、例外を投げる', async () => {
    ;(fetch as jest.Mock).mockResolvedValue({ ok: false })

    await expect(fetchPost(99)).rejects.toThrow('投稿の取得に失敗しました')
  })
})
ポイント 解説
global.fetch = jest.fn() Jest (またはVitest) のモックで fetch を上書き
mockResolvedValue() 非同期関数の成功時の戻り値を模倣
mockJson() res.json() も非同期なので別途モック
.rejects.toThrow() エラー系の検証はこの書き方で安全に書けます
resetAllMocks() テスト間でモック状態が混ざらないよう初期化します

下記コマンドでテストを実行します。

npm run test

合計4ファイルのテストが正常終了しました。

Image from Gyazo

統合テスト構成:App Routerとページ単位のテスト

Next.js App Router構成では、layout.tsx, page.tsx, template.tsx などのファイルをルートごとに分けて管理するスタイルが主流です。
これにより、アプリケーション全体の構造は明確になりますが、その一方で「ページ全体を対象とした統合テストをどのように設計すればよいか」という点で悩む方も多いのではないでしょうか。

このセクションでは、App Router構成におけるページ単位の統合テストの基本的な進め方と、よく使われるモックの書き方について紹介します。

統合テストの目的とは?

ユニットテストが「コンポーネント単体の振る舞い」を検証するのに対し、統合テストでは主に以下のような確認を行います。

  • ページコンポーネントと複数の子コンポーネントが正しく連携して動作するか
  • クライアントコンポーネントや状態管理を伴う表示切り替えが期待通り行われるか
  • ルーティング(useRouter, useSearchParams)やサーバーアクションとの統合動作が想定通りか

「ユーザーがページを開いたとき、表示・状態・挙動が一通り正しく動くかをテストする」のが目的です。

page.tsx

テスト対象のコード

ダッシュボードを想定して新たにページを作成します。
このコンポーネントは、tab クエリパラメータに応じて表示を切り替えるダッシュボードページです。
「設定タブへ」ボタンをクリックすると、URLが /dashboard?tab=settings に変更され、表示も更新されるようになっています。

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>ホームタブの内容</p>
    case 'settings':
      return <p>設定タブの内容</p>
    default:
      return <p>不明なタブです</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>ダッシュボード</h1>
      <button onClick={goToSettings} className='cursor-pointer'>設定タブへ</button>

      <Suspense fallback={<p>読み込み中...</p>}>
        <TabContent tab={tab} />
      </Suspense>
    </main>
  )
}
  • useRouter()

    • クライアントサイドルーティングを制御するフック。
    • router.push() を使ってURLを変更できます(ページ遷移なし)。
  • useSearchParams()

    • 現在のURLのクエリパラメータ(?tab=home など)を読み取るためのフック。
    • クエリが存在しない場合は null を返すので、|| 'home' でデフォルトを設定。
  • TabContent の構造と役割

    • クエリパラメータで渡された tab に応じて、表示内容を切り替えています。
    • このような表示切り替えは、UI上で「同じページ内で切り替える」場合に非常によく使われるパターンです。

テストコード

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('初期表示でホームタブが選択されていること', () => {
    render(<DashboardPage />)

    expect(screen.getByRole('heading', { name: 'ダッシュボード' })).toBeInTheDocument()
    expect(screen.getByText('ホームタブの内容')).toBeInTheDocument()
  })

  it('「設定タブへ」ボタンをクリックすると router.push が呼ばれる', async () => {
    render(<DashboardPage />)

    const button = screen.getByRole('button', { name: '設定タブへ' })
    await userEvent.click(button)

    expect(pushMock).toHaveBeenCalledWith('/dashboard?tab=settings')
    expect(pushMock).toHaveBeenCalledTimes(1)
  })
})
  • jest.mock('next/navigation'
    • 目的:Next.jsの useRouter()useSearchParams() をモックに差し替えています。
    • pushMockrouter.push() を呼んだかどうかを確認するための モック関数。
    • useSearchParams() は URL のクエリ ?tab=home を再現するため、常に "home" を返すよう固定。
    • テストでは実際のルーター挙動は必要ないため、「こう動いていることにして検証する」=モック戦略が有効です。
  • 初期表示でホームタブが選択されていること
    • このテストで「ページが初期状態で正しく描画されている」ことを保証しています。
    • render() で DashboardPage を仮想DOMに描画。
    • <h1> の要素に "ダッシュボード" と書かれていることを確認。
    • クエリパラメータが "tab=home" なので、正しく内容が表示されているか検証。
  • 「設定タブへ」ボタンをクリックすると router.push が呼ばれる
    • 「ユーザー操作によりURL遷移の意図が正しくトリガーされた」ことを保証しています。
    • "設定タブへ" というラベルのボタンを取得。
    • userEvent.click() でクリック操作を模擬。
    • router.push('/dashboard?tab=settings') が正しく呼ばれたかを確認。
    • 一度だけ呼ばれていることをチェック(二重実行されていないかの防止にもなる)

layout.tsx

Next.js App Routerの layout.tsx は、基本的に以下のような役割を持ちます

  • 共通のレイアウト(Header、Sidebar、フッターなど)を描画する
  • 子要素(children)を slot のように受け取って描画する
  • グローバルなProviderをラップする(テーマ、認証、QueryClient など)

つまり、その中身は「props.childrenを含んだラッパーコンポーネント」にすぎません。そのため普通のReactコンポーネントとしてユニットテスト可能です。

テスト対象のコード

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="/">ホーム</Link></li>
          <li><Link href="/dashboard">ダッシュボード</Link></li>
        </ul>
      </nav>
    </header>
  )
}

テストコード

app/dashboard/__test__/layout.test.tsx
import { render, screen } from '@testing-library/react'
import RootLayout from '../layout'

describe('RootLayout', () => {
  it('ヘッダーと children を表示する', () => {
    render(
      <RootLayout>
        <p>これはテスト用の中身です</p>
      </RootLayout>
    )

    // ヘッダーが表示されているか
    expect(screen.getByRole('banner')).toBeInTheDocument()

    // childrenが描画されているか
    expect(screen.getByText('これはテスト用の中身です')).toBeInTheDocument()
  })
})

下記コマンドでテストを実行します。

npm run test

合計6ファイルのテストが正常終了しました。

Image from Gyazo

Playwright で E2Eテスト

Playwright で Next.js App をテストするメリット

1. 実際のブラウザ上で本番に近い動作を確認できる

  • Playwright は Chrome / Firefox / Safari(WebKit)での動作を自動テストできます。
  • App Routerの動的ルーティング・Server Actions・Layout構成など、ブラウザの挙動に依存する部分も正確に検証できます。

開発中は動いていたのに「本番ではクリックが効かない」みたいな事故を防げます。

2. ルーティング・ページ遷移も丸ごとテストできる

  • page.goto('/dashboard?tab=home') で Next.js のページに直接アクセス
  • await page.click('text=設定タブへ')router.push() の確認
  • toHaveURL() で URL の変化も検証できる

ユニットテストではできない「ページ単位の挙動保証」が可能になります。

3. Server Actions や APIレスポンスまで含めた統合確認ができる

  • 入力 → form action={submit} → 結果が表示される
  • この一連の動作をブラウザレベルで検証可能となります。

4. CI/CDと簡単に統合できる

  • GitHub Actions, CircleCI, VercelのPreview Deployなどと連携して、自動でE2Eテストを動かすことができます。
  • playwright.config.tswebServer オプションを設定すれば、Next.js の dev or start を自動で立ち上げてテストできます。

開発速度を落とさずに 安心してリリースできる仕組みが構築可能です。

5. ユニットテストだけでは拾えない不具合をキャッチ

ユニットテストで気づきにくいバグ Playwrightなら気づける理由
router.push() 後の表示更新がされない 実際にボタンを押して URL が変わるか確認できる
レイアウト・遷移の崩れ 実ブラウザでレンダリングが走る
Server Action でエラーが出てるのに気づかない 実際にsubmitしてメッセージ表示されるかまで検証できる

セットアップ手順

必要なパッケージのインストールをします。

npx playwright install
npm install --save-dev playwright @playwright/test
  • npx playwright install
    • 各ブラウザ(ランタイム)をローカルにインストール
    • Playwright が動作に必要なバイナリや依存ファイルを展開

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
    • テストファイルの配置ディレクトリ(相対パス)
    • 通常は tests/ や e2e/ が使われます
    • この中に *.spec.ts や *.test.ts を置くことで自動的に認識されます
  • video: 'retain-on-failure'
    • テストが失敗した際に動画を記録してあとで見ることができます。
    • どのような状況でテストが失敗したか追いやすくなります。
  • webServer
    • playwrightで操作する対象のサーバーを起動することができます。
    • command
      • テスト前に Next.jsアプリ(開発サーバー)を起動するコマンド
    • port
      • アプリが立ち上がるポートを指定
    • reuseExistingServer
      • ローカルでは再利用、CIでは毎回起動 という設定
      • npm run dev を何回も起動しなくて済むため、開発中は高速化できる

全体としての動きは以下の通りになります。

  1. テスト前に npm run dev をバックグラウンドで実行
  2. http://localhost:3000 が立ち上がるのを検知
  3. 各 .test.ts ファイルのテストを実行
  4. 終わったらサーバーを止める(または再利用)

.gitignore

動画の保存先を.gitignoreで除外しておきます。

# playwright
/test-results

実際のテストコード

ダッシュボード画面を作成したのでそのテストを実施します。

tests/dashboard.test.ts
import { test, expect } from '@playwright/test'

test('ダッシュボードに My App のヘッダーが表示される', async ({ page }) => {
  await page.goto('/dashboard')
  // header が role="banner" で表示されていること
  await expect(page.getByRole('banner')).toBeVisible()

  // h1 内の "My App" テキストを確認したいならこちらもOK
  await expect(page.getByRole('heading', { name: 'My App' })).toBeVisible()
})

test('「設定タブへ」をクリックするとURLが変わる', async ({ page }) => {
  await page.goto('/dashboard?tab=home')

  await page.getByRole('button', { name: '設定タブへ' }).click()

  await expect(page).toHaveURL('/dashboard?tab=settings')
})

scriptsに追加

Playwright実行用のコマンドを追加します。

package.json
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "test": "jest",
+   "test:e2e": "playwright test",
    "lint": "next lint"
  },

下記コマンドでテストを実行します。

npm run test:e2e

2つのテストが正常終了しました。

Image from Gyazo

おわりに

ここまで、Next.js App Router構成のプロジェクトにおけるユニットテスト・統合テスト・E2Eテストの基本と、React Testing Library・Jest・Playwrightを使った具体的な実装方法を紹介してきました。

特にApp Routerでは、Server ComponentやServer Action、ルーティングの設計がより柔軟になった一方で、テスト構成もそれに合わせて丁寧に設計する必要があります。
ですが、だからこそ「ユニットで細かく確認」「統合で構造を保証」「E2Eで体験全体をテスト」と、粒度に応じて戦略的にテストを書くことが、アプリの信頼性を大きく底上げしてくれます。

テストは「完璧に書く」ことが目的ではありません。今のアプリに必要な安心感や、開発体験の改善のために、少しずつ取り入れていけば大丈夫です。
みなさまのNext.jsアプリが、より安心して育てられるプロダクトになることを願っています。ここまで読んでいただき、本当にありがとうございました🙇

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

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ

関連する技術ブログ

弊社の技術支援サービス

お問い合わせ

経営と現場をつなぐ“共創型”の技術支援。
成果に直結するチーム・技術・プロセスを共に整えます。

お問い合わせ