Next.js の LLM ストリーミングを外部 API なしで Playwright E2E テストしてみた

Next.js の LLM ストリーミングを外部 API なしで Playwright E2E テストしてみた

Next.js の LLM ストリーミングを、外部プロバイダーへの実通信なしでブラウザ E2E テストしました。テスト用プロバイダーを本番コードへ追加せず、逐次表示、429、途中切断、完了通知の欠落を検証します。
2026.07.20

はじめに

LLM を利用するチャット画面では、回答が完成してから一括表示されるのではなく、生成中の内容が少しずつ表示される体験が重要です。

一方、ストリーミングの E2E テストを実際の LLM プロバイダーへ接続すると、API 利用料が発生します。応答内容や速度も一定ではありません。HTTP 429 や途中切断を意図したタイミングで再現することも困難です。

今回は、OpenRouter を利用している Next.js アプリケーションを題材に、外部 LLM API へ通信しないブラウザ E2E テストを作りました。ブラウザへの逐次表示だけでなく、ストリーム開始前のエラー、途中切断、完了通知の欠落まで確認します。

テスト用プロバイダーは本番コードへ追加しません。Next.js の Route Handler と既存の OpenRouter アダプターを通る構成を維持します。

対象読者

  • Next.js で LLM の回答をストリーミング表示している方
  • LLM プロバイダーの差し替えを検討している方
  • 有料 API や実際の API キーを使わずにブラウザ E2E テストを行いたい方
  • 正常系だけでなく、途中切断や完了通知の欠落も再現したい方

検証環境

項目 バージョン
OS Windows
Node.js 24.14.0
npm 11.9.0
Next.js 15.5.9
React 19.1.0
Playwright 1.61.1
Google Chrome 150.0.7871.125

参考資料

構成

テストする通信経路

Next.js の Route Handler は、Web 標準の RequestResponse API を利用できます。今回の POST /api/generate も、上流の ReadableStream を持つ Response をブラウザへ中継する構成です。

ブラウザが接続する /api/generate はモックしません。アプリケーションは通常と同じ Route Handler を実行し、OpenRouter アダプターがリクエストを組み立てます。そのアダプターが最後に呼ぶ OpenRouter URL だけを、Next.js のサーバープロセス内で置き換えます。

ブラウザから見えるストリーム契約を固定する

バックエンドを差し替える前に、ブラウザから見えるストリーム契約を固定しました。

request:
  POST /api/generate
  { "messages": [{ "role": "user|assistant", "content": "..." }] }

success:
  Content-Type: text/event-stream
  data: {"choices":[{"delta":{"content":"..."}}]}

completion:
  data: [DONE]

pre-stream failure:
  HTTP status + JSON { "error": "...", "retryAfter"?: "..." }

サーバー側では、プロバイダー固有の処理を次のインターフェースへ分離しています。

src/lib/llm/contract.ts
export type StreamChatRequest = {
  messages: ChatMessage[];
  signal?: AbortSignal;
};

export interface StreamingLlmBackend {
  readonly id: string;
  streamChat(request: StreamChatRequest): Promise<Response>;
}

OpenRouter アダプターは stream: true を指定してリクエストを送ります。別のプロバイダーが異なるイベント形式を返す場合は、アダプターでアプリケーション共通の SSE 形式へ変換します。

ブラウザ側はプロバイダー名を知りません。次の 3 種類のイベントだけを処理します。

src/lib/llm/sse.ts
export type AppStreamEvent =
  | { type: "delta"; text: string }
  | { type: "error"; message: string }
  | { type: "done" };

この境界を設けると、バックエンドの認証、URL、リクエスト形式、固有イベントを変更しても、React 側の増分描画を変更せずに済みます。

実装

Next.js のサーバープロセスで fetch を置き換える

Playwright の webServer 設定を使い、テスト開始時に Next.js の開発サーバーを起動します。

Playwright は page.route()browserContext.route() を使って、ブラウザページが発行するリクエストをモックできます。

今回、ブラウザが発行するリクエストは /api/generate までです。OpenRouter へのリクエストは Next.js のサーバープロセスから発行されます。/api/generate をブラウザ側でモックすると、Route Handler、プロンプト生成、OpenRouter アダプター、SSE 中継を通らなくなります。

そこで、Playwright が起動する Next.js のプロセスへ CommonJS の preload を設定しました。Node.js の --require には、指定したモジュールを起動時に読み込む機能があります。詳細は Node.js の CLI ドキュメント を参照してください。

playwright.config.ts
import { defineConfig } from "@playwright/test";
import path from "node:path";

const preloadPath = path.resolve("tests/e2e/mock-openrouter.cjs");

export default defineConfig({
  testDir: "./tests/e2e",
  workers: 1,
  use: {
    baseURL: "http://127.0.0.1:3100",
    browserName: "chromium",
    channel: "chrome",
    headless: true,
  },
  webServer: {
    command: "npm run dev -- --hostname 127.0.0.1 --port 3100",
    url: "http://127.0.0.1:3100",
    reuseExistingServer: false,
    env: {
      ...process.env,
      NODE_ENV: "development",
      NODE_OPTIONS: `--require=${preloadPath}`,
      LLM_E2E_FETCH_MOCK: "1",
      OPENROUTER_API_KEY: "e2e-placeholder-not-a-secret",
    },
  },
});

テスト用 preload を本番環境で起動させない

サーバープロセスの fetch を書き換えるため、テスト用 preload が本番環境で読み込まれないようにします。

preload の先頭では、次の 2 条件を検査します。

tests/e2e/mock-openrouter.cjs
"use strict";

if (process.env.NODE_ENV === "production") {
  throw new Error("E2E fetch mock must never run with NODE_ENV=production");
}

if (process.env.LLM_E2E_FETCH_MOCK !== "1") {
  throw new Error("E2E fetch mock requires an explicit local-only opt-in");
}

置き換える URL は、OpenRouter の Chat Completions エンドポイントと完全一致させます。それ以外の fetch は元の実装へ渡します。

tests/e2e/mock-openrouter.cjs
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const originalFetch = globalThis.fetch;

globalThis.fetch = async function controlledFetch(input, init) {
  if (String(input) !== OPENROUTER_URL) {
    return originalFetch(input, init);
  }

  // 合成したシナリオに応じたレスポンスを返す
};

OpenRouter URL に来た未知のシナリオは、実際のネットワークへフォールバックさせません。

tests/e2e/mock-openrouter.cjs
return new Response(
  JSON.stringify({ error: { message: "unknown E2E scenario" } }),
  {
    status: 400,
    headers: { "Content-Type": "application/json" },
  },
);

これにより、テスト入力の誤りによって有料 API へ接続する経路を閉じています。preload の起動条件も別のユニットテストで確認します。

時間差のあるストリームを作る

SSE 文字列を一括で返すだけでは、React が途中状態を描画したことを確認できません。各チャンクを指定時間後に enqueue() する ReadableStream を作りました。

tests/e2e/mock-openrouter.cjs
const encoder = new TextEncoder();

function delayedStream(steps) {
  return new ReadableStream({
    start(controller) {
      let elapsed = 0;
      for (const step of steps) {
        elapsed += step.afterMs;
        setTimeout(() => {
          if (step.error) {
            controller.error(new Error(step.error));
          } else if (step.close) {
            controller.close();
          } else {
            controller.enqueue(encoder.encode(step.data));
          }
        }, elapsed);
      }
    },
  });
}

正常系では、最初の delta、不正な JSON イベント、次の delta、[DONE] を時間差で返します。CRLF と LF も混在させています。

tests/e2e/mock-openrouter.cjs
return streamResponse([
  {
    afterMs: 100,
    data: 'data: {"choices":[{"delta":{"content":"第一段階"}}]}\r\n\r\n',
  },
  { afterMs: 250, data: "data: this-is-not-json\n\n" },
  {
    afterMs: 650,
    data: 'data: {"choices":[{"delta":{"content":"第二段階"}}]}\n\n',
  },
  { afterMs: 100, data: "data: [DONE]\n\n" },
  { afterMs: 10, close: true },
]);

別のユニットテストでは、UTF-8 の日本語文字列と SSE の区切りをネットワークチャンクの途中で分割しました。プロバイダー側のチャンク境界に依存せず再構成できることを確認しています。

ブラウザで途中状態を確認する

E2E テストでは、最終結果だけでなく、最初の delta と次の delta の間を確認します。

tests/e2e/streaming.spec.ts
test("renders token-equivalent deltas incrementally", async ({ page }) => {
  await page.goto("/");
  await page.getByTestId("prompt-input")
    .fill("[[e2e:success]] 公開可能な合成入力");
  await page.getByTestId("send-button").click();

  const assistant = page.getByTestId("assistant-message").last();

  await expect(page.getByTestId("stream-status")).toBeVisible();
  await expect(assistant).toHaveText("第一段階");
  await expect(assistant).not.toContainText("第二段階");

  await expect(assistant).toHaveText("第一段階第二段階");
  await expect(page.getByTestId("stream-status")).toBeHidden();
});

最初の toHaveText("第一段階") が成功した時点では、生成中の表示が残っています。第二段階 はまだ DOM にありません。その後、2 つ目の delta と [DONE] を受け取って完了状態になります。

最終文字列だけを確認するテストでは、一括でバッファリングされた実装も通過します。途中状態を確認することで、今回維持したい利用者体験を直接テストできます。

正常系以外のシナリオを用意する

バックエンドを差し替える場合、失敗が発生した時点も重要です。今回は次の 4 シナリオを用意しました。

シナリオ テスト用レスポンス 期待する画面表示
正常完了 2 つの delta、不正イベント、[DONE] 段階表示後に生成中表示を消す
開始前エラー HTTP 429、JSON、Retry-After: 3 レート制限と待機秒を表示する
途中切断 1 つの delta 後に controller.error() 部分応答を保持し、途中切断と再送ボタンを表示する
完了通知の欠落 1 つの delta 後に正常 close、[DONE] なし 正常完了とせず、完了通知前終了と再送ボタンを表示する

開始前の HTTP エラーは、SSE を読み始める前に処理します。途中切断では、すでに受け取った部分応答を消しません。

transport error がなくても、[DONE] が来ないままストリームが閉じる場合があります。今回の契約では、正常な EOF を [DONE] と同一視しません。部分応答を保持し、完了通知前に終了したことを表示します。

実行結果

テストの実行

次のコマンドを実行しました。

npm test
npm run lint
npx tsc --noEmit
npm run build
npm run test:e2e
検証 結果
ストリーム契約と本番起動防止 10 / 10 成功
ESLint 成功
TypeScript --noEmit 成功
Next.js 本番ビルド 成功
Playwright ブラウザ E2E 4 / 4 成功、16.1 秒
外部 LLM プロバイダー呼び出し 0 回
Running 4 tests using 1 worker
4 passed (16.1s)

途中切断のテストでは、Next.js の開発サーバーへ failed to pipe response が出力されました。合成した切断がサーバーの SSE 中継まで到達した結果です。このシナリオでは想定した診断です。

最初に E2E テストを作成した際は、4 件中 3 件しか通りませんでした。HTTP 429 に対してアプリケーションが既存の日本語メッセージを表示する仕様なのに、テスト側が HTTP 429 という文字列を期待していたためです。

このときはアプリケーションをテストへ合わせず、現在の画面仕様を確認してテストの期待値だけを修正しました。E2E テストが失敗した場合、常に本番コードが誤っているとは限りません。

分かったこと

今回の E2E テストでは、ブラウザへの入力から Next.js の Route Handler、OpenRouter アダプター、SSE の中継を経て、画面に回答が少しずつ表示されるところまで確認できました。正常完了だけでなく、開始前のエラー、途中切断、[DONE] がないまま終了するケースも区別できています。

一方で、Vercel や CDN がレスポンスをバッファリングしないか、デプロイ環境のタイムアウトがどう働くかまでは分かりません。実際のバックエンドにおける応答速度やエラー形式も未確認です。このあたりは、バックエンド候補を決めた後にプレビュー環境で確かめる必要があります。

別のストリーミングバックエンドを追加するときも、認証やリクエスト形式の違いをアダプターの中に閉じ込め、ブラウザには共通の delta、error、done を返す形が扱いやすそうです。ただし、これは今回の結果から考えた設計方針であり、別のバックエンドで確認済みの事実ではありません。非同期ジョブ方式を選ぶ場合は、同じ形へ無理に合わせず、別の API と画面設計として考えます。

まとめ

Next.js のサーバープロセスで OpenRouter 宛ての fetch だけを置き換え、外部 LLM API へ通信しないブラウザ E2E テストを作りました。この仕組みがあれば、将来 LLM バックエンドを変更するときも、現在のストリーミング体験を外部 API の費用や応答の変動に依存せず確認できます。

LLM ストリーミングの E2E テストを検討している方の参考になれば幸いです。

付録

E2E テスト側で使用したコード全文を掲載します。アプリケーション側には、prompt-inputsend-buttonassistant-messagestream-statuserror-panelretry-buttondata-testid が必要です。

Playwright の設定全文
playwright.config.ts
import { defineConfig } from "@playwright/test";
import path from "node:path";

const preloadPath = path.resolve("tests/e2e/mock-openrouter.cjs");

export default defineConfig({
    testDir: "./tests/e2e",
    fullyParallel: false,
    workers: 1,
    reporter: "line",
    outputDir: "test-results",
    use: {
        baseURL: "http://127.0.0.1:3100",
        browserName: "chromium",
        channel: "chrome",
        headless: true,
        trace: "retain-on-failure",
    },
    webServer: {
        command: "npm run dev -- --hostname 127.0.0.1 --port 3100",
        url: "http://127.0.0.1:3100",
        reuseExistingServer: false,
        timeout: 120_000,
        env: {
            ...process.env,
            NODE_ENV: "development",
            NODE_OPTIONS: `--require=${preloadPath}`,
            LLM_E2E_FETCH_MOCK: "1",
            OPENROUTER_API_KEY: "e2e-placeholder-not-a-secret",
        },
    },
});
OpenRouter の fetch mock 全文
tests/e2e/mock-openrouter.cjs
"use strict";

if (process.env.NODE_ENV === "production") {
    throw new Error("E2E fetch mock must never run with NODE_ENV=production");
}
if (process.env.LLM_E2E_FETCH_MOCK !== "1") {
    throw new Error("E2E fetch mock requires an explicit local-only opt-in");
}

const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const originalFetch = globalThis.fetch;
const encoder = new TextEncoder();

function delayedStream(steps) {
    return new ReadableStream({
        start(controller) {
            let elapsed = 0;
            for (const step of steps) {
                elapsed += step.afterMs;
                setTimeout(() => {
                    if (step.error) {
                        controller.error(new Error(step.error));
                    } else if (step.close) {
                        controller.close();
                    } else {
                        controller.enqueue(encoder.encode(step.data));
                    }
                }, elapsed);
            }
        },
    });
}

function streamResponse(steps) {
    return new Response(delayedStream(steps), {
        status: 200,
        headers: { "Content-Type": "text/event-stream; charset=utf-8" },
    });
}

globalThis.fetch = async function controlledFetch(input, init) {
    if (String(input) !== OPENROUTER_URL) {
        return originalFetch(input, init);
    }

    const request = JSON.parse(String(init?.body ?? "{}"));
    const messages = Array.isArray(request.messages) ? request.messages : [];
    const lastUser = [...messages].reverse().find((message) => message?.role === "user");
    const scenario = String(lastUser?.content ?? "");

    if (scenario.includes("[[e2e:success]]")) {
        return streamResponse([
            { afterMs: 100, data: "data: {\"choices\":[{\"delta\":{\"content\":\"第一段階\"}}]}\r\n\r\n" },
            { afterMs: 250, data: "data: this-is-not-json\n\n" },
            { afterMs: 650, data: "data: {\"choices\":[{\"delta\":{\"content\":\"第二段階\"}}]}\n\n" },
            { afterMs: 100, data: "data: [DONE]\n\n" },
            { afterMs: 10, close: true },
        ]);
    }

    if (scenario.includes("[[e2e:pre-error]]")) {
        return new Response(JSON.stringify({ error: { message: "synthetic rate limit" } }), {
            status: 429,
            headers: { "Content-Type": "application/json", "Retry-After": "3" },
        });
    }

    if (scenario.includes("[[e2e:disconnect]]")) {
        return streamResponse([
            { afterMs: 100, data: "data: {\"choices\":[{\"delta\":{\"content\":\"部分応答\"}}]}\n\n" },
            { afterMs: 250, error: "synthetic mid-stream disconnect" },
        ]);
    }

    if (scenario.includes("[[e2e:missing-done]]")) {
        return streamResponse([
            { afterMs: 100, data: "data: {\"choices\":[{\"delta\":{\"content\":\"完了前\"}}]}\n\n" },
            { afterMs: 100, close: true },
        ]);
    }

    return new Response(JSON.stringify({ error: { message: "unknown E2E scenario" } }), {
        status: 400,
        headers: { "Content-Type": "application/json" },
    });
};
ブラウザ E2E テスト全文
tests/e2e/streaming.spec.ts
import { expect, test } from "@playwright/test";

async function send(page: import("@playwright/test").Page, prompt: string) {
    await page.goto("/");
    await page.getByTestId("prompt-input").fill(prompt);
    await page.getByTestId("send-button").click();
}

test("renders token-equivalent deltas incrementally and completes despite a malformed event", async ({ page }) => {
    await send(page, "[[e2e:success]] 公開可能な合成入力");

    const assistant = page.getByTestId("assistant-message").last();
    await expect(page.getByTestId("stream-status")).toBeVisible();
    await expect(assistant).toHaveText("第一段階");
    await expect(assistant).not.toContainText("第二段階");
    await expect(assistant).toHaveText("第一段階第二段階");
    await expect(page.getByTestId("stream-status")).toBeHidden();
    await expect(page.getByTestId("error-panel")).toHaveCount(0);
});

test("shows a pre-stream provider error separately from a stream interruption", async ({ page }) => {
    await send(page, "[[e2e:pre-error]] 公開可能な合成入力");

    await expect(page.getByTestId("error-panel")).toContainText("レート制限中");
    await expect(page.getByText("サーバからの推奨待機秒: 3s")).toBeVisible();
    await expect(page.getByTestId("assistant-message").last()).toContainText("レート制限中");
    await expect(page.getByTestId("stream-status")).toBeHidden();
});

test("retains partial output and identifies a mid-stream disconnect", async ({ page }) => {
    await send(page, "[[e2e:disconnect]] 公開可能な合成入力");

    const assistant = page.getByTestId("assistant-message").last();
    await expect(assistant).toContainText("部分応答");
    await expect(page.getByTestId("error-panel")).toContainText("途中で切断");
    await expect(assistant).toContainText("途中で切断");
    await expect(page.getByTestId("retry-button")).toBeVisible();
    await expect(page.getByTestId("stream-status")).toBeHidden();
});

test("treats EOF without the completion event as an incomplete stream", async ({ page }) => {
    await send(page, "[[e2e:missing-done]] 公開可能な合成入力");

    const assistant = page.getByTestId("assistant-message").last();
    await expect(assistant).toContainText("完了前");
    await expect(page.getByTestId("error-panel")).toContainText("完了通知前");
    await expect(page.getByTestId("stream-status")).toBeHidden();
});
preload の起動条件テスト全文
tests/unit/e2e-fetch-guard.test.mjs
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
import test from "node:test";

const preload = path.resolve("tests/e2e/mock-openrouter.cjs");

function requireMock(environment) {
    return spawnSync(process.execPath, ["--require", preload, "--eval", "void 0"], {
        cwd: process.cwd(),
        encoding: "utf8",
        env: { ...process.env, ...environment },
    });
}

test("the E2E fetch mock refuses production", () => {
    const result = requireMock({ NODE_ENV: "production", LLM_E2E_FETCH_MOCK: "1" });
    assert.notEqual(result.status, 0);
    assert.match(result.stderr, /must never run with NODE_ENV=production/);
});
test("the E2E fetch mock requires explicit local opt-in", () => {
    const result = requireMock({ NODE_ENV: "development", LLM_E2E_FETCH_MOCK: "0" });
    assert.notEqual(result.status, 0);
    assert.match(result.stderr, /requires an explicit local-only opt-in/);
});

test("the E2E fetch mock can preload only with both development guards", () => {
    const result = requireMock({ NODE_ENV: "development", LLM_E2E_FETCH_MOCK: "1" });
    assert.equal(result.status, 0, result.stderr);
});

この記事をシェアする

関連記事