成为全栈·Node 后端篇·后端测试策略:单元、集成与测试数据库

成为全栈·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.tsHttpForCode 逐一比对,断言完全一致。契约里说 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",不是"凑绿勾":

  1. 金字塔:纯函数单元 + 路由集成(主力)+ 契约一致性(守底线);端到端基本不写。
  2. 不引 supertestapp.request() 发真实请求,靠 M1-08 原生 Response 信封可直接断言。
  3. P-52 测试库:memory: 内存库,setup.ts 全局建 + 用例 beforeEach 重建防污染;tokenOf 自包含(内先 register)。
  4. P-18 门禁≠正确 :4001 写 422、禁用写错状态,三道门禁全过却违契约 → error-codes.test.ts 比对契约 vs HttpForCode 抓语义回归;24h 冷却边界漏测踩坑。
  5. P-53 打边界+并发:冷却窗口切换瞬间、唯一约束并发抢、阅读量原子 ±1,必须有专门用例。
  6. P-54 ESM 坑 :spy 优先 vi.mock;外键测试先建用户行。
  7. P-55 脚本门禁 :种子脚本不受 tsc 覆盖,靠 biome + 实跑 + .env.exampleSEED_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

相关推荐
FungLeo2 小时前
成为全栈·Node 后端篇·评论内容安全:敏感词过滤、三态审核与级联删除
node.js·敏感词过滤·成为全栈·评论内容安全·评论审核·级联删除
yume_sibai4 小时前
09-Rust 测试与质量保证(单元测试 + 集成测试 + Mock + Benchmark + Fuzzing + CI/CD)
rust·单元测试·集成测试
FungLeo13 小时前
成为全栈·Node 后端篇·阅读量防刷:去重、冷却与计数写分离
node.js·读写分离·数据去重·接口防刷·成为全栈·数据冷却
脉动数据行情11 天前
Node.js WebSocket 实现贵金属实时行情监听 伦敦金 / 伦敦银自动重连方案
websocket·node.js·vim
不老刘1 天前
一行命令解决 Node.js 版本兼容问题:`--openssl-legacy-provider` 深度解析
node.js
ChampaignWolf1 天前
Joule Unit Test 深度集成:ABAP 单元测试的 AI 六件套全解析
人工智能·单元测试·sap·abap·joule·单元测试ai
川石课堂软件测试1 天前
涨薪技术|Prometheus之HTTP API中使用PromQL
网络协议·测试工具·jmeter·http·单元测试·postman·prometheus
万敏2 天前
Vue3 全栈实战:第一阶段复盘(第1-8周)
vue.js·node.js·全栈
濮水大叔2 天前
舒服了,CabloyJS 的 AI Spec 驱动开发会自动生成甘特图和燃尽图
typescript·node.js·vibecoding