性能优化动了代码、鉴权层改了判定流、缓存加了失效逻辑——凭什么敢发布?答案由三件事构成:测试证明行为没变,调试手段保证问题可定位,监控保证线上异常第一时间有人知道。本节把这套底盘一次建成,全册至此形成完整的工程闭环。
第 3.1 节给过"假 ctx 加假 next"的测法,这里补上鉴权层的完整测试——它是全册行为最关键的层,三个截停分支加一个放行分支,每条都得有测试钉住:
// auth.test.js const jwt = require('jsonwebtoken'); const { auth } = require('../middleware/auth'); const SECRET = process.env.JWT_SECRET; const makeCtx = (token) => ({ get: (k) => (k === 'authorization' ? token : ''), state: {}, }); test('无凭证返回 401 异常', async () => { const ctx = makeCtx(''); await expect(auth()(ctx, async () => {})).rejects.toMatchObject({ status: 401 }); }); test('过期 token 返回 401', async () => { const expired = jwt.sign({ sub: 'u1', role: 'user' }, SECRET, { expiresIn: '-1s' }); const ctx = makeCtx(`Bearer ${expired}`); await expect(auth()(ctx, async () => {})).rejects.toMatchObject({ status: 401 }); }); test('角色不符返回 403', async () => { const token = jwt.sign({ sub: 'u1', role: 'user' }, SECRET, { expiresIn: '1h' }); const ctx = makeCtx(`Bearer ${token}`); await expect(auth({ roles: ['admin'] })(ctx, async () => {})) .rejects.toMatchObject({ status: 403 }); }); test('合法 token 放行且写入 state', async () => { const token = jwt.sign({ sub: 'u1', role: 'user' }, SECRET, { expiresIn: '1h' }); const ctx = makeCtx(`Bearer ${token}`); let passed = false; await auth()(ctx, async () => { passed = true; }); expect(passed).toBe(true); expect(ctx.state.user).toEqual({ id: 'u1', role: 'user' }); });
注意环境变量的处理:测试进程里 JWT_SECRET 用测试专用值,绝不复用开发或生产密钥——测试配置也是配置,按 3.3 节的纪律走注入。
单测验证单层行为,集成测试验证装配后的整条洋葱——层序、异常冒泡、响应形态,只有真实串联才能暴露。supertest 直接吃 app.callback(),不起端口:
// orders.api.test.js const request = require('supertest'); const { app } = require('../app'); // app.js 导出实例而非直接 listen describe('POST /api/v1/orders', () => { test('未登录回 401', async () => { const res = await request(app.callback()).post('/api/v1/orders').send({}); expect(res.status).toBe(401); }); test('参数缺失回 400 且带字段明细', async () => { const res = await request(app.callback()) .post('/api/v1/orders') .set('authorization', `Bearer ${validToken}`) .send({}); expect(res.status).toBe(400); expect(res.body.code).toBe('INVALID_ARGUMENT'); expect(res.body.errors).toEqual( expect.arrayContaining([ expect.objectContaining({ field: 'items' }), ]), ); }); test('成功创建回 201 与 Location', async () => { const res = await request(app.callback()) .post('/api/v1/orders') .set('authorization', `Bearer ${validToken}`) .send({ items: [{ sku: 'A1', qty: 1 }], address: '某市某路 1 号' }); expect(res.status).toBe(201); expect(res.headers.location).toMatch(/\/orders\//); }); });
三个测试分别锁住了 7.2 的鉴权流、4.3 的校验层、4.2 的创建语义——它们同时也是文档:新同事读测试就知道接口的契约形态。集成测试的数据库用测试库或内存替身,每个用例前清场,保证可重复。
断点调试走 Inspector:node --inspect app.js 后 Chrome 打开专用面板,Koa 代码里所有 await 的跳转都可视化——排查 6.1 节那类"异步时序"问题时,断点比日志直观得多。线上不能挂 Inspector,两条退路:诊断报告,process.report(或 kill -USR2)生成包含堆、句柄、环境快照的 JSON,离线分析内存与句柄问题;动态日志,6.2 节的 LOG_LEVEL 运行时可调,排查时临时开 debug 不用改代码重发。
线上排障的标准化动线再走一遍:告警(下一小节)→ 按 reqId 检索错误日志(6.2 节)→ 同号展开访问轨迹 → 必要时取诊断报告。整个链路没有一步依赖"登录到机器上翻文件"——翻机器是最后手段,不是第一反应。
监控不必一步到 APM 全家桶,四个指标先立住(业界称黄金信号:延迟、流量、错误、饱和度):
function metrics() { const counters = { total: 0, errors: 0 }; const buckets = [10, 50, 100, 500, 2000]; // 耗时分桶 const hist = new Array(buckets.length + 1).fill(0); return async (ctx, next) => { const start = Date.now(); try { await next(); } finally { counters.total += 1; if (ctx.status >= 500) counters.errors += 1; const cost = Date.now() - start; const idx = buckets.findIndex((b) => cost < b); hist[idx === -1 ? buckets.length : idx] += 1; } // 暴露在内部端点供采集器抓取(示意;生产可换 prom-client 库) }; } router.get('/internal/metrics', async (ctx) => { ctx.body = { ...counters, latencyBuckets: hist.map(String) }; });
指标有了,告警沿用 6.2 节的窗口计数纪律,阈值从两个角度设:错误率(5xx 占比连续五分钟超 1% 告警)与饱和度(事件循环延迟、内存占用量级的连续偏离)。P99 延迟看板主要用于容量规划与优化前后对照——它是"趋势镜"不是"警报器",突变类问题错误率告警跑得更快。
💡 关键直觉:测试与监控是同一枚硬币的两面——测试回答"这次改动对不对",监控回答"线上此刻好不好"。两者共享同一套契约认知(状态码语义、错误结构、指标口径),这也是为什么它们应该由同一批人维护,而不是测试归 QA、监控归 SRE 各管一段。
底盘建成,稳态运行闭环走通。最后一章抬起视野:TypeScript 的类型化洋葱、微服务与 Serverless 里的新形态、两个完整案例的复盘——剥层之旅的终点站。