本节摘要:单元测试验证零件,集成测试验证组装。本节用 supertest 直接打 Express 应用的完整请求-响应链路,讲清外部依赖的隔离策略(测试数据库、替身服务),辨析 stub/mock/fake 三种替身,最后完整走一遍测试驱动开发的红绿重构循环,体会「先写测试」如何反过来塑造设计。
集成测试最怕的就是「必须先把服务跑起来」。supertest 的妙处是让 HTTP 请求直接发生在进程内——不占端口、不走网卡,请求穿过完整的中间件管道,返回结果:
const request = require('supertest'); const app = require('../app'); // Express 应用(不必 listen) describe('订单接口', () => { it('GET /api/orders/1 返回存在的订单', async () => { const res = await request(app).get('/api/orders/1'); expect(res.status).toBe(200); expect(res.body).toMatchObject({ id: 1 }); }); it('GET /api/orders/999 返回 404', async () => { const res = await request(app).get('/api/orders/999'); expect(res.status).toBe(404); expect(res.body.error).toBe('订单不存在'); }); it('POST 缺字段返回 400', async () => { const res = await request(app).post('/api/orders').send({}); expect(res.status).toBe(400); }); });
注意 app 不调用 listen——supertest 自己起了个临时端口并把请求喂进去。这套测试覆盖了体解析、路由、错误链整条管道,这正是单元测试给不了的「组装级真实感」。
集成测试「真」的边界要画清楚:中间件管道要真,数据库可用临时实例,第三方服务必须隔离。三层策略:
第一层: 进程内 — Express 应用与中间件全真, supertest 直连 第二层: 数据 — 每次测试跑独立的临时库或事务回滚, 用例之间互看不见 第三层: 外部 — 支付、短信等第三方用替身服务挡住, 永不在测试里触达真实世界
数据隔离的常用做法是 beforeEach 里清表或回滚事务:
let db; beforeAll(async () => { db = await createTestDatabase(); // 临时库, 测试完整个删掉 }); beforeEach(async () => { await db.seed('baseline'); // 每个用例从同一份基准数据出发 }); afterAll(async () => { await db.drop(); });
⚠️ 常见坑:用例之间通过数据库残留数据「暗中合作」——单独跑某个用例失败、全量跑就通过。这是顺序依赖的典型症状,beforeEach 重置基准数据是唯一可靠的解法。
三个词常被混用,边界其实清楚:
| 替身 | 回答的问题 | 典型用法 |
|---|---|---|
| stub | 「给它输入,它吐什么」 | 支付接口替身:传成功参数就返回成功 |
| mock | 「它被怎么调用过」 | 断言下单失败时通知函数从未被调用 |
| fake | 「有个能跑的简装实现」 | 内存数据库、内存文件系统 |
选型直觉:关心返回结果用 stub,关心交互行为用 mock,需要可工作的简化实现用 fake。一个健康代码库的替身以 stub/fake 为主,mock 少而精——断言交互太多,测试会跟实现细节锁死,重构寸步难行。
// stub: 只管返回 const payStub = stubPaymentGateway({ result: 'SUCCESS' }); // mock: 验证交互 const mailer = { send: jest.fn() }; placeOrder({ ...order, mailer }); expect(mailer.send).not.toHaveBeenCalled(); // 失败时不能发信 // fake: 能跑的简装品 class MemoryUserStore { /* 用 Map 模拟用户表 */ }
需求:实现一个限流器——每分钟每个用户最多允许 N 次请求,超出则拒绝并告知剩余等待秒数。TDD 的纪律是先写会失败的测试(红),再写刚好让它通过的实现(绿),然后重构。
第一步,红。写测试时顺便把 API 的形状设计出来:
const { createLimiter } = require('./limiter'); describe('限流器', () => { it('限额内放行', () => { const lim = createLimiter({ limit: 3, windowMs: 60000 }); expect(lim.check('u1').allowed).toBe(true); }); it('超额拒绝并给出等待秒数', () => { jest.useFakeTimers(); const lim = createLimiter({ limit: 2, windowMs: 60000 }); lim.check('u1'); lim.check('u1'); const r = lim.check('u1'); expect(r.allowed).toBe(false); expect(r.retryAfterSec).toBeGreaterThan(0); }); it('窗口滑动后恢复', () => { jest.useFakeTimers(); const lim = createLimiter({ limit: 2, windowMs: 60000 }); lim.check('u1'); lim.check('u1'); jest.setSystemTime(Date.now() + 61000); expect(lim.check('u1').allowed).toBe(true); }); });
第二步,绿。写最简单的实现让测试通过——滑动窗口用「时间戳数组」就够了:
function createLimiter({ limit, windowMs }) { const hits = new Map(); // userId -> 时间戳数组 return { check(userId) { const now = Date.now(); const arr = (hits.get(userId) || []).filter(t => now - t < windowMs); if (arr.length >= limit) { const oldest = arr[0]; return { allowed: false, retryAfterSec: Math.ceil((oldest + windowMs - now) / 1000) }; } arr.push(now); hits.set(userId, arr); return { allowed: true }; }, }; } module.exports = { createLimiter };
第三步,重构。测试全绿的保护下,把 Map 换成 LRU 防止用户键无限增长、抽取窗口判断为纯函数——行为不变、结构更好,测试持续见证。一轮结束,下一个需求再从红开始。
这个循环的价值不在「测试多了」,而在写测试的时刻被迫当了第一个使用者:上面的 API 形状(check 返回 allowed 与 retryAfterSec)是在写测试时自然长出来的,而不是实现完再补文档。这就是「测试驱动设计」的本义。
诚实地说,不是。探索性原型、一次性脚本、界面布局这类「答案会随写随变」的工作,先写测试反而拖慢迭代。TDD 回报最高的是边界清晰、规则明确的领域逻辑——计费、限流、权限、状态机。团队的务实做法是划定核心域强制测试覆盖,其余区域鼓励但不强求,让测试成为安全网而不是枷锁。