QAフェーズへの移行を機に、後回しにしていたテスト基盤を一気に整備した記録です。

同じドメインで連携する3つのNext.jsサイト(利用者向け/提供者向け/社内管理画面)を並行開発し、リリース前のQAに突入したものの、モンキーテストで予想を超えるバグが多発しました。

修正のたびに3サイト全画面を手動で再確認することになり、1サイクルあたり30分〜1時間かかりました。本来テストシナリオ作成に充てるはずだった1週間がバグ修正で埋まりました。

そこでE2Eテスト(Playwright)・コンポーネントテスト(Vitest)・本番エラー監視(Sentry)を 半日で一気に導入 しました。

この記事では、Next.js App Router + TanStack Query + MSW という構成で、ツール選定から実装・CI統合までの過程を解説します。あわせて、SSR / Turbopack固有のハマりポイントと、AIでテストを量産する際の落とし穴も共有します。

この記事で得られること

  • Next.js App Routerで Playwright + MSW を動かすためのSSR対応パターン
  • MSW ハンドラーを E2E と Vitest で共有する設計(モックの二重管理を防ぐ)
  • E2E / Vitest / 手動テストの 仕分け基準(テストピラミッドの設計)
  • Sentryを Next.js 16 + Turbopack で動かす際のハマりポイントと解決策
  • Sentryの 3層防御マスキング設計(SDKフック / Server-side Scrubber / Generative AI機能停止)
  • ソースマップを Sentry にアップロードしない という運用選択肢とそのトレードオフ
  • AIでテストを量産する際の「成功するテストしか作らない」問題と対策

動作確認環境

種別バージョン
OSmacOS 15.x
Node.js24.x
パッケージマネージャーpnpm 10.x
Next.js16.x(Turbopack)
React19.x
@playwright/test1.58.x
vitest4.x
msw2.x
@sentry/nextjs10.x
@tanstack/react-query5.x

1. 背景: 手動テストだけでは回らなくなった瞬間

3つのNext.jsサイト(利用者向けサイト/提供者向けサイト/社内管理画面)を約10名のチームで半年かけて並行開発しました。

開発スピードを優先する判断から、フロントエンドの自動テストは整備されないままQAフェーズに突入しました。

具体的に何が起きていたか:

  • モンキーテスト初日に予想を超えるバグが多発 — テストシナリオ作成予定の1週間がバグ修正で埋まる
  • バグ修正のたびに全画面を手動で再確認(1回30分〜1時間)→ 修正→デグレ→再修正のサイクル
  • 3サイトが共通の API を使っているため、1サイトの修正が他サイトに影響していないかの確認が困難
  • テスト手順が担当者の頭の中にしかなく、品質保証が完全に属人化
  • AI(Claude Code / Devin等)でコードを生成しているものの、設計品質のチェック不在で手戻りが多発

開発は完了しているのにリリースできない——その最大の理由が「品質保証の仕組みがない」ことでした。

なぜ QA フェーズで腰を据えてテスト戦略を設計するのか

本来、テスト戦略は開発初期に設計すべきです。今回のプロジェクトでは、フロントエンドの開発スピードを優先する判断から、自動テストの整備は後ろ倒しになっていました。

QAフェーズに入り、手動テストの限界が明らかになったこのタイミングで、「とりあえずテストを書く」のではなく チームの標準となるテスト戦略を設計する 判断をしました。

今回設計するテスト戦略は、現行プロジェクトの品質保証だけでなく、次期プロジェクトで開発初日からテストが回る体制を作るための基盤でもあります。


2. ツール選定: なぜ Playwright × Vitest × Sentry なのか

E2E: Playwright を選んだ決定的な理由

E2Eツールは6つの候補(Playwright / Cypress / WebdriverIO / Nightwatch / TestCafe / CodeceptJS)を比較しました。結論は Playwright 一択 です。

一次選考: Playwright / Cypress 以外を落とした理由

3サイト横断のテスト基盤としてAI連携・SSR対応・運用実績の3軸で評価しました。

ツール落とした理由
WebdriverIOSelenium ベースで起動・実行が重く、CI 時間が3サイト並列で許容外。MSW 連携の知見も少ない
Nightwatchコミュニティ規模が小さく、Next.js App Router × SSR の事例が見つからない。社内に知見保有者なし
TestCafe開発ペースが鈍化し、最新の Next.js / React 19 環境での動作実績が乏しい
CodeceptJSラッパー型で内部は Playwright / WebdriverIO のいずれかに依存。直接 Playwright を採用するほうが抽象化のオーバーヘッドがない

この時点で Playwright と Cypress の2択 に絞り込みました。

二次選考: Playwright が Cypress を上回った決定打

決め手は AI 連携・並列実行のスケーラビリティ・マルチブラウザ対応 の3点です。

比較軸PlaywrightCypress
AI 連携◎ MCP Server + Codegen○ AI 機能(開発中)
並列実行◎ 標準で無料(fullyParallel: true△ 公式 --parallel は Cloud 必須。CI matrix + cypress-split で自前並列は無料可
マルチブラウザ◎ Chromium/Firefox/WebKit△ Chromium 中心
デバッグ○ Trace Viewer + UI mode◎ タイムトラベル(直感的)
SSR + MSW 連携msw/node をアプリ側で起動◎ 同上(ランナー非依存)

並列実行についての補足です。Cypress Cloudが必須なのは公式の --parallel フラグそのものとload-balancing/insight機能だけです。spec分割をCI matrixで自前管理すれば無料でも並列化は可能です(cypress-split やspecファイル分割など)。今回は3サイト横断のメンテコストを抑える目的で、追加実装なしで並列化できるPlaywrightを優先しました。

3サイト横断のテスト基盤として、Playwrightを採用しました。決め手は次の3点です。第一に、社内で実証済みのAIエージェント連携(Playwright MCP / Codegen)。第二に、追加課金なしでスケールする並列実行。第三に、Chromium/Firefox/WebKitを網羅するマルチブラウザ対応です。Cypressのタイムトラベルデバッグは魅力的でしたが、上記3点の優位を覆すには至りませんでした。

SSR + MSW は「ランナーの違い」ではなく「msw/node をどこで起動するか」の問題

注意点として、prefetchQuery のようなSSRデータ取得をMSWでモックできるかは、E2EランナーがPlaywrightかCypressか で決まりません。実際には、Next.js dev serverプロセス内に msw/node を起動できるか で決まります。

サーバー側: msw/node(server.listen())→ SSR prefetch をインターセプト ✅
ブラウザ側: msw/browser(worker.start())→ クライアント fetch をインターセプト ✅

本記事の§5.1で示す構成(msw-provider.tsxmsw/node を起動)は、Cypress側でも成立します。start-server-and-test 等で同じNext.js dev serverを立ち上げる構成にすればよいだけです。ブラウザ側API(cy.intercept()page.route())の違いはありますが、SSRモックの可否はランナー非依存です。

参考実装:

コンポーネントテスト: Vitest を選んだ理由

プロジェクトの一部に既存のJestテストがありましたが、jest.mock() でフックを直接モックしており、E2Eで使うMSWとは別世界でした。

1
2
3
// Jest: フック自体をモック → API の振る舞いは検証できない
jest.mock("@/gen/repository/reservation/reservation");
(useGetReservationList as jest.Mock).mockReturnValue({ isPending: true });

Vitestにすると、E2Eと同じMSWハンドラーをそのまま使えます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Vitest + MSW: 実際の HTTP リクエストをインターセプト
// → fetch → フック → 描画の全フローを検証
import { server } from '@/mocks/server';

test('API エラー時にエラー表示になる', async () => {
  server.use(
    http.get('*/api/users', () =>
      HttpResponse.json({ message: 'Error' }, { status: 500 }),
    ),
  );
  render(<UserList />);
  expect(await screen.findByText('エラーが発生しました')).toBeInTheDocument();
});
観点jest.mock()(既存)MSW(推奨)
モック対象TanStack Query のフック自体HTTP リクエスト
検証範囲コンポーネントの描画のみfetch → フック → 描画の全フロー
E2E との整合性E2E は MSW、単体は jest.mock で別世界同じハンドラーを共有
リファクタリング耐性フック名変更で壊れるAPI エンドポイントが変わらなければ安定

E2E と単体テストでモック基盤を共有できる のが最大の利点です。

エラー監視: Sentry を選んだ理由

テストでリリース前の品質は担保できますが、本番固有のエラーは必ず発生します。しかし、プロジェクトには 本番エラーを検知する仕組みが一切ない 状態でした。

Error BoundaryはフォールバックUIを表示するだけで、エラーの発生をどこにも通知しません。ユーザーからの問い合わせで初めてバグに気づく、という状況です。

Sentryを選んだ理由:

  • @sentry/nextjs がApp Routerをネイティブサポート(instrumentation.ts でSSR/RSCエラーも自動キャプチャ)
  • 既存の react-error-boundaryonError コールバックを追加するだけで統合可能
  • Web Vitals(LCP / INP / CLS)の自動計測に対応(FIDは2024-03-12にINPへ置換され、Sentry SDK v10でも置換済み)

3. テストピラミッドの設計: 何をどのレイヤーでテストするか

「全ページをE2Eでテストする」のは非効率です。E2Eはテストピラミッドの頂点——最もコストが高いテスト手法です。

E2E (Playwright)
コンポーネント・統合 (Vitest)
ユニット (Vitest)
静的解析 (TypeScript / ESLint)

各層の特性は次のとおりです:

実行コストテスト本数の目安主なテスト対象
E2E数十秒〜分/テスト少(各サイト2〜3本)ページ遷移を伴うクリティカルパス
コンポーネント・統合ミリ秒/テスト中量状態遷移・エラーハンドリング
ユニットミリ秒/テスト多量Zodスキーマ・hooks・utils
静的解析即時(保存時 / CI)全コード型・構文・コーディング規約

ユースケース一覧から、以下の基準で振り分けました:

E2E でやることVitest でやること
ページ遷移を伴うユーザーフローバリデーションの全パターン
認証状態に依存する表示切り替えローディング・エラー・空状態
API 連携の正常系ハッピーパスAPI エラー系のハンドリング
クリティカルパス(複数ページ横断)Zod スキーマの境界値

例えば「ログインフォームのバリデーション」はVitest、「トップ→一覧→詳細のページ遷移フロー」はE2E、という分担です。

全ページを最初からE2E化すると、CIの実行時間が伸び、テストが壊れるたびに修正コストが発生します。テストを書く文化を定着させる初期段階でこれをやると 「テスト=開発を止めるもの」 という認識が定着するリスクもあります。各サイト2〜3本のクリティカルパスからスタートし、段階的に拡充する計画です。


4. 3サイト横断のテスト基盤統一

3サイトとも Next.js App Router + TanStack Query + MSW という同じ技術スタックのため、テスト基盤を統一しました。同じ設計を別サイトでゼロから作り直すのは無駄です。

共通構成

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{project-root}/
├── e2e/                          # E2Eテスト(Playwright)
│   ├── {page-name}.spec.ts
│   └── fixtures/
├── playwright.config.ts
├── vitest.config.ts              # コンポーネント・統合テスト
├── vitest.setup.ts               # MSW server.listen() + Testing Library
└── src/
    ├── mocks/                    # MSWハンドラー(E2E / Vitest 共有)
    └── features/
        └── {feature}/
            ├── components/
            │   ├── {component}.tsx
            │   └── {component}.test.tsx   # コロケーション

サイト別の設定差分

項目利用者向けサイト提供者向けサイト管理画面
ビューポートモバイル専用(428×926)PC + モバイルPC + モバイル
認証テストユーザーログイン提供者ログイン管理者ログイン
優先テスト対象予約・決済フローシフト・稼働状態管理ユーザー管理・設定

設定差分はビューポートと認証フローの違い程度です。テスト基盤(Playwright設定、Vitest設定、MSW構成)はテンプレート化し、サイト間で統一しています。1サイトで構築 → 残り2サイトはテンプレート適用 のフローで横展開できます。


5. 実装: MSW × Playwright で SSR をテストする

ここからはPlaywrightを採用した前提に立ち、§2二次選考で確認した「ランナー非依存のSSR + MSW連携」を具体実装に落としていきます。

5.1 MSW の2層構造

Next.js App RouterでMSWを使うには、サーバー側とブラウザ側の両方 でMSWを起動する必要があります。

1
2
3
4
5
// msw-provider.tsx(サーバー側)
if (process.env.NEXT_PUBLIC_ENABLE_API_MOCKING === "true") {
  const { server } = await import("@/mocks/server");
  server.listen({ onUnhandledRequest: "bypass" });
}
1
2
3
4
5
6
// msw-client-provider.tsx(ブラウザ側)
"use client";
const mockingEnabledPromise =
  process.env.NEXT_PUBLIC_ENABLE_API_MOCKING === "true" && typeof window !== "undefined"
    ? import("@/mocks/browser").then(({ worker }) => worker.start({ onUnhandledRequest: "bypass" }))
    : Promise.resolve();

ブラウザ側だけでMSWを動かすと、SSR時の prefetchQuery が本物のAPIに飛んでしまいます。サーバー側にもMSWを入れることで、SSRでもモックデータが返ります。

onUnhandledRequest"bypass" にする理由

dev serverとブラウザ側の両方で onUnhandledRequest: "bypass" を指定しているのは、Next.js内部の /_next/* を素通しする必要があるためです。素通しの対象はHMR・React Server Component fetch・Webpack/Turbopackチャンク取得などです。"error" にするとこれらの内部fetchがすべて未マッチで失敗扱いになり、dev server自体が壊れます。一方、後述する vitest.setup.ts 側はjsdomの純粋な環境です。未マッチfetch=バグとして "error" でfail-fastさせる方針にしています(§5.3)。

5.2 MSW ハンドラーを E2E と Vitest で共有する

テスト戦略全体で最も重要な設計判断は、E2E テストとコンポーネント・統合テストで MSW ハンドラーを共有する ことです。

1
2
3
4
5
6
7
8
src/mocks/
├── handlers.ts            ← E2E / Vitest 共通のエントリポイント
├── handlers/
│   ├── auth.ts            ← 認証(カスタム)
│   ├── user.ts            ← ユーザー(カスタム)
│   └── ...
├── server.ts              ← msw/node(SSR + Vitest 用)
└── browser.ts             ← msw/browser(E2E ブラウザ側用)

3サイトともOrval(OpenAPIコードジェネレーター)で自動生成されたMSWハンドラーを持っています。カスタムハンドラーを先頭に配置し、自動生成ハンドラーをフォールバックとして使う 構成です。

1
2
3
4
5
6
// handlers.ts
import { customHandlers } from "./handlers/index";
import { orvalHandlers } from "./generated";

// MSW ハンドラーは登録順で先勝ち
export const handlers = [...customHandlers, ...orvalHandlers];

この構成により、以下のメリットがあります:

  • モックの二重管理が不要: E2E用とVitest用でハンドラーを別々に書く必要がない
  • 正常系はそのまま動く: 既存のOrvalハンドラーがフォールバックとして機能するため、テストごとにモックを準備しなくてよい
  • 異常系だけ上書きする: server.use() でテスト単位でハンドラーを差し替えるだけで異常系テストが書ける

5.3 Vitest 側の MSW セットアップ

vitest.setup.ts でMSWサーバーのライフサイクルを管理します。

1
2
3
4
5
6
7
8
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
import { server } from "@/mocks/server";
import { beforeAll, afterAll, afterEach } from "vitest";

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

vitest.config.ts でこのセットアップファイルを読み込みます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    setupFiles: ["./vitest.setup.ts"],
    include: ["src/**/*.test.{ts,tsx}"],
  },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});

正常系テストでは server.use() を呼ぶ必要はありません。vitest.setup.ts で起動したMSWサーバーが、Orval自動生成ハンドラーのデフォルトレスポンスを返します。

SSRエラーハンドリングなど異常系テストでは、テスト単位で server.use() を使ってハンドラーを上書きします:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
test('ランキング API が500を返した場合、エラー表示になる', async () => {
  server.use(
    http.get('*/v1/rankings/:type', () =>
      HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }),
    ),
  );

  render(<UserList />, { wrapper: createWrapper() });
  expect(await screen.findByText('データの取得に失敗しました')).toBeInTheDocument();
});

5.4 Playwright 設定: webServer で MSW を起動する

Playwright側は webServer オプションでMSW有効のdev serverをテスト実行時に自動起動します。NEXT_PUBLIC_ENABLE_API_MOCKING=true でアプリ側のMSW ProviderをONにするのがポイントです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 1 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: "html",

  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
    /* モバイルビューポート(デザイン基準幅 428px) */
    viewport: { width: 428, height: 926 },
  },

  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"], viewport: { width: 428, height: 926 } },
    },
  ],

  webServer: {
    command: "NEXT_PUBLIC_ENABLE_API_MOCKING=true pnpm dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 60_000,
  },
});

ビューポートはモバイル専用の利用者向けサイトの設定例です。PC・モバイル両対応のサイトでは projects にPCとモバイルの2つを並べる構成にしています(§4のサイト別差分テーブル参照)。

CI で workers: 1 にしている理由

§2の選定で「並列実行が無料」を決定打に挙げておきながら、上記configではCIに限り workers: 1 を指定しています。理由はMSWの状態競合の回避です。server.use()server.resetHandlers() はプロセス内で共有されるグローバルstateを操作します。同一Next.js dev serverに対してテストを並列に走らせると、ハンドラーの上書きが相互に干渉します。今回はまず「19本のクリティカルパスをグリーンに通す」ことを優先し、安定性を取りました。

並列度を上げたい場合の選択肢は2つあります。

  • dev serverを workers 数だけ起動して水平分割: playwright.config.tswebServer を複数起動するか、または port を動的に割り当てる方法。各テストを独立したMSWプロセスへ振り分ける形になる
  • CI matrix で spec単位の分割: GitHub Actions / CircleCIのmatrix機能でspecファイルをチャンク化し、ジョブを並列実行する

今回は19本・40秒のスケールでは投資効果が薄かったため workers: 1 を選びました。本数が増えてきたタイミングで上記いずれかに切り替えます。

package.json 側のスクリプトはこうなります:

1
2
3
4
5
6
7
8
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "e2e": "playwright test",
    "e2e:ui": "playwright test --ui"
  }
}

CIでは pnpm testpnpm e2e を順に実行するだけです。

5.5 networkidle は使わない

Playwrightの waitForLoadState('networkidle') は、ネットワークリクエストが一定時間途絶えるまで待機する機能です。一見便利ですが、不安定なテストの原因 です。

1
2
3
4
5
6
7
8
// ❌ 不安定: ポーリングや WebSocket があると永遠に待つ
await page.waitForLoadState("networkidle");

// ✅ SSR ページ: データ依存の要素が表示されるまで待つ
await expect(page.getByText("サンプル ユーザー")).toBeVisible();

// ✅ CSR ページ: フォーム要素の表示で待つ
await expect(page.getByRole("button", { name: "ログインする" })).toBeVisible();

5.6 既知の制約

テスト基盤の設計段階で明らかになった制約と、その対策を整理しておきます:

制約影響対策
SSR prefetch に page.route() が効かないE2E でエラー系テストが偽陽性になるVitest + MSW で SSR エラーテストを補完
MSW モックのためサイト間データ連携の検証不可サイトをまたいだデータ整合性は E2E では検証できないシナリオテスト(手動)およびユースケーステスト(バックエンド)で担保
waitForLoadState('networkidle') が不安定MSW 環境でテストがフレーキーになるwaitForSelectorwaitForResponse で特定の要素・API を待つ

特に サイト間データ連携 はE2E(MSWモック)の根本的な限界です。利用者向けサイトでの操作が提供者向けサイトに反映されるかといった検証は、バックエンドのユースケーステストや手動のシナリオテストで担保する設計です。


6. Sentry を本番運用に乗せる設計

Sentryは アプリ内のあらゆるデータを SaaS に送る 仕組みです。送信対象は、エラーのスタックトレース・HTTPリクエストの内容・ユーザー入力(breadcrumb)・Session ReplayのDOMなど多岐にわたります。デフォルト設定のまま本番に出すと、認証トークン・個人情報・業務上の機密データがSentryのサーバー側に保存され、漏洩時のリスクが大きくなります。

特にチャット・通話・問い合わせフォームなどユーザー間のコミュニケーションを扱うサービスでは、本文・電話番号・決済情報がbreadcrumbやフォーム値経由でSentryに届きやすい構造です。

このセクションでは、Turbopack環境で初期化に詰まったポイントを共有します。さらに、本番運用に乗せるための 3層防御のマスキングソースマップを Sentry にアップロードしない選択肢、そして リリース前チェックリスト も解説します。

6.1 Turbopack で instrumentation-client.ts を使う

これが最もつまずいたポイントです。

Sentryの公式ドキュメントに従って sentry.client.config.ts を作成しましたが、ブラウザの Console に Sentry のログが一切出ません でした。

原因は Next.js 16 + Turbopack 環境では、sentry.client.config.ts が自動読み込みされないことでした。

Sentryの @sentry/nextjs はwebpackのエントリポイントに sentry.client.config.ts を自動注入する仕組みです。しかし、Turbopackはこのwebpackプラグインを使いません。

解決策: ファイルを instrumentation-client.ts にリネームする。

❌ sentry.client.config.ts    → Turbopack では読み込まれない
✅ instrumentation-client.ts  → Turbopack の client instrumentation として認識される

instrumentation-client.ts はNext.jsの公式APIであり、Turbopackでもクライアント側の初期化コードとして確実に実行されます。

リネーム後にdev serverを再起動したところ、ConsoleにSentryのトレースログが大量に出力され、正常動作を確認できました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// instrumentation-client.ts(旧 sentry.client.config.ts)
import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  enabled: process.env.NODE_ENV === "production",
  tracesSampleRate: 0.1,
  replaysOnErrorSampleRate: 1.0,
  integrations: [Sentry.replayIntegration()],
});

Sentry SDKを確認すると、webpackビルドでは2ファイルを順番に検索する実装でした。sentry.client.config.tsinstrumentation-client.ts の順です。後者が存在すれば前者の非推奨警告を出す挙動です。Turbopack移行を見据えると、今後は instrumentation-client.ts を使うのが正解です。

サーバー側は instrumentation.ts でSSR/RSC由来のエラーを捕捉します。onRequestError フックを定義しておくと、SSRレンダリング中の例外もSentryに届きます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    await import("./sentry.server.config");
  }
  if (process.env.NEXT_RUNTIME === "edge") {
    await import("./sentry.edge.config");
  }
}

export async function onRequestError(
  error: unknown,
  request: {
    path: string;
    method: string;
    url: string;
    headers: Record<string, string | string[] | undefined>;
  },
  context: { routerKind: string; routePath: string; routeType: string },
) {
  const { captureRequestError } = await import("@sentry/nextjs");
  captureRequestError(error, request, context);
}

instrumentation-client.ts(ブラウザ側)と instrumentation.ts(サーバー側)の2ファイルが揃って、はじめてApp Routerのエラー全域がカバーされます。

6.2 マスキング設計: 3層防御で機密データを Sentry に送らない

Sentryの beforeSend フックで簡易マスクをかけている記事は多く見かけます。しかし、公式の “Scrubbing Sensitive Data” を基準に再設計したところ、beforeSend だけではギャップが残る と判明しました。

漏れがちな箇所:

  • event.userSentry.setUser の値)
  • スタックトレースに含まれるローカル変数値(frame.vars
  • event.extra / event.contexts / event.tags
  • console カテゴリのbreadcrumb
  • Session ReplayのDOMテキスト・入力値

これらをすべてカバーするため、SDK側・Sentryサーバ側・組織設定の3層で防御する設計にしました。

%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 60, "rankSpacing": 80, "padding": 20}, "themeVariables": {"fontSize": "15px"}}}%%
flowchart LR
    A["アプリ<br/>(エラー発生)"]
    L1["第1層: SDK フック<br/>beforeSend / beforeBreadcrumb<br/>送信前にマスク"]
    B["Sentry サーバ<br/>(受信)"]
    L2["第2層: Server-side<br/>Data Scrubber<br/>保管前に追加キーをマスク"]
    C["永続化 / UI 表示"]
    L3["第3層: Generative AI<br/>機能停止<br/>組織設定 OFF + 学習オプトアウト"]

    A --> L1 --> B --> L2 --> C
    L3 -. 組織設定 .-> B

    classDef stage fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#0d47a1,padding:12px
    classDef layer fill:#fff3e0,stroke:#e65100,stroke-width:2.5px,color:#bf360c,padding:14px
    classDef result fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,color:#4a148c,padding:12px

    class A,B stage
    class L1,L2,L3 layer
    class C result
防御内容設定箇所役割
第1層SDK の beforeSend / beforeSendTransaction / beforeBreadcrumb で送信前にマスクアプリコード送信そのものを止める最強の防御
第2層Sentry 組織の Data Scrubber を有効化、追加キーを登録Sentry UI第1層の漏れを最終ガード。SDK 改修なしで即時更新可
第3層Generative AI 機能を組織全体で停止、学習利用はオプトアウト維持Sentry UIデータの AI 学習利用を明示的に防ぐ

第1層: SDK フックの実装方針

Sentry.init のオプションと beforeSend / beforeBreadcrumb で以下を徹底します:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import * as Sentry from "@sentry/nextjs";

// アプリ側で `Sentry.setContext("custom", {...})` のように追加した
// カスタムコンテキストだけホワイトリスト適用する。
// SDK 自動付与の `runtime` / `os` / `browser` / `trace` / `react` 等は触らない
const ALLOWED_CUSTOM_CONTEXTS = ["app"]; // app_name / app_version / build_id 等

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  enabled: process.env.NODE_ENV === "production",
  // v10 でも既定 false だが、SDK アップデートでの挙動変化に備えて明示
  sendDefaultPii: false,
  beforeSend(event) {
    // ① ユーザーコンテキストを完全削除(ID も送らない)
    //    ⚠️ trade-off: ID を完全に消すと Sentry の「影響ユーザー数」が
    //       常に 1 になり、issue triage の重要シグナルが失われる。
    //       PII を出さずに unique count だけ維持したい場合は
    //       `event.user = { id: sha256(originalId) }` のように
    //       hash 化 ID を保持する選択肢もある
    delete event.user;

    // ② スタックトレースのローカル変数値を削除(サーバー側 SDK 向けの保険)
    //    `frame.vars` は Node SDK の `localVariablesIntegration`
    //    (V8 inspector 利用、サーバー側のみ)で初めて埋まる。
    //    ブラウザ SDK では基本 no-op だが、Next.js のサーバー側 SDK で同
    //    integration を有効化したときの漏洩防止として残しておく
    event.exception?.values?.forEach((exception) => {
      exception.stacktrace?.frames?.forEach((frame) => {
        delete frame.vars;
      });
    });

    // ③ カスタム追加した extra / contexts をホワイトリストで絞る
    //    ⚠️ event.contexts には SDK 自動付与の `runtime` / `os` / `browser` /
    //       `trace` / `react` が含まれる。distributed tracing と環境情報の
    //       要なので、これらを丸ごと上書きしてはいけない
    if (event.contexts?.custom) {
      event.contexts.custom = pickAllowed(
        event.contexts.custom as Record<string, unknown>,
        ALLOWED_CUSTOM_CONTEXTS,
      );
    }
    event.extra = {}; // 初期は空。必要が生じたらレビューを経て許可キーを追加

    // ④ HTTP リクエストの機密フィールドを削除
    if (event.request) {
      delete event.request.cookies;
      if (event.request.headers) {
        delete event.request.headers["authorization"];
        delete event.request.headers["cookie"];
      }
    }

    return event;
  },
  beforeBreadcrumb(breadcrumb) {
    // ⑤ console 出力をマスク(誤って機密情報を console.log した場合の保険)
    if (breadcrumb.category === "console") {
      return { ...breadcrumb, message: "[masked console output]", data: undefined };
    }
    // ⑥ ui.input カテゴリ(フォーム入力等)をマスク
    if (breadcrumb.category?.startsWith("ui.input")) {
      return { ...breadcrumb, message: "[masked user input]" };
    }
    return breadcrumb;
  },
});

設計のポイントは カスタム拡張に限ったホワイトリスト方式 です。SDKが自動付与する runtime / os / browser / trace 等は調査・tracingに不可欠なので保持します。そのうえで、アプリ側で Sentry.setContext などで追加したカスタム値だけ許可キーで絞ります。「機密キーをブラックリストで弾く」とリスト漏れで漏洩しますが、「許可キーだけ通す」設計なら新しいキーが追加されたときに自動的にブロックされます。許可キーの追加はADRで履歴を残し、レビューを経てから反映する運用にしています。

pickAllowed ヘルパーはコピペで動くようにジェネリックで定義しておきます。

1
2
3
4
5
6
7
8
9
function pickAllowed<T extends Record<string, unknown>>(
  obj: T | undefined,
  allowed: readonly string[],
): Partial<T> {
  if (!obj) return {};
  return Object.fromEntries(
    Object.entries(obj).filter(([key]) => allowed.includes(key)),
  ) as Partial<T>;
}

参考: Sentry Dev Docs — Browser Tracing

第1層補足: Session Replay は必ずマスクを有効化する

Session Replayを使うなら、DOM上のテキストや入力値が録画されないように以下を必須にします:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Sentry.init({
  // ...
  integrations: [
    Sentry.replayIntegration({
      maskAllText: true, // すべてのテキストをマスク
      maskAllInputs: true, // すべての input 値をマスク
      blockAllMedia: true, // 画像・動画をブロック
    }),
  ],
});

「特定の要素だけ表示する」運用にしたい場合も、デフォルトでマスクし、必要な要素だけ解除する 方向にすると安全です。逆方向(デフォルト表示・機密要素だけブロック)はクラス付け漏れで簡単に破綻します。

第2層: Server-side Data Scrubber

第1層がSDKのバグや実装漏れで効かなかったときの最終ガードとして、Sentry組織側でもServer-side Scrubberを有効化します。

Sentry UIの Organization Settings → Security & Privacy で以下を設定:

  • Data Scrubber: ON
  • Use Default Scrubbers: ON: 既定キー(password / token / api_key / access_token / auth 等)をマスク
  • Additional Sensitive Fields にサービス固有の機密フィールド名を追加。例:
    • 連絡先系: telephone / phone / tel / email / birthday
    • ID系: user_id / account_id
    • コンテンツ系: message / comment / memo
    • 決済系: card_number / payment_method / order_amount
  • Prevent Storing of IP Addresses: ON

第1層と第2層を両方使う最大の利点は、SDK の再リリース不要で即座にマスク追加できる 点です。新しい機密フィールドが見つかったとき、Sentry UIで1行追加すれば全サイトに即時反映されます。

第3層: Generative AI 機能の停止

SentryにはSeer(Auto-Fix, Issue Summary等)というGenerative AI機能があります。エラー内容をAIが要約・解析する便利な機能ですが、機密データを含むエラーが AI に渡される可能性 があります。

機密度の高いサービスでは、組織全体で停止するのが安全です:

  • Organization Settings → “Show Generative AI Features” を OFF
  • Seerの各機能(Auto-Fix, Issue Summary等)も個別にOFF
  • データのAI学習利用はデフォルトで opt-out(SentryのAI Privacy Principles参照)だが、設定画面でオフ状態を確認しスクリーンショットを残す

Sentryのプライバシーポリシーには次の記載があります。

By default, your data will not be used to train any generative AI models without your permission

とはいえ、念のため設定画面で オフ状態を確認・スクリーンショットを保管 しておくと、社内のセキュリティ監査や問い合わせがあったときに即答できます。

6.3 ソースマップを Sentry にアップロードしないという選択肢

Sentryのドキュメントに従えば、CIでソースマップをアップロードしてブラウザに公開しない(hidden-source-map)構成が標準です。Sentry上でスタックトレースが元のソースコードに解決され、調査が一気に速くなります。

しかし、ソースマップそのものを Sentry に置かない という選択肢もあります。

ソースマップに含まれる関数名・変数名・コード断片そのものが「Sentryに置きたくない情報」になりえる場合です。万が一Sentry側で漏洩が起きたとき、ソースマップがあるとアプリの内部実装が完全に復元できてしまいます。

ローカルで手動解決する運用

そこで、機密度の高いサービスでは以下の運用に切り替える選択肢があります:

  • ビルド時に .next/ 配下へソースマップを生成する(ローカル保持)
  • withSentryConfigsourcemaps.disabletrue に固定し、Sentryへのアップロードを止める
  • エラー解析時はローカルでソースマップを使って手動解決

具体的な設定は次の通りです。

1
2
3
4
5
6
7
8
// next.config.js
const { withSentryConfig } = require("@sentry/nextjs");

module.exports = withSentryConfig(nextConfig, {
  sourcemaps: {
    disable: true, // Sentry へのソースマップアップロードを止める
  },
});

参考: Sentry Build Options — sourcemaps.disable / Source Maps (Next.js)

flowchart TD
    A["Sentry でエラー検知"] -->|Slack 通知| B["担当が気づく"]
    B --> C{".next/ に .map があるか?"}
    C -->|ある| E["③ source-map ライブラリで<br/>元ファイル・行番号を解決"]
    C -->|なければ| D["② git checkout &lt;該当 release のコミット&gt;<br/>&amp;&amp; pnpm build"]
    D --> E
    E --> F["④ 該当コードのスニペットを表示"]

このフローはスクリプト化(あるいはSlackスラッシュコマンド化)して、誰でもワンコマンドで回せる状態にしておくのが必須です。手動解決のままだと属人化します。

トレードオフと対策

課題対策
エラー調査時に手動解決の手間が入るスクリプト化(/resolve-sourcemap 的なコマンド)でワンコマンド化
担当者の手元ビルド環境が必要git checkout main && pnpm build を標準フローに含め、環境変数を固定
過去リリースの再現性エラーの release タグからコミットハッシュを引いてビルド
属人化スクリプトとデモ動画をチームに共有して、複数人が回せる状態を担保

調査効率は当然落ちます。しかし、「便利さ」と「漏洩時の影響範囲を最小に抑えること」のトレードオフ で後者を取る判断です。Sentryの標準構成が常に正解とは限りません。

6.4 リリース前チェックリスト

Sentryを本番運用に乗せる前は、以下を毎回確認します:

  • beforeSend の単体テストがPASS(マスキング処理が想定通り動くか)
  • sendDefaultPii: falseSentry.init に明示されている
  • ステージングで実エラーを発生させ、Sentry上のeventに PII / 認証情報が含まれないこと をサンプル確認(最低5件)
  • Sentry UIのData ScrubberがON、追加キーが登録済み
  • Generative AI機能がOFF、学習利用がオプトアウト状態
  • ソースマップがSentryにアップロードされていない(環境変数・ビルド設定で固定)
  • エビデンス(スクリーンショット・サンプルイベント)を社内ドキュメントに格納

特にサンプル確認は重要です。beforeSend を書いただけでは、本当に消えているかはSentry上で見てみないと分かりません。「実装した」と「機能している」は別物として運用に組み込みます。


7. コスト: テストを書く工数に見合うか

「テストを書く時間でバグ修正した方が早くない?」という疑問への回答です。

定性面の比較

テストなし(導入前)テストあり(導入後)
リグレッション検知手動で全画面再確認(30分〜1時間/回)CI で自動検知(数分)
バグ修正後の再確認修正者が手動で再テストpnpm e2e で即確認
本番エラー検知ユーザー報告経由(数時間〜数日遅れ)Sentry でリアルタイム通知

導入工数(実測)

作業工数成果物
Vitest 導入 + スキーマテスト13本1〜2時間(AI 生成 + レビュー)Zod スキーマ13本、PASS 13/13
E2E テスト6本(検索→詳細フロー)1〜2時間(AI 生成 + デバッグ)6本中6本グリーン、実行時間約40秒
Sentry 導入 + ErrorBoundary 統合1〜2時間(wizard + Turbopack 対応)3サイト分の instrumentation-client.ts 整備
合計約半日(4〜6時間)テスト19本 + 監視基盤

数字で見る回収ライン

導入前は1回の手動再確認に30分〜1時間かかっていました。導入後はCIによる自動検知に置き換わり、再確認時間はゼロです。バグ修正→デグレ確認のループが1日あたり3〜5回発生していた状況を踏まえると、1〜2日で導入工数を回収できる試算 です。

実装の大半はAI(Claude Code)で生成し、人間はレビューと動作確認が中心です。configの生成、テストコードの雛形作成、Zodスキーマからのテストケース導出はAIが得意な作業です。


8. 属人化防止: 自分だけがテストを書ける状態にしない

テスト基盤を導入しても、特定の人しかテストを書けない状態では意味がありません。

テンプレートの標準化

Playwrightのconfig、MSWの連携パターン、Vitestのセットアップファイルはすべてテンプレート化し、新しいサイトへの横展開はテンプレートのコピーで完了するようにしました。

AI による量産フロー

インプット:
  1. 対象コンポーネントのソースコード
  2. MSW ハンドラー(モックデータ)
  3. テスト項目(ユースケースから振り分けたもの)

→ AI が .test.tsx / .spec.ts を生成
→ 人間がレビュー + 動作確認

CI でのゲート

テストをCIに組み込むことで、テストが壊れたら PR を出した人が修正する 運用になります。テスト作成者だけが保守する状態にはなりません。


9. AI 生成テストの落とし穴: 「成功するテストしか作らない」問題

AIでテストを量産する際に気づいた重要な課題があります。

問題: コードから生成すると「バグを正解として固定する」

AIにソースコードを渡して「このコンポーネントのテストを書いて」と指示すると、現在の実装を正解とするテスト が生成されます。

ソースコード → AI → テスト生成
  → 「この実装の振る舞いが正しい」前提で期待値を設定
  → バグがあってもテストは通る

これは「テスト成功=品質の担保」という誤った安心感を生みます。

実体験: 同じ AI でも、入力が違えば結果が変わる

今回の導入では、Zodスキーマテスト13本とE2Eページ遷移フロー6本をAIで生成しました。結果は対照的でした:

  • Zod スキーマテスト: スキーマ定義そのものが「仕様」のため、コードから生成して問題なし。13本中13本が意図通りのテストになった
  • E2E ページ遷移フロー:「ユーザーがどう動線を通るか」という仕様情報を渡さなかったため、AIは実装の振る舞いをそのままテスト化。実装 PoC の域を出ない品質 にとどまった

→ 仕様情報を持たないテストは、実装が変わったときに「正しく壊れる」ことができません。これがPhase 2以降は仕様書ベースで生成すべき理由です。

対策: テストのインプットを「コード」ではなく「仕様」にする

❌ コード → AI → テスト(実装が正解になる)
✅ 仕様書 → AI → テスト → コードに対して実行(仕様が正解になる)

仕様書ベースでテストを先に書けば、テストが落ちたときに「コード側のバグ」と判断できます。本来のテストの使い方です。

使い分けの基準

すべてのテストを仕様ベースで書く必要はありません。テストの種類によって使い分けます。

テストの種類コードから生成してOK仕様から生成すべき
Zod スキーマバリデーション✅ スキーマ定義=仕様そのもの
スナップショットテスト✅ リグレッション検知が目的
E2E ページ遷移フロー✅ ユーザーの期待する動線
異常系・境界値テスト✅ 仕様上の制約を検証

段階的な進め方

Phase 1(基盤構築期): コードから生成 → リグレッション防止用
  - 「今の動作を壊さない」ためのテスト
  - 基盤の証明が目的なのでこれで OK

Phase 2(テスト拡充期): 仕様書から生成 → バグ発見用
  - ユースケース一覧をインプットに
  - AI にコードは渡さず「この仕様を満たすテストを書いて」と指示
  - テストが落ちたらコード側のバグ

Phase 3(運用期): 両方を組み合わせ
  - 仕様ベーステスト: 新機能・重要フロー
  - コードベーステスト: リファクタリング時のリグレッション防止

今回の導入では、基盤の証明が目的だったのでPhase 1(コードベース)で問題ありませんでした。次フェーズでは仕様書(ユースケース一覧)をインプットとする運用へ移行します。


まとめ

テスト基盤をゼロから整備して学んだことをまとめます。

  1. Playwright 選定の決め手は AI 連携・並列実行・マルチブラウザ — SSR + MSWのサポートは「msw/node をdev serverに同居させる設計」がコア。ランナー(Playwright/Cypress)には依存しない。3サイト横断でスケールするAI連携と並列実行が決定打
  2. 全ページ E2E にしない — テストピラミッドの原則に従い、クリティカルパス(ページ遷移フロー)はE2E、バリデーション等はVitestで分担する
  3. MSW ハンドラーを E2E と Vitest で共有する[...customHandlers, ...orvalHandlers] でカスタム優先・自動生成フォールバックの構成にする。モックの二重管理がなくなる
  4. MSW の2層構造は必須 — サーバー側(msw/node)とブラウザ側(msw/browser)の両方でMSWを起動しないと、SSR prefetchにモックが効かない
  5. Turbopack 時代の正解は instrumentation-client.ts — webpack専用の sentry.client.config.ts はTurbopackで読み込まれない
  6. Sentry のマスキングは beforeSend だけでは不十分 — SDKフック・組織側Server-side Scrubber・Generative AI機能の停止の3層で設計する。extra / contexts / tags はホワイトリスト方式が安全
  7. ソースマップを Sentry にアップロードしない選択肢もある — 機密度の高いサービスでは、ローカル保持+手動解決スクリプトで運用する方が漏洩時の影響範囲を抑えられる
  8. AI でテストを生成するなら「コード」ではなく「仕様」を起点に — コードから生成するとバグを正解として固定する。仕様書ベースで生成すれば、テスト失敗=コードのバグとして機能する
  9. AI で量産、人間はレビュー — config生成もテストコード作成もAIが得意。半日で3ツールの導入が完了した

テスト基盤は「1本目のテストを書くまで」が最も重いです。仕組みさえできれば、あとはテンプレートとAIで量産できます。


次回予告: AI(Claude Code / Devin / NotebookLM)を活用したテスト仕様書作成の実践。「コードから生成」を「仕様から生成」に切り替える具体的なワークフローを紹介予定です。