Worker をローカルでテストする

CloudflareBeginner
オンラインで実践に進む

はじめに

サポート 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 APIoutbound 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 のリグレッションを再現して、期待される契約を弱めずに実装を修正しました。入力エラーと上流サービスエラーのケースを追加し、ケースごとにフィクスチャの状態をリセットして、各ランタイムを破棄しました。テストスイートは、クラウド認証情報やリソースへの書き込みを必要とせず、ローカルで再現可能な状態を維持しています。