# 测试、调试与故障定位

## 你需要证明什么

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

```js 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。要求取消后不再开始下一次操作，等待中也能取消。写测试证明取消发生在第一次失败后时，调用次数仍为一次。

<details>
<summary>参考思路</summary>

每轮开始前调用 `signal?.throwIfAborted()`，真实等待使用 `delay(ms, undefined, { signal })`。operation 也应接收 signal 并传给底层 fetch。测试使用可控的等待函数触发 controller.abort，不依赖真实时钟竞争；断言拒绝原因和后续调用次数。只有检查循环开头还不够，因为一个很长的等待可能无法及时终止。

</details>

## 验收与自测

- 能为一次修复保留失败复现并验证修复后的行为。
- 能区分语法检查、单元测试、集成测试与模型评测。
- 能把一次失败定位到具体边界，而不是笼统归因于 AI。
- 报告中明确哪些检查实际运行、哪些依赖尚未验证。

**问：全 mock 的测试为什么仍可能漏错？** 答：mock 可能不符合真实 SQL、协议或依赖的行为。

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

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

## 官方参考

- [Node：测试运行器](https://nodejs.org/api/test.html)
- [Node：断言](https://nodejs.org/api/assert.html)
- [Node：Promise 定时器](https://nodejs.org/api/timers.html#timers-promises-api)
- [Vue：测试指南](https://cn.vuejs.org/guide/scaling-up/testing)
