本页目录

测试、调试与故障定位

用契约测试、失败注入和请求追踪,证明系统在常见故障下仍按约定工作。

L2 · 能交付约 13 分钟阅读含示例、练习与验收

建议先读:TypeScript 与接口契约

本页内容

你需要证明什么#

目标是 L2:能用测试与观测回答“修改是否解决问题、有没有破坏关键行为、失败发生在哪一层”。你不必为每行代码写一个镜像测试,但需要保护权限、数据一致性、协议解析和不可重复的业务副作用。前置是 Node 基础、接口契约与系统边界。

AI 系统有两类验证。普通软件部分要求确定性,例如无权限必须拒绝、重复操作只写一次;模型部分需要样例集和评分,例如回答有无依据、工具选择是否合适。单元测试和 AI 评测互相补充,不能用“模型回答看起来不错”替代接口测试。

测试层次按风险选择#

层次 适合验证 例子
单元测试 小范围确定性规则 参数校验、状态转移、NDJSON 解析
集成测试 组件之间的真实契约 API 与数据库事务、权限过滤
端到端测试 用户关键流程 登录、上传、提问、查看引用
AI 评测 语义质量与策略变化 召回率、答案依据、拒答、工具成功率

数量越多不一定越可靠。一个只断言函数被调用的测试,可能完全没有检查业务结果。优先测试可以让系统做错事情的边界,特别是两个用户、两次请求、两次执行和两种失败时机。

一个完整的可测试重试函数#

保存为 retry.test.mjs,运行 node --test retry.test.mjs,使用 Node 22+,没有第三方依赖。它只重试明确标记的暂时失败;等待函数可注入,让测试无需真实等待。

js
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,使测试既可以控制等待,也能证明取消链路穿过了等待和操作两个边界。

retry-abort.mjs
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、协议或依赖的行为。

问:重试次数越多越可靠吗? 答:会增加延迟、成本和副作用风险,需要边界和预算。

问:类型检查通过等于运行正确吗? 答:不等于,类型不能覆盖外部数据和真实执行行为。

官方参考#

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭