成为全栈·Node 后端篇·后端测试策略:单元、集成与测试数据库
一个项目测试全绿、覆盖率 85%,上线第二天还是出了事故------这种事几乎每个团队都经历过。问题往往不在测试写得少,而在测得不对:正常路径重复测了十遍,边界和并发一处没碰。

这一篇讲我们的测试策略:测什么、用什么测、怎么在不引 supertest 的前提下发真实请求、测试数据库怎么用 :memory: + beforeEach 做到互不污染,以及那些"全绿却仍埋雷"的真实教训。
一、测试金字塔:用什么测、测什么
经典测试金字塔是"多单元测试、少集成测试、更少端到端"。落我们项目上,分三层:

| 层级 | 测什么 | 怎么测 | 占比 |
|---|---|---|---|
| 单元测试 | shared / services 纯函数:信封构造、错误码映射、slug 校验、状态机 canTransition、分类树 buildTree |
不碰网络、不碰 DB,直接断言返回值 | 底座,量大 |
| 集成测试 | 路由层"分层协作":鉴权→校验→service→信封 | app.request() 发真实 HTTP 请求,走完整链路 |
主力,覆盖绝大多数业务路径 |
| 契约一致性测试 | 系统协议自洽:错误码 ↔ HTTP 状态 | 解析 openapi.v1.yaml 比对 HttpForCode |
守底线,少数几道 |
比例上,单元和集成占绝大多数,契约测试是守底线的少数几道。端到端(真起服务、真浏览器)我们基本不写------成本太高、太脆,收益不如把集成和契约做扎实。为什么集成测试是主力?因为它验证的是"分层真的协作对了"------路由正确调了 service、service 正确碰了 DB、错误被正确包成信封。单元测试只能证明"每个零件好",集成测试才证明"装起来能用"。而端到端要起整套环境、慢且脆,性价比低,所以把预算压在集成上最划算。
二、不引 supertest:用 app.request 发真实请求
集成测试怎么"发请求"?常见做法是引 supertest 起一个监听端口的 server。我们没这么做------Hono 的 app 可以直接 app.request(path, init) 发请求,返回的就是真实 Response。看 test/routes/articles.test.ts 里的真实写法:
ts
// test/routes/articles.test.ts
const app = createApp(readEnv(process.env as Record<string, string | undefined>));
const register = (username: string, password = 'password123') =>
app.request(`${BASE.replace('/articles', '')}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email: `${username}@example.com`, password }),
});
不启端口、不引额外依赖。这能成立,全靠 统一响应结构:HTTP 状态码与业务码如何分工 埋的伏笔:shared/response.ts 的信封构造器返回的是原生 Response.json() ,而不是某个 Hono 专属对象------所以单测拿到 Response 就能直接 res.json() 断言,不需要起一个 Hono server 来"兜"着它。分层纪律在这里回馈了测试便利。相比 supertest 起端口、走真实 TCP,这种方式更快、也更不容易因为"端口被占用"之类环境问题而 flake(随机失败)。
三、测试数据库::memory: + beforeEach 重建(P-52)
测试不能碰生产库,也不能用例之间互相污染。我们的做法是用 SQLite 内存库 :memory::
ts
// test/setup.ts(全局前置)
const db = createLocalDb(':memory:');
migrate(db); // 跑一遍迁移,建好全部表结构
setDb(db); // 设为当前活跃 DB
setup.ts 在用例前建一个内存库、跑迁移、设为活跃。而具体的路由/服务测试(如 articles.test.ts)还会在每个用例前再建一个新的 :memory: 并 migrate + setDb:
ts
// test/routes/articles.test.ts --- beforeEach
beforeEach(async () => {
const db = createLocalDb(':memory:');
await migrate(db);
setDb(db);
});
为什么这么折腾?因为 :memory: 是"一次性、随连接销毁"的数据库------每个用例一张崭新的空表,上一条测试插的数据绝不会漏到下一条。这就是 P-52 的核心:每用例重建测试库,杜绝状态污染 。内存库还带来两个好处:快(不落盘)、CI 上干干净净(不需要起一个外部 Postgres/MySQL)。对比其他方案:用真实 Sqlite 文件得自己清理、还得防并行跑测试时文件锁;用 testcontainers 起 Postgres 又重又慢。:memory: 是"快、隔离、零运维"的最优解------当然代价是它和生产的 PG/D1 方言有微小差异,所以迁移脚本得在两种方言下都验证过(数据迁移:schema 变更如何不弄脏线上数据 讲过双路径)。
P-52 还有一条:测试 helper 要自包含 。tokenOf(username, role?) 这个 helper 内部会先 register 一个用户(必要时提权成 editor/admin),再返回令牌。看 articles.test.ts 里的真实实现:
ts
// test/routes/articles.test.ts --- tokenOf(自包含 helper)
const register = (username: string, password = 'password123') =>
app.request(`${BASE.replace('/articles', '')}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email: `${username}@example.com`, password }),
});
const login = (username: string, password = 'password123') =>
app.request('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const tokenOf = async (username: string, password = 'password123'): Promise<string> => {
await register(username, password); // 幂等:用户已存在则注册返回 409,忽略即可
const r = await json<TokenResp>(await login(username, password));
return r.data.accessToken;
};
自包含意味着用例不需要依赖"某个前置测试已经建好了这个用户",自己调 tokenOf 就能拿到一个可用的登录态。用例之间零顺序依赖,随便删一个、改一个都不会牵连别人。
四、门禁全绿 ≠ 没缺陷(P-18)

这是这一篇最想敲黑板的一点。我们踩过真实的"全绿却错"事故:
- 某个错误码本该返回
422,手滑写成了4001(也是 400 状态码,但业务码错了)------三道门禁(tsc / biome / vitest)全过,因为"能编译、能跑、状态码对",没人报错。 - 某个禁用账号本该返回
403,写成了别的------同样全绿。
问题出在哪?门禁默认只查"格式对不对、正常路径通不通",不查"语义对不对" 。于是我们专门写了 test/contract/error-codes.test.ts 这道契约一致性门禁:它解析 docs/api/openapi.v1.yaml,抽出每个错误响应里的业务码和对应 HTTP 状态,再和 shared/codes.ts 的 HttpForCode 逐一比对,断言完全一致。契约里说 1001 → 401,代码里 HttpForCode[1001] 也必须是 401,否则测试红。看它的核心断言:
ts
// test/contract/error-codes.test.ts --- 核心断言
describe('契约错误码 ↔ HTTP 状态一致性', () => {
it('HttpForCode 每个业务码都与契约实际 HTTP 状态一致', () => {
for (const [code, statuses] of byCode) {
if (code === 0) continue;
const mapped = HttpForCode[code as BizErrorCode];
expect(mapped, `业务码 ${code} 在 HttpForCode 中缺失`).toBeDefined();
for (const s of statuses) {
expect(mapped, `业务码 ${code} 契约 HTTP=${s},但 HttpForCode=${mapped}`).toBe(s);
}
}
});
});
把"格式对但语义错"这类回归,前移到了测试阶段。比如契约里 Unauthorized 含 1001/1002 两个例子,分别对应 401,测试就会分别核对------连"一个码对应多个相互矛盾的 HTTP 状态"这种诡异情况都能抓出来。这把"加 code 却漏配/配错 HTTP"的回归,死死按在测试阶段。
还有一个更隐蔽的:阅读量防刷的"24 小时冷却窗口"({{LINK:M1-26}} 会详讲),正常路径测试全绿,但边界时刻------刚好跨过 24 小时窗口的那一瞬------没测。结果上线后在这个边界踩了坑。这引出了下一点。
五、补测试要打边界 + 并发(P-53)
正常路径全绿只是及格线。真正能抓 bug 的测试,打的是边界和并发:
- 边界时刻 :冷却窗口切换的瞬间、分页
offset越界、空列表、NULL排序键。这些"看起来不会出问题"的输入,恰恰是 bug 温床。 - 并发 :两个请求同时抢同一个 slug(唯一约束)、同时给一篇文章 +1 阅读量。我们的
isUniqueConstraintError兜底(错误处理:异常分层与全局捕获 的 P-19)和阅读量原子±1({{LINK:M1-29}})这类并发逻辑,必须有并发用例专门打 ------单线程跑十遍都不会暴露的问题,并发一来就现形。顺带,并发测试不是"跑两次看谁赢",而是用Promise.all同时发两个冲突请求,断言其中一个拿到 409、另一个成功------你要设计让冲突必然发生,才能验证兜底真的兜住了。盲跑十次可能每次都"恰好不冲突",那测试等于没写。
P-53 的提醒是:门禁"能编译 + 正常路径对"太容易满足,别被绿勾麻痹,要把测试当成"我怎么证伪这段代码"的武器,而不是"凑覆盖率"的任务。
六、ESM 下的测试坑(P-54)
项目是纯 ESM(Node 的 import),测试里有两个专属坑:
- spy 模块函数优先
vi.mock而非vi.spyOn。ESM 的模块绑定是只读的,vi.spyOn有时 spy 不到跨模块的函数引用,得用vi.mock整体 mock 模块才稳------spyOn在某些 ESM 场景下会静默失效,排查起来很费解,所以宁可一开始就用vi.mock。这是我们踩过、在attachment.test.ts里用vi验证过的经验。 - 外键测试别忘建用户行 。文章、评论都引用
users.id,测试里插一篇待审文章前,得先插对应的用户行------否则外键(或应用层查 author)直接空指针。这是 ESM 无关的"老坑",但在集成测试里极容易忘。
七、脚本门禁(P-55)
最后提一个容易被忽视的角落:种子脚本 scripts/seed-users.ts 建首个 admin,它不受 tsc 类型检查覆盖 (属于脚本而非应用源码)。那它的"门禁"靠什么?靠 biome 格式/静态检查 + 实跑验收 ------pnpm seed 真跑一遍,确认能建出 admin、幂等、不崩。此外 .env.example 补了 SEED_ADMIN_* 变量,让脚本需要的配置有文档兜底。脚本虽小,门禁不能漏,否则"生产第一次部署没有 admin"的事故就来了。
八、小结与前瞻
后端测试,目标是"抓住真 bug",不是"凑绿勾":
- 金字塔:纯函数单元 + 路由集成(主力)+ 契约一致性(守底线);端到端基本不写。
- 不引 supertest :
app.request()发真实请求,靠 M1-08 原生Response信封可直接断言。 - P-52 测试库 :
:memory:内存库,setup.ts全局建 + 用例beforeEach重建防污染;tokenOf自包含(内先 register)。 - P-18 门禁≠正确 :4001 写 422、禁用写错状态,三道门禁全过却违契约 →
error-codes.test.ts比对契约 vsHttpForCode抓语义回归;24h 冷却边界漏测踩坑。 - P-53 打边界+并发:冷却窗口切换瞬间、唯一约束并发抢、阅读量原子 ±1,必须有专门用例。
- P-54 ESM 坑 :spy 优先
vi.mock;外键测试先建用户行。 - P-55 脚本门禁 :种子脚本不受 tsc 覆盖,靠 biome + 实跑 +
.env.example补SEED_ADMIN_*。
下一篇({{LINK:M1-22}})我们聊"容器化":给 Node 应用写一个像样的多阶段 Dockerfile------镜像怎么瘦、怎么非 root 跑。
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
