简介
支持 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 API 和 outbound 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 状态,并释放了每个运行时。整个测试套件无需云凭据或写入云资源,始终在本地且可重复执行。

