はじめに
サポート API は正しいリクエストを受け付けますが、不正な JSON を受け取るとクラッシュします。この報告を失敗するテストに変え、解析境界を修正し、入力検証と上流サービスのエラーハンドリングを維持するテストスイートへ拡張します。
この独立した VM には、「サポートリクエスト API を構築する」で扱ったルーティング概念に基づく小さな API が用意されています。ただし、意図的に 1 つのリグレッションが含まれています。Node.js 22.22.0、Wrangler 4.131.1、Miniflare 4.20260730.0 は /home/labex/project/worker-tests に準備済みです。Node の組み込みテストランナーを使って自分でテストを作成し、Miniflare を通じて workerd 内でハンドラーを実行します。前提条件は、JavaScript の基礎知識と前回の API レッスンです。テストのアサーション、フック、フィクスチャの分離については、この実験で説明します。
これはローカルだけで実行できる実験です。Cloudflare アカウントの認証、リモートリソース、以前の VM は必要ありません。Worker からのすべての外向きリクエストはローカルのフィクスチャでインターセプトされ、各ケースの終了後にテストランタイムは破棄されます。
Workers ランタイムに対するテストを作成する
このステップでは、用意されているサポート API を対象に、小さなテストスイートを作成します。テストは Node のテストランナーで実行しますが、リクエストはハンドラーを Node に直接 import するのではなく、Miniflare の workerd ランタイム内で実行されます。
独立したプロジェクトへ移動し、用意されているハンドラーと固定された依存関係を確認します。
cd /home/labex/project/worker-tests
node --version
npx wrangler --version
npm ls miniflare --depth=0
cat src/index.js
Node v22.22.0、Wrangler 4.131.1、そして直接依存する Miniflare 4.20260730.0 が表示されることを確認してください。これらのツールはインストール済みです。自分のマシンで実行する場合は、正確なテスト依存関係を npm install --save-dev miniflare@4.20260730.0 で追加します。既存の lockfile がある場合は npm ci を使用してください。この実験では、変化する latest タグに依存せず、4.x API とその対応する compatibility date を意図的に固定しています。
test/support.test.mjs を作成します。引用符付きの heredoc によって、モジュールがそのまま書き込まれます。test はテストケースを定義し、assert.equal はスカラー値を検証し、assert.deepEqual は構造化された JSON を比較します。非同期の各ケースでは、アサーションを行う前にレスポンスを待機します。
beforeEach は新しいランタイムを起動し、呼び出し一覧をリセットします。afterEach はテストが失敗した場合でもランタイムを破棄します。dispatchFetch はプロセス内でテスト用リクエストを送信します。ここで指定するホスト名は、デプロイ済み Worker を表すものではありません。outboundService は Worker からのすべての fetch をインターセプトし、ローカルのフィクスチャレスポンスを返します。インターネットへトラフィックを転送することはありません。想定した上流 URL とメソッドを確認し、正規化したリクエストを記録します。cf: false は Cloudflare リクエストメタデータのサンプル取得を無効にします。ログイン、リモートバインディング、クラウドへのデプロイは使用しません。
cat > test/support.test.mjs <<'JS'
import {test, beforeEach, afterEach} from 'node:test';
import assert from 'node:assert/strict';
import {fileURLToPath} from 'node:url';
import {Miniflare} from 'miniflare';
let mf;
let calls;
beforeEach(() => {
calls = [];
mf = new Miniflare({
modules: true,
scriptPath: fileURLToPath(new URL('../src/index.js', import.meta.url)),
compatibilityDate: '2026-07-30',
cf: false,
bindings: {UPSTREAM_URL: 'https://tickets.test'},
outboundService: async (request) => {
assert.equal(request.url, 'https://tickets.test/tickets');
assert.equal(request.method, 'POST');
const body = await request.json();
calls.push(body);
if (body.subject === 'simulate-outage') {
return new Response('SIMULATED_INTERNAL_DETAIL', {status: 503});
}
return Response.json({ticket: `demo-${calls.length}`, subject: body.subject}, {status: 201});
}
});
});
afterEach(async () => { await mf.dispose(); });
test('health stays public', async () => {
const response = await mf.dispatchFetch('http://worker.test/health');
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {status: 'ok'});
assert.equal(calls.length, 0);
});
test('valid request reaches the local fixture', async () => {
const response = await mf.dispatchFetch('http://worker.test/requests', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({subject: ' Printer offline '})
});
assert.equal(response.status, 201);
assert.deepEqual(await response.json(), {ticket: 'demo-1', subject: 'Printer offline'});
assert.deepEqual(calls, [{subject: 'Printer offline'}]);
});
JS
標準の Node テストコマンドを実行します。--test-reporter=spec を指定すると、ケース名と合計が読みやすく表示されます。
node --test --test-reporter=spec test/support.test.mjs
2 件のテストが成功し、失敗が 0 件になることを確認してください。health は上流サービスを呼び出してはいけません。正しいリクエストでは demo-1 が返り、前後の空白を削除した subject が 1 回だけ送信される必要があります。この最初のテストでは、まだ不正な JSON を扱いません。検証機能を使って、テストスイートと独立したランタイムの動作を確認します。
基盤となるインターフェースについては、Miniflare API と outbound service option を参照してください。本番ネットワークでの動作については、別途デプロイテストが必要です。
失敗するリグレッションテストを追加する
このステップでは、報告された不具合をテストで再現します。不正な JSON には予測可能な 400 レスポンスを返すべきですが、最初のハンドラーでは解析例外がそのまま外へ伝播します。
>> を使ってテストを 1 件追加します。これにより、既存の 2 件のテストは保持されます。本文には不完全な JSON テキスト { を指定します。JSON を解析する前にレスポンスステータスを検証するため、失敗した場合に HTTP 契約のどこが問題かを明確に確認できます。
cat >> test/support.test.mjs <<'JS'
test('malformed JSON returns 400 before the upstream', async () => {
const response = await mf.dispatchFetch('http://worker.test/requests', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: '{'
});
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), {error: 'invalid_json'});
assert.equal(calls.length, 0);
});
JS
node --test --test-reporter=spec test/support.test.mjs
3 件のテストのうち、2 件が成功し、不正な JSON のテストが失敗することを確認してください。アサーションには、実際の値 500 と期待値 400 の違いが表示され、プロセスは 0 以外の終了コードで終了します。ランタイムが基盤の解析例外を表示することもあります。これは意図した不具合であり、期待ステータスを 500 に変更する理由ではありません。import エラー、パッケージ不足、既存ケースの失敗は別の問題です。
ハンドラー内で保護されていない await request.json() を確認してください。不正な JSON の場合、どのリクエストもフィクスチャへ到達してはいけません。不具合が残っている状態で検証を実行します。このステップでは、リグレッションテストが失敗し、既存ケースが成功することを確認します。次のステップで実装を修正します。
テストを弱めずに JSON 解析を修正する
このステップでは、JSON 解析の失敗だけを処理し、既存のルーティング、検証、上流サービスの処理を維持します。ハンドラーを、次の完全な修正版に置き換えてください。request.json() の周囲にある try/catch は、構文例外を HTTP 400 の JSON レスポンスへ変換します。上流サービス用の別の try/catch は、ネットワークエラーやレスポンスエラーを引き続き処理します。
cat > src/index.js <<'JS'
export default {
async fetch(request, env) {
const path = new URL(request.url).pathname;
if (path !== '/health' && path !== '/requests') {
return Response.json({error: 'not_found'}, {status: 404});
}
const allowed = path === '/health' ? 'GET' : 'POST';
if (request.method !== allowed) {
return Response.json({error: 'method_not_allowed'}, {
status: 405, headers: {Allow: allowed}
});
}
if (path === '/health') return Response.json({status: 'ok'});
const mediaType = (request.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (mediaType !== 'application/json') {
return Response.json({error: 'unsupported_media_type'}, {status: 415});
}
let body;
try {
body = await request.json();
} catch {
return Response.json({error: 'invalid_json'}, {status: 400});
}
if (!body || Array.isArray(body) || typeof body.subject !== 'string' ||
body.subject.trim().length < 1 || body.subject.trim().length > 80) {
return Response.json({error: 'invalid_subject'}, {status: 422});
}
const subject = body.subject.trim();
try {
const upstream = await fetch(`${env.UPSTREAM_URL}/tickets`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({subject})
});
if (!upstream.ok) {
return Response.json({error: 'upstream_unavailable'}, {status: 502});
}
const ticket = await upstream.json();
return Response.json({ticket: ticket.ticket, subject}, {status: 201});
} catch {
return Response.json({error: 'upstream_unavailable'}, {status: 502});
}
}
};
JS
node --test --test-reporter=spec test/support.test.mjs
変更していない不正な JSON のテストを含め、3 件すべてが成功することを確認してください。同じコマンドをもう一度実行し、新しいテストプロセスでも成功することを確認します。
node --test --test-reporter=spec test/support.test.mjs
どちらの実行でも、3 件成功、失敗 0 件になるはずです。各ケースでは新しいランタイムが作成され、フィクスチャの呼び出し一覧は空の状態から始まります。失敗しているテストを無効にしたり、テストスイートを成功させるために 500 レスポンスを受け入れたりしないでください。検証では、学習者が作成したテストと、別に用意された一連のランタイムレスポンスの両方を確認します。
境界のテスト範囲を広げ、ローカルで完了する
このステップでは、さらに 2 つのリグレッションを防ぎます。不正な入力が依存先へ到達することと、上流サービスの障害が成功リクエストとして見えることです。前の 3 件を削除せず、次のケースを追加します。
最初のテストでは、不正な JSON 値をループで処理して 422 を検証し、その後、サポートされていないテキスト入力に対して 415 を確認します。どのケースも上流サービスを呼び出してはいけません。2 つ目のテストでは、空のフィクスチャから開始し、依存先が 503 を返す状況を再現して、API が処理した 502 JSON を期待します。レスポンス本文全体を比較することで、フィクスチャ内部の診断情報が漏れることも防ぎます。
cat >> test/support.test.mjs <<'JS'
test('invalid subjects and media types never reach the upstream', async () => {
for (const body of [null, [], {}, {subject: 5}, {subject: ' '}, {subject: 'x'.repeat(81)}]) {
const response = await mf.dispatchFetch('http://worker.test/requests', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
});
assert.equal(response.status, 422);
assert.deepEqual(await response.json(), {error: 'invalid_subject'});
}
const response = await mf.dispatchFetch('http://worker.test/requests', {
method: 'POST', headers: {'Content-Type': 'text/plain'}, body: 'hello'
});
assert.equal(response.status, 415);
assert.deepEqual(await response.json(), {error: 'unsupported_media_type'});
assert.equal(calls.length, 0);
});
test('upstream errors are contained with fresh fixture state', async () => {
assert.equal(calls.length, 0);
const response = await mf.dispatchFetch('http://worker.test/requests', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({subject: 'simulate-outage'})
});
assert.equal(response.status, 502);
assert.deepEqual(await response.json(), {error: 'upstream_unavailable'});
assert.deepEqual(calls, [{subject: 'simulate-outage'}]);
});
JS
node --test --test-reporter=spec test/support.test.mjs
5 件すべてが成功し、失敗が 0 件になることを確認してください。テストスイートをもう一度実行します。各ケースでフィクスチャの状態がリセットされるため、demo-1 と記録される障害呼び出し 1 件は変わらないはずです。
node --test --test-reporter=spec test/support.test.mjs
すべての処理はローカルで完了しています。テスト URL は Miniflare にディスパッチされ、Worker からの外向き呼び出しはすべてインターセプトされ、各ランタイムは破棄されました。VM に Cloudflare のログイン情報が保存されていないことを確認します。
npx wrangler whoami --json
"loggedIn": false と表示されることを確認してください。未認証のステータスコマンドは、0 以外の終了コードで終了する場合があります。この実験ではログインしないでください。削除するクラウドリソースはありません。最後の検証を実行すると、実際のローカル API を確認し、修正版コードだけでなく、意図的に壊したコピーもテストが拒否することを確認できます。これらの評価用コピーがプロジェクトを変更することはありません。その後、VM を終了します。
ローカルランタイムテストを使うと、リグレッションを再現可能な形で検証できます。ただし、アカウント所有権、デプロイ設定、実際のインターネット上の依存先、エッジへの段階的な展開動作までは検証できません。これらにはコースのリモートチェックが必要です。
まとめ
API をローカルの Workers ランタイムで実行するテストを作成し、500 と 400 のリグレッションを再現して、期待される契約を弱めずに実装を修正しました。入力エラーと上流サービスエラーのケースを追加し、ケースごとにフィクスチャの状態をリセットして、各ランタイムを破棄しました。テストスイートは、クラウド認証情報やリソースへの書き込みを必要とせず、ローカルで再現可能な状態を維持しています。

