
Next.js の LLM ストリーミングを外部 API なしで Playwright E2E テストしてみた
はじめに
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 |
参考資料
- Playwright: Web server
- Playwright: Mock APIs
- Playwright:
page.route() - Node.js:
--requireCLI option - Next.js: Route Handlers
構成
テストする通信経路
Next.js の Route Handler は、Web 標準の Request と Response 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"?: "..." }
サーバー側では、プロバイダー固有の処理を次のインターフェースへ分離しています。
export type StreamChatRequest = {
messages: ChatMessage[];
signal?: AbortSignal;
};
export interface StreamingLlmBackend {
readonly id: string;
streamChat(request: StreamChatRequest): Promise<Response>;
}
OpenRouter アダプターは stream: true を指定してリクエストを送ります。別のプロバイダーが異なるイベント形式を返す場合は、アダプターでアプリケーション共通の SSE 形式へ変換します。
ブラウザ側はプロバイダー名を知りません。次の 3 種類のイベントだけを処理します。
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 ドキュメント を参照してください。
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 条件を検査します。
"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 は元の実装へ渡します。
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 に来た未知のシナリオは、実際のネットワークへフォールバックさせません。
return new Response(
JSON.stringify({ error: { message: "unknown E2E scenario" } }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
これにより、テスト入力の誤りによって有料 API へ接続する経路を閉じています。preload の起動条件も別のユニットテストで確認します。
時間差のあるストリームを作る
SSE 文字列を一括で返すだけでは、React が途中状態を描画したことを確認できません。各チャンクを指定時間後に enqueue() する ReadableStream を作りました。
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 も混在させています。
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 の間を確認します。
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-input、send-button、assistant-message、stream-status、error-panel、retry-button の data-testid が必要です。
Playwright の設定全文
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 全文
"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 テスト全文
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 の起動条件テスト全文
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);
});





