测试、调试与故障定位
用契约测试、失败注入和请求追踪,证明系统在常见故障下仍按约定工作。
建议先读:TypeScript 与接口契约
本页内容
你需要证明什么#
目标是 L2:能用测试与观测回答“修改是否解决问题、有没有破坏关键行为、失败发生在哪一层”。你不必为每行代码写一个镜像测试,但需要保护权限、数据一致性、协议解析和不可重复的业务副作用。前置是 Node 基础、接口契约与系统边界。
AI 系统有两类验证。普通软件部分要求确定性,例如无权限必须拒绝、重复操作只写一次;模型部分需要样例集和评分,例如回答有无依据、工具选择是否合适。单元测试和 AI 评测互相补充,不能用“模型回答看起来不错”替代接口测试。
测试层次按风险选择#
| 层次 | 适合验证 | 例子 |
|---|---|---|
| 单元测试 | 小范围确定性规则 | 参数校验、状态转移、NDJSON 解析 |
| 集成测试 | 组件之间的真实契约 | API 与数据库事务、权限过滤 |
| 端到端测试 | 用户关键流程 | 登录、上传、提问、查看引用 |
| AI 评测 | 语义质量与策略变化 | 召回率、答案依据、拒答、工具成功率 |
数量越多不一定越可靠。一个只断言函数被调用的测试,可能完全没有检查业务结果。优先测试可以让系统做错事情的边界,特别是两个用户、两次请求、两次执行和两种失败时机。
一个完整的可测试重试函数#
保存为 retry.test.mjs,运行 node --test retry.test.mjs,使用 Node 22+,没有第三方依赖。它只重试明确标记的暂时失败;等待函数可注入,让测试无需真实等待。
import test from 'node:test';
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
async function retryRead(operation, {
maxAttempts = 3,
wait = ms => delay(ms),
} = {}) {
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 5) {
throw new RangeError('maxAttempts 应为 1 到 5');
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
// 输入错误、权限错误等永久失败直接抛出。
if (!error?.retryable || attempt === maxAttempts) throw error;
await wait(100 * 2 ** (attempt - 1));
}
}
}
test('暂时失败后成功,按退避顺序等待', async () => {
let calls = 0;
const waits = [];
const result = await retryRead(async () => {
calls++;
if (calls < 3) throw Object.assign(new Error('暂时不可用'), { retryable: true });
return { title: '事务' };
}, { wait: async ms => { waits.push(ms); } });
assert.deepEqual(result, { title: '事务' });
assert.equal(calls, 3);
assert.deepEqual(waits, [100, 200]);
});
test('永久错误不重试', async () => {
let calls = 0;
await assert.rejects(() => retryRead(async () => {
calls++;
throw new Error('FORBIDDEN');
}), /FORBIDDEN/);
assert.equal(calls, 1);
});
test('到达上限后保留原错误', async () => {
const original = Object.assign(new Error('上游失败'), { retryable: true });
let calls = 0;
await assert.rejects(() => retryRead(async () => {
calls++;
throw original;
}, { maxAttempts: 2, wait: async () => {} }), error => error === original);
assert.equal(calls, 2);
});
test('不接受无限或无效尝试次数', async () => {
await assert.rejects(() => retryRead(async () => 'unused', {
maxAttempts: 0,
}), RangeError);
});
预期四个测试通过。operation 是返回 Promise 的读取操作,成功结果原样返回;永久失败或耗尽次数时抛出最后错误。这里的 retryable 是适配层根据明确规则赋予的标记,不应盲目相信任何外部响应字段。生产实现还需整体截止时间、取消信号、随机抖动与 Retry-After 处理。
函数名使用 retryRead,是为了强调示例适合无副作用读取。创建任务、发送邮件等动作不能直接套用重试,需要幂等与执行结果查询。测试例子故意注入 wait,它让测试关注行为而不受机器快慢影响,也不会因为“等了 100 毫秒”产生不必要耗时。
故障排查从证据开始#
先记录预期与实际,再把请求划分为浏览器发出、后端接收、鉴权、数据库、检索、模型和响应渲染。每段记录开始、结束、错误类别与同一 requestId。判断延迟时看分段耗时,判断错误时看最早偏离预期的位置。
例如“回答没有引用”可能是检索没有结果、后端忘记传片段、模型没有返回引用 ID、校验器丢弃无效 ID,或前端没有渲染字段。最小实验应隔离其中一层:固定正确检索结果直接测试生成,再固定合法响应测试前端。这样才能判断修改点。
用失败注入建立恢复能力#
把模型适配层替换为三种可控返回:成功、超时、格式错误。把数据库调用替换成明确失败,检查是否回滚以及用户是否看到可恢复状态。把网络流切成不同大小,检查解析器;把同一幂等操作同时提交两次,检查是否只产生一个结果。
mock 应模拟真实契约,而不是仅返回测试喜欢的对象。至少保留一组真实环境的集成测试,验证 SQL、HTTP 和依赖行为。不要用 mock 的成功证明真实数据库事务正确;不要用离线固定回答证明模型质量达标。
学会阅读失败报告#
断言失败说明行为和预期不一致;测试启动失败则可能是环境、依赖或权限问题。两者都不是通过。若某项检查没有运行,应记录原因和替代证据。只运行 node --check 能发现语法问题,不能证明业务行为正确。
在修复前保留失败用例,修复后确认同一用例通过,再做相关回归。调试完成后移除临时日志和测试数据,保留能长期保护行为的必要测试。不要为了“绿色”删除原本揭示问题的断言,也不要改测试去迎合错误实现。
用故障时间线决定测试切面#
一条知识库问答路径经过身份验证、会话读取、资料检索、模型调用、事件转换和界面渲染。如果只写一个“最终出现回答”的端到端测试,失败时只能知道链路某处出问题。如果每个函数都 mock 掉,你又无法证明真实组件能连起来。需要按系统边界建立几组有不同职责的测试,而不是选一种测试包打天下。
先写出故障发生前后应该保留的事实。例如文档上传时,文件存储成功但数据库登记失败,必须能追踪并清理孤立文件;数据库提交成功但响应丢失,重试应查回同一文档;解析失败时,原文件仍在,用户可以看到失败并重试。这些结果才是断言目标,调用次数只是辅助证据。
| 场景 | 最小可信测试环境 | 关键断言 |
|---|---|---|
| 日期与参数校验 | 纯函数 | 非法值拒绝,规范化结果准确 |
| 旧消息回调晚到 | 可控 Promise 与状态模型 | 新消息内容不被覆盖 |
| NDJSON 中文拆包 | 真实字节流 | 中文正确,无 done 报不完整 |
| SQL 事务回滚 | 测试 PostgreSQL | 两张表都保持原有事实 |
| 租户权限 | 两个真实身份与资源 | 越权不返回内容,也不调用模型 |
| 用户确认工具 | API 与数据库 | 相同幂等键仅产生一次副作用 |
| RAG 回答依据 | 固定资料和问题集 | 检索与生成分别计分 |
SQL 测试需要真实数据库,是因为隔离、约束和锁由数据库决定。用内存 Map 模拟库可以测试控制流,却不能证明 SELECT FOR UPDATE 的锁范围。界面测试也一样:jsdom 可以验证事件和 DOM 更新,不等于真实浏览器的布局、剪贴板权限和输入法行为。报告要按实际工具能力陈述。
给重试补上取消:完整参考实现#
上一节练习要求取消后不能开始下一次调用,也不能在很长的退避中一直等待。保存下面完整程序为 retry-abort.mjs,执行 node --test retry-abort.mjs。wait 接收 signal,使测试既可以控制等待,也能证明取消链路穿过了等待和操作两个边界。
import test from 'node:test';
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
async function retryRead(operation, {
signal, maxAttempts = 3,
wait = (ms, currentSignal) => delay(ms, undefined, { signal: currentSignal }),
} = {}) {
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 5) throw new RangeError('ATTEMPTS');
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
signal?.throwIfAborted();
try {
const result = await operation(signal);
signal?.throwIfAborted(); // 已取消的旧结果不应再作为成功返回。
return result;
} catch (error) {
signal?.throwIfAborted();
if (!error?.retryable || attempt === maxAttempts) throw error;
await wait(100 * 2 ** (attempt - 1), signal);
}
}
}
test('开始前取消,不调用 operation', async () => {
const controller = new AbortController(); controller.abort();
let calls = 0;
await assert.rejects(() => retryRead(async () => { calls++; }, { signal: controller.signal }),
error => error.name === 'AbortError');
assert.equal(calls, 0);
});
test('退避中取消,不开始第二次操作', async () => {
const controller = new AbortController(); let calls = 0;
await assert.rejects(() => retryRead(async signal => {
assert.equal(signal, controller.signal); calls++;
throw Object.assign(new Error('TEMPORARY'), { retryable: true });
}, {
signal: controller.signal,
wait: async (_ms, signal) => { controller.abort(); signal.throwIfAborted(); },
}), error => error.name === 'AbortError');
assert.equal(calls, 1);
});
test('操作返回前取消,结果不会被接受', async () => {
const controller = new AbortController();
await assert.rejects(() => retryRead(async () => {
controller.abort(); return '迟到结果';
}, { signal: controller.signal }), error => error.name === 'AbortError');
});
test('未取消时正常退避并成功', async () => {
let calls = 0; const waits = [];
const value = await retryRead(async () => {
if (++calls === 1) throw Object.assign(new Error('TEMPORARY'), { retryable: true });
return 42;
}, { wait: async ms => { waits.push(ms); } });
assert.equal(value, 42); assert.deepEqual(waits, [100]);
});
这里没有使用 setTimeout 后“猜测任务应该已经进入第二步”。测试直接在注入的 wait 中触发取消,事件顺序确定,因此机器快慢不会影响结果。真实的 delay 能响应 signal,但它不能让一个完全不理会 signal 的 operation 神奇中止。你的实现必须把信号继续传给 fetch、流读取或可取消依赖,并给不支持取消的操作设计结果丢弃和资源上限。
重试还有总时限、随机抖动、Retry-After 和计费预算,本例没有声称覆盖这些策略。把每项新增能力写成一个明确场景:到达总截止时间不再开始调用;等待不能超过剩余预算;永久错误不重试。不要为了把接口做得“灵活”增加十个可配置参数,却没有对应的验收语义。
怎样证明旧请求不会污染新请求#
不要靠连续点击两次、恰巧看到正确结果来证明竞态安全。测试需要控制完成顺序:先启动 A,再启动 B,先兑现 B,最后兑现 A。最终结果必须保持 B。然后交换顺序、取消 A、卸载页面,并检查旧 finally 是否清除了 B 的资源。每个测试针对一种顺序,而不是依赖真实网络随机变慢。
这种可控 Promise 通常称为 deferred:外部保存 resolve/reject,让测试在指定时刻决定结果。它适合验证你的状态所有权,而不是替代真实 HTTP 测试。实际网络中的 AbortError、响应体消费和浏览器行为仍需要另一层检查。测试切面不同,证据才能互补。
还要验证“不该发生的事”。越权读取不只断言返回 403,还要断言模型适配器没有收到私人文档。重复确认不只断言两个响应都成功,还要查数据库只有一条任务。删除资料不只断言列表不见了,还要确认检索和缓存都不再返回内容。很多事故发生在用户看见的结果已经正确,但系统额外做了错误的事情。
一次“回答慢”的完整排障记录#
假设用户说发送后十秒才看见文字。先确认是在首个字节、首个业务事件还是完整答案上测量“十秒”。这三个时间可能差很多。浏览器收到响应头很快,却没有正文,可能是服务端还在检索;模型已返回增量但网关积攒后才发送,则是传输链路;字节已经到达但界面等 response.text 完成才更新,则是消费方式。
下面是一份虚构记录,用于学习排查方法,不是本项目性能数据:浏览器发出0ms,API接收30ms,鉴权完成40ms,检索完成210ms,模型首事件920ms,API首次write930ms,浏览器首正文9950ms。差异主要落在API写出到浏览器接收之间。下一步实验应该比较直连后端和经过代理,而不是立刻换模型。
记录时间时,单进程阶段耗时优先使用单调时钟;跨机器绝对时间需要时钟同步,不能直接把不同机器的 Date.now 相减当精确网络耗时。requestId 用于关联,trace 中各段 span 用于观察依赖关系。日志只保留必要元数据,不默认记录完整提示词和私人文档。
固定输入集比临场挑例子更有意义#
给流解析器准备空流、只含 done、UTF-8 单字节拆分、多事件合并、CRLF、无换行尾行、未知事件、重复 done、done 后有事件、超长单行十类输入。给权限准备无身份、同租户有权、同租户无权、跨租户、成员资格撤销五类。给工具执行准备重复请求、参数变化但幂等键相同、成功后响应丢失、失败后重试四类。
每条样例都写清楚允许结果。错误案例不是所有都要“抛异常”:拒答问题可能应该返回一个可解释的正常业务结果,资料不存在也可能使用统一404。没有预期结果的故障注入,只是在制造噪声。测试报告应能指出哪个规则被违反,而不是只有“红了”。
对于模型评测,保留问题、允许资料、期望事实、不可出现的事实、评分方式与模型配置。不要让评测模型在看不到证据的情况下凭流畅程度打分。普通代码测试固定某一输入的确定结果,语义评测允许合理表述变化;两者的断言粒度不一样。
修复闭环与报告写法#
先用最小样例证明问题存在,记录失败原因;再做针对性修改;最后运行同一个样例验证行为改变,并补足相关边界。若工具无法运行,记录阻塞点和已有证据,不把“代码看起来对”写成通过。若样例只是证明语法,就只报告语法,不声称完整业务完成。
报告可以写成四句:问题由什么触发;哪条边界处理错误;修改后实际行为是什么;使用什么输入和环境验证,哪些没有验证。例如“完整NDJSON事件超过上限时原先未检查;现在解析完整行前检查长度;超长完整行返回EVENT_TOO_LARGE;本机Node测试通过,真实供应商流尚未接入”。这比列出几十条命令更容易判断可靠性。
达到本章 L2 后,你应能为自己的修复设计一条真正会在修改前失败的检查,解释 mock 的边界,并根据证据选择下一次实验。你不需要一开始建立庞大的测试平台,但不能把运行一次正常路径当成对权限、并发和恢复的验证。
练习#
给 retryRead 增加 AbortSignal。要求取消后不再开始下一次操作,等待中也能取消。写测试证明取消发生在第一次失败后时,调用次数仍为一次。
参考思路
每轮开始前调用 signal?.throwIfAborted(),真实等待使用 delay(ms, undefined, { signal })。operation 也应接收 signal 并传给底层 fetch。测试使用可控的等待函数触发 controller.abort,不依赖真实时钟竞争;断言拒绝原因和后续调用次数。只有检查循环开头还不够,因为一个很长的等待可能无法及时终止。
验收与自测#
- 能为一次修复保留失败复现并验证修复后的行为。
- 能区分语法检查、单元测试、集成测试与模型评测。
- 能把一次失败定位到具体边界,而不是笼统归因于 AI。
- 报告中明确哪些检查实际运行、哪些依赖尚未验证。
问:全 mock 的测试为什么仍可能漏错? 答:mock 可能不符合真实 SQL、协议或依赖的行为。
问:重试次数越多越可靠吗? 答:会增加延迟、成本和副作用风险,需要边界和预算。
问:类型检查通过等于运行正确吗? 答:不等于,类型不能覆盖外部数据和真实执行行为。