测试全绿不等于行为正确
客户端有一个危险的错觉:单元测试覆盖率很高,线上照旧出错。原因通常不是测试写得不好,而是测的层级不对。纯函数测试证明不了「两次请求乱序返回时会怎样」,进程内测试证明不了「真实 socket 断开后重连还认得原来的会话」。
social-lab 从一开始就把验证分成三级,每一级有明确的输入、明确的产出,也有明确的不承诺范围:
| 级别 | 依赖 | 能证明 | 不能证明 |
|---|---|---|---|
| L1 | 无(纯函数、内存) | 分类、排序、去重、状态迁移的正确性 | 任何时序行为 |
| L2 | 进程内事件循环、假时钟 | 一次完整流程的先后顺序 | 真实网络与服务端行为 |
| L3 | 本地 HTTP/WebSocket 端口、真实定时器 | 连接、断线、重连、消息到达 | 真机性能与平台差异 |
本节先给出判定器,再逐级说明边界。最后复盘一次把 L2 用例并发执行导致的假绿灯。
判定器要回答的问题
一段验证属于哪一级,不取决于它在哪个目录,而取决于它依赖什么。判定输入只有四个布尔事实:
- 是否发起网络请求(
usesNetwork) - 是否建立长连接(
usesSockets) - 是否依赖真实定时器(
usesTimers) - 是否启动外部进程或真实服务(
usesExternalProcess)
// file: src/core/lab.ts
// tier: L1
// verify: node --test test/core/lab.test.ts
export type Tier = "L1" | "L2" | "L3";
export interface LabPlan {
usesNetwork: boolean;
usesSockets: boolean;
usesTimers: boolean;
usesExternalProcess: boolean;
}
export interface TierGate {
tier: Tier;
command: string;
note: string;
}
export function classifyTier(plan: LabPlan): Tier {
if (plan.usesExternalProcess || plan.usesSockets) return "L3";
if (plan.usesNetwork || plan.usesTimers) return "L2";
return "L1";
}
export function gateFor(tier: Tier): TierGate {
if (tier === "L1") {
return { tier, command: "node --test test/core", note: "纯函数与内存模型,不引入时钟" };
}
if (tier === "L2") {
return {
tier,
command: "node --test --test-concurrency=1 test/",
note: "进程内端到端,串行执行避免共享内存互相干扰",
};
}
return {
tier,
command: "node --test --test-concurrency=1 test/ realtime/",
note: "真实本地端口与定时器,先起服务再跑用例",
};
}
export function describePlan(plan: LabPlan): string[] {
const tier = classifyTier(plan);
const reasons: string[] = [];
if (plan.usesNetwork) reasons.push("网络");
if (plan.usesSockets) reasons.push("长连接");
if (plan.usesTimers) reasons.push("定时器");
if (plan.usesExternalProcess) reasons.push("外部进程");
return [`${tier}`, reasons.length ? reasons.join("+") : "纯逻辑"];
}
判定顺序很重要:只要有长连接或外部进程,就是 L3,即使它同时用了网络和定时器。原因是 L3 的风险覆盖了 L2 的风险,反过来不成立。如果先判网络,一个 WebSocket 用例会被错误地归到 L2,然后被放进不需要端口的环境里跑,结果是连接失败被当成用例失败,或者更糟——被跳过。
gateFor 返回的不只是命令,还附带一句说明。这句说明会打印在测试报告里,提醒后来者为什么这条命令要串行。命令行本身也有约束:L2 与 L3 一律加 --test-concurrency=1,L1 不加。
L1:只证明逻辑,不证明时序
L1 的典型对象是分类器、排序器、去重器、状态迁移函数。它们的特点是不读时钟、不发请求、不改全局状态。
信息流的 appendPage 属于 L1 还是 L2?它的实现里没有定时器也没有网络,只有数组与集合操作,所以是 L1。哪怕它描述的是一个「加载更多」的流程,只要流程在同一个同步调用里完成,就可以用 L1 验证。
L1 的价值是快且稳定:几百条用例在毫秒级跑完,失败原因一定在函数本身,不需要怀疑环境。它的局限也很清楚:
- 两个请求乱序返回,谁覆盖谁?L1 无法表达「乱序」。
- 断线重连时旧回调晚到,是否写进了新会话?L1 没有会话概念。
- 用户切后台 30 秒后回来,连接是否还活着?L1 没有时间。
这些问题必须交给 L2 或 L3,不能用「多写几个纯函数用例」假装覆盖。
L2:进程内流程,用假时钟控制顺序
L2 的核心能力是安排顺序。它不需要真实网络,但需要一个可控的事件循环:Promise 的 resolve 顺序、定时器的触发时刻、事件的派发次序。
social-lab 里 L2 的典型用法是:把网络客户端替换成一个内存实现,按脚本依次返回响应,然后断言容器最终的状态与中间状态。比如:
- 第一次
appendPage返回{ items: [a, b], nextCursor: "c2" }。 - 第二次返回
{ items: [b, c], nextCursor: "c3" }。 - 断言最终顺序是
a, b, c,重复项为 1。
这与 L1 的区别在于,L2 会经过真实的调用链:请求函数、Promise、回调、容器写入。它能抓住「忘了 await」「错误被吞掉」「回调在页面销毁后仍然写入」这类问题。
L2 仍然不碰真实端口。Node 26 的 node:test 提供了 mock.timers,可以用假时钟把「30 秒后超时」压缩成一次同步推进,既快又不 flaky。
L3:真实本地端口,证明连接行为
L3 用真实的本机端口:http.createServer 监听 127.0.0.1:0,或者用 WebSocket 服务端跑一次握手。它证明的是:
- 连接建立、关闭、重连的真实时序。
- 服务端主动断开时,客户端的退避与补拉是否触发。
- 消息在网络边界上的序列化与反序列化是否正确。
L3 的代价是慢与不稳定:端口占用、定时器抖动、进程退出时的连接清理都可能让用例偶发失败。因此 L3 用例要满足三个条件:
- 端口用
0让系统分配,不写死。 - 每个用例自己起服务、自己关服务,不共享全局端口。
- 超时时间显式设置,并断言「在 N 毫秒内重连成功」,而不是睡固定时长。
L3 也不承诺真机行为。真机的后台策略、网络切换、内存回收都不在本机模拟范围内。这一点在卷二末尾会再强调一次:本套书只承诺到本地端口,不承诺真机性能数字。
用假时钟安排顺序
L2 之所以能证明时序,关键在假时钟。下面这段代码把「30 秒超时」压缩成两次同步推进,跑在本机 Node 26.5.0 上:
import test from "node:test";
import assert from "node:assert/strict";
test("假时钟把 30 秒超时压缩成两次推进", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let timedOut = false;
setTimeout(() => {
timedOut = true;
}, 30_000);
t.mock.timers.tick(29_999);
assert.equal(timedOut, false);
t.mock.timers.tick(1);
assert.equal(timedOut, true);
});
真实输出:
$ node --test fake-clock.test.ts
✔ 假时钟把 30 秒超时压缩成两次推进 (1.044292ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0
ℹ duration_ms 142.140583
假时钟的价值不只是快。真实定时器在 CI 上会抖动,tick(29_999) 与 tick(1) 的边界断言在真实时钟下根本无法稳定表达;假时钟让「差 1 毫秒不发生、补上 1 毫秒才发生」成为确定性的断言。
使用假时钟有三条纪律:
- 在用例开始时启用、在
t的作用域内使用,避免影响其他用例。 - 只启用需要的 API(这里是
setTimeout),不要把所有定时器都替换掉。 - 不要在同一个用例里混用真实
await与假时钟,除非明确知道事件循环的推进点。
违反第三条的典型症状是:断言写在 await 之后,但假时钟没有推进,回调永远不会执行,用例超时失败。正确做法是把「推进时间」写成一个显式步骤,让顺序在代码里可见。
测试与真实输出
判定器本身也是 L1,因此可以用 L1 的方式测试:
$ node --test test/core/lab.test.ts
✔ 纯逻辑归入 L1 (0.753708ms)
✔ 网络或定时器归入 L2 (0.09025ms)
✔ 长连接或外部进程归入 L3 (0.071125ms)
✔ L1 不强制串行执行 (0.07925ms)
✔ L2 与 L3 强制串行执行 (0.1125ms)
✔ 描述计划时列出触发降级的原因 (0.463125ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 206.710333
其中「长连接或外部进程归入 L3」这条用例,专门锁住判定顺序。曾经有一个实现先判断 usesNetwork,结果 WebSocket 用例被归为 L2,CI 在无端口环境里跑它,连接超时被当成业务失败,排查了两个小时才发现是分级错了。
一次并发执行导致的假绿灯
social-lab 早期用 node --test 默认并发跑所有用例。L1 和 L2 混在一起,出现过一个很隐蔽的现象:
- 单独跑每一个用例都通过。
- 全量跑时,大约每十次有一次失败,失败信息是「游标应为 c3,实际为 c2」。
- 重跑一次又通过,无法稳定复现。
根因是两个用例共享了同一个 SocialClient 实例(当时为了省事,把它放在了模块顶层)。并发执行时,一个用例的 appendPage 改了游标,另一个用例正好读到中间状态。因为 Node 的默认并发度取决于 CPU 核数与文件数,失败概率随机器变化,在开发机上几乎不出现。
修复分两步:
- 每个用例自己构造实例,不共享可变对象。
- L2 与 L3 一律加
--test-concurrency=1,并在gateFor里把这条约束写进命令,防止后来者手动去掉。
这件事的教训不是「测试要隔离」这种正确的废话,而是:并发是 L2 的隐藏输入。当验证依赖顺序时,并发度就是一个必须显式固定的参数。把它交给运行时的默认值,等于把稳定性交给机器配置。
三级如何映射到章节
本卷每一节至少承诺一个可运行片段,并标注它属于哪一级:
- 纯函数、分类器、序列化、去重 → L1。
- 一次完整流程,比如「发内容 → 互动 → 计数回写」→ L2。
- 连接、重连、补拉、断线降级 → L3。
标注不是装饰。它告诉读者这段代码在什么环境下能复现,也告诉评审者:如果一节只标了 L1 却在结论里谈「弱网下表现」,那就是越级承诺。
小结
三级验证各自回答不同的问题:L1 证明逻辑,L2 证明顺序,L3 证明连接。判定依据是依赖,不是目录。L2 与 L3 必须串行,因为并发度会改变时序;L3 必须自己起端口、自己关,不共享全局资源。
下一章进入信息流本身:为什么页码分页在内容流里会失效,游标应该怎么设计,以及在两次请求之间内容顺序变化时,端上如何保持位置。
阅读导航:上一节:1.2 social-lab 的第一个决定:端上先画什么 · 下一节:2.1 游标分页:为什么页码在内容流里会失效 。
常见错误与边界
- 先判网络再判长连接。WebSocket 用例会被错分到 L2,在无端口环境里失败或跳过。
- 把「流程完整」当成 L2 的唯一标准。不依赖真实时序的流程仍应留在 L1。
- L2/L3 去掉串行。默认并发度会随机器变化,时序用例会间歇性失败。
- L3 写死端口。并行执行或端口占用会让用例随机失败。
- 用固定
sleep等待重连。应轮询条件并设置上限,否则慢机器上必然 flaky。 - 把 L1 的结论外推到弱网。纯函数测试没有时间维度,无法证明任何时序行为。
- 把 L3 的结论外推到真机。本地端口不模拟后台回收、网络切换与平台差异。
- 只标注级别不写命令。级别是约束,不是标签;约束必须落到可执行的命令上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。