在本地测试 Worker

CloudflareBeginner
立即练习

简介

支持 API 可以接受有效请求,但遇到格式错误的 JSON 时会崩溃。你将把这份问题报告转换为一个失败测试,修复解析边界,并扩展测试套件,以保留输入验证和上游错误处理。

此独立虚拟机提供了一个基于「构建支持请求 API」中路由概念的小型 API,其中包含一个故意引入的回归问题。Node.js 22.22.0、Wrangler 4.131.1 和 Miniflare 4.20260730.0 已准备在 /home/labex/project/worker-tests 中。你将使用 Node 内置测试运行器自行编写测试,并通过 Miniflare 在 workerd 中执行处理程序。完成本实验需要具备 JavaScript 基础以及上一节 API 课程的知识;本文会解释测试断言、钩子和 fixture 隔离。

这是一个仅在本地执行的实验。不需要 Cloudflare 账户授权、远程资源或之前的虚拟机。Worker 发出的所有外部请求都会被本地 fixture 拦截,每个测试用例结束后都会释放测试运行时。

针对 Workers 运行时编写测试

在此步骤中,你将围绕提供的支持 API 构建一个小型测试套件。测试在 Node 的测试运行器中执行,但请求会在 Miniflare 的 workerd 运行时中执行,而不是直接将处理程序导入 Node。

进入独立项目,并查看提供的处理程序和固定版本的依赖:

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 添加完全相同版本的测试依赖;如果已有锁文件,则使用 npm ci。本实验有意固定 4.x API 及其支持的兼容性日期,而不是依赖不断变化的 latest 标签。

创建 test/support.test.mjs。带引号的 heredoc 会原样写入该模块。test 用于声明测试用例,assert.equal 用于检查标量值,assert.deepEqual 用于比较结构化 JSON。每个异步测试用例都会等待响应返回后再执行断言。

beforeEach 会启动新的运行时并重置调用列表;即使测试失败,afterEach 也会释放运行时。dispatchFetch 会发送进程内测试请求。请求使用的主机名不代表已部署的 Worker。outboundService 会拦截 Worker 发出的每次 fetch,并返回本地 fixture 响应;它不会将流量转发到 Internet。该服务会检查预期的上游 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

你应看到两个测试通过且没有失败。健康检查不得调用上游。有效请求必须生成 demo-1,并且只发送一次去除首尾空格后的 subject。此时的初始测试还没有覆盖格式错误的 JSON。使用验证来检查测试套件和独立运行时的行为。

如需了解底层接口,请参阅 Miniflare APIoutbound service 选项。生产环境中的网络行为仍需要单独进行部署测试。

添加一个会失败的回归测试

在此步骤中,你将记录一个已报告的缺陷:格式错误的 JSON 应返回可预测的 400 响应,但初始处理程序会让解析异常逸出。

使用 >> 追加一个测试,这样可以保留已有的两个测试。请求体是不完整的 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

你应看到三个测试:两个通过,格式错误 JSON 测试失败。断言会报告实际值为 500,而预期值为 400,进程也会以非零状态退出。运行时还可能输出底层解析异常。这正是预期的缺陷,不要因此把预期状态改为 500。导入错误、缺少软件包或已有测试失败,则属于其他问题。

检查处理程序中未受保护的 await request.json()。对于无效 JSON,不应有任何请求到达 fixture。趁缺陷仍然存在时使用验证:此步骤专门检查回归测试会失败且已有测试仍然通过。下一步将修复实现。

修复 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 测试。再次运行相同命令,确认全新的测试进程也能通过:

node --test --test-reporter=spec test/support.test.mjs

两次运行都应报告三个通过、零失败。每个测试用例都会获得新的运行时和空的 fixture 调用列表。不要禁用失败测试,也不要接受 500 响应来让测试套件变绿。使用验证:它会同时检查你编写的测试,以及另一组独立的运行时响应。

扩展边界覆盖并在本地完成实验

在此步骤中,你将防止另外两种回归问题:无效输入到达依赖服务,以及上游服务中断却被表现为成功请求。追加这些测试,不要删除之前的三个测试。

第一个测试会遍历无效 JSON 值并断言响应为 422,然后检查不支持的文本输入是否返回 415。它们都不应调用上游。第二个测试从空的 fixture 开始,模拟依赖服务返回 503,并验证 API 将其封装为 502 JSON。比较完整的响应体还可以防止 fixture 内部诊断信息泄漏。

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

你应看到五个测试通过且没有失败。再次运行测试套件;由于每个测试用例都会重置 fixture 状态,demo-1 和一条记录的中断调用应保持稳定。

node --test --test-reporter=spec test/support.test.mjs

所有操作都在本地完成:测试 URL 被分发到 Miniflare,Worker 发出的每个外部请求都被拦截,每个运行时也都已释放。确认虚拟机中没有保存的 Cloudflare 登录信息:

npx wrangler whoami --json

你应看到 "loggedIn": false;未认证的状态命令可能以非零状态退出。不要为本实验登录。没有需要删除的云资源。使用最终验证,它会检查真实的本地 API,并确认你的测试不仅能接受修复后的代码,还能拒绝可丢弃的错误代码副本。这些评估副本不会修改你的项目。然后结束虚拟机。

本地运行时测试可以让回归问题能够重复出现。但它们无法验证账户所有权、部署设置、真实 Internet 依赖或边缘发布行为;这些内容需要通过课程的远程检查完成。

总结

你编写了在本地 Workers 运行时中执行 API 的测试,复现了 500 与 400 之间的回归问题,并在不削弱预期契约的情况下修复了实现。你添加了输入错误和上游错误测试,在测试用例之间重置了 fixture 状态,并释放了每个运行时。整个测试套件无需云凭据或写入云资源,始终在本地且可重复执行。