6.2 集成测试与TDD实战


6.2 集成测试与 TDD 实战

本节摘要:单元测试验证零件,集成测试验证组装。本节用 supertest 直接打 Express 应用的完整请求-响应链路,讲清外部依赖的隔离策略(测试数据库、替身服务),辨析 stub/mock/fake 三种替身,最后完整走一遍测试驱动开发的红绿重构循环,体会「先写测试」如何反过来塑造设计。

动手目标

  1. 能用 supertest 对 Express 应用做无端口的接口测试
  2. 能为集成测试准备隔离的数据环境并在用例间清理
  3. 能区分并选择 stub、mock、fake
  4. 能按红绿重构节奏完成一个小功能的 TDD 全流程

一、supertest:不打网络的网络测试

集成测试最怕的就是「必须先把服务跑起来」。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,关心交互行为用 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 模拟用户表 */ }

四、TDD 实战:红绿重构一轮

需求:实现一个限流器——每分钟每个用户最多允许 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 适合所有场景吗

诚实地说,不是。探索性原型、一次性脚本、界面布局这类「答案会随写随变」的工作,先写测试反而拖慢迭代。TDD 回报最高的是边界清晰、规则明确的领域逻辑——计费、限流、权限、状态机。团队的务实做法是划定核心域强制测试覆盖,其余区域鼓励但不强求,让测试成为安全网而不是枷锁。

本节要点回顾

  • supertest 进程内打接口:不占端口,覆盖完整中间件管道。
  • 隔离三层:应用真、数据临时、外部必挡。
  • beforeEach 重置:杀死用例间的顺序依赖。
  • 替身三问:关心返回用 stub、关心交互用 mock、要能跑用 fake。
  • 红绿重构:先红的测试是 API 的第一份设计稿,绿后的重构有安全网。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U