集成与功能测试
这一课你将能做到
- 用一张对照表说清单测 / 集成测试 / 功能(E2E)测试的边界、成本与数量配比
- 把 Express 应用拆成「可导出的 app + 独立的启动入口」,并用 supertest 写出不占端口的 API 集成测试
- 落地测试数据库的三种隔离策略(独立测试库 / 事务回滚 / 每轮清理),并搭一个测试专用环境
为什么要学
上一课的单测把每个零件都验过,但零件全对不代表机器能转:路由注册漏了、中间件顺序错了、SQL 写对了但字段映射歪了、缓存键冲突了——这些「拼起来的缝」只有集成测试能抓到。它是后端性价比最高的测试层:比 E2E 快得多,比单测真实得多。
人话:单测像检查每块砖都合格,集成测试像检查砖缝有没有砌歪。功能测试(E2E)则是「找个用户从头走一遍」——浏览器点开、登录、下单全链路。三层各管一段,谁也替代不了谁。
先建立图像
三种测试的分工一图看清:
被测范围 依赖真实度 速度 数量
单元测试 一个函数/类 全 mock ms 级 最多
集成测试 路由+服务+真DB/缓存 依赖真实(测试库) 秒级 适中 ← 本课主角
功能/E2E 整个系统含前端 全真实环境 分钟级 最少
典型场景:"创建笔记"接口
单测: validateNote() 校验逻辑对不对
集成测试: POST /api/notes → 进库 → 返回 201,再 GET 能查到吗
功能测试: Playwright 开浏览器 → 填表单 → 点提交 → 断言页面出现新笔记
跟着做
步骤 1:先拆 app 与 server——可测试性的第一步
第 7 课的项目把 listen 和路由写在一个文件里,测试没法导入。标准拆法:
mkdir bebg-test-int && cd bebg-test-int && npm init -y && npm install express
npm install -D vitest supertest
package.json 补上 "type": "module" 与 "scripts": { "test": "vitest run" }。
// src/app.js —— 只定义应用,不监听端口(测试可直接导入)
import express from 'express';
const state = { notes: [], nextId: 1 }; // 演示用内存存储,接真库的版本见步骤 3
export function createApp(store = state) {
const app = express();
app.use(express.json());
app.get('/api/notes', (req, res) => {
res.json({ data: store.notes, total: store.notes.length });
});
app.post('/api/notes', (req, res) => {
const { title } = req.body || {};
if (!title?.trim()) return res.status(400).json({ error: 'title 必填' });
const note = { id: store.nextId++, title: title.trim() };
store.notes.push(note);
res.status(201).json(note);
});
app.delete('/api/notes/:id', (req, res) => {
const i = store.notes.findIndex(n => n.id === Number(req.params.id));
if (i === -1) return res.status(404).json({ error: '笔记不存在' });
store.notes.splice(i, 1);
res.status(204).end();
});
return app;
}
// src/server.js —— 真正启动时才监听
import { createApp } from './app.js';
createApp().listen(3000, () => console.log('http://localhost:3000'));
注意设计点:createApp 接受 store 参数——依赖注入让测试可以传入全新状态(上一课 mock 思想在集成测试里的延续:不 mock 模块,注入数据源)。
步骤 2:用 supertest 写 API 集成测试
supertest 的妙处:直接把 HTTP 请求打进 app 对象,不占端口、不起进程。
// tests/api.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { createApp } from '../src/app.js';
function freshState() {
return { notes: [], nextId: 1 };
}
describe('Notes API(集成)', () => {
let app;
beforeEach(() => { // 每个测试前造全新实例,测试之间零污染
app = createApp(freshState());
});
it('创建后能查到(写读闭环)', async () => {
const created = await request(app)
.post('/api/notes')
.send({ title: '第一条' });
expect(created.status).toBe(201);
expect(created.body).toMatchObject({ id: 1, title: '第一条' });
const list = await request(app).get('/api/notes');
expect(list.status).toBe(200);
expect(list.body.total).toBe(1);
expect(list.body.data[0].title).toBe('第一条');
});
it('缺 title 返回 400 和错误说明', async () => {
const res = await request(app).post('/api/notes').send({});
expect(res.status).toBe(400);
expect(res.body.error).toBe('title 必填');
});
it('删除存在的笔记返回 204,再查列表为空', async () => {
await request(app).post('/api/notes').send({ title: '待删' });
const del = await request(app).delete('/api/notes/1');
expect(del.status).toBe(204);
expect((await request(app).get('/api/notes')).body.total).toBe(0);
});
it('删除不存在的笔记返回 404', async () => {
expect((await request(app).delete('/api/notes/999')).status).toBe(404);
});
});
对比上一课:没有 mock 任何模块,路由、解析、校验、状态码真实地走了一遍——这正是集成测试的价值。
步骤 3:接真数据库——三种隔离策略
真实项目的集成测试要连数据库。铁律只有一条:绝不碰开发库,更不可能碰生产库。三种主流隔离策略(按推荐顺序):
// tests/setup-db.js —— 策略 A:独立测试库 + 每轮清表(最常用)
import { PrismaClient } from '@prisma/client';
import { beforeAll, beforeEach, afterAll } from 'vitest';
// .env.test 里配独立库:DATABASE_URL="postgresql://postgres:postgres@localhost:5432/ormdb_test"
export const prisma = new PrismaClient({
datasources: { db: { url: process.env.DATABASE_URL_TEST } },
});
beforeAll(async () => {
await prisma.$connect();
await prisma.$executeRawUnsafe(
'TRUNCATE note_tags, notes, users RESTART IDENTITY CASCADE'
);
});
beforeEach(async () => {
// 策略 C:每条测试前清理,保证用例间互不可见
await prisma.note.deleteMany();
await prisma.user.deleteMany();
});
afterAll(async () => {
await prisma.$disconnect();
});
| 策略 | 做法 | 优点 | 代价 |
|---|
| A. 独立测试库 | 单独建 xxx_test 库 | 与生产库结构一致,真实验证 | 要维护 schema 同步(跑迁移) |
| B. 事务回滚 | 测试包在 BEGIN 里,结束 ROLLBACK | 天然隔离、不留垃圾 | 测试内不能自己开事务(冲突) |
| C. 每轮清理 | beforeEach 删数据 | 直白易懂 | 大表清理慢;并发跑 CI 要库隔离 |
工程上常见组合:每个 CI 任务建一个独立 schema/库(隔离并发)+ 每轮清理。本地开发用 Docker 起一个专门的测试库最省心:
docker run --name pg-test -e POSTGRES_PASSWORD=postgres -p 5433:5432 -d postgres:16
步骤 4:测试环境隔离(.env.test)
环境变量是测试隔离的开关,Vitest 读 vitest.config.mjs:
// vitest.config.mjs
import { defineConfig } from 'vitest/config';
import { loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ''); // 读取 .env.test
return {
test: {
env: { ...env },
globals: false,
},
};
});
# .env.test —— 与开发环境彻底分开
DATABASE_URL_TEST="postgresql://postgres:postgres@localhost:5433/ormdb_test"
REDIS_URL_TEST="redis://localhost:6380"
JWT_SECRET="test-only-secret"
配合 Redis:docker run --name redis-test -p 6380:6379 -d redis:7。测试代码里禁止出现任何生产地址——环境变量全部走 *_TEST,配置层做一次切换,业务代码零改动。
步骤 5:功能测试(E2E)与 API 工具全景
功能测试的代表作是 Playwright(驱动真实浏览器),后端工程师了解即可:
// e2e/notes.spec.js —— npm init playwright@latest 后可运行
import { test, expect } from '@playwright/test';
test('用户可以创建笔记并看到它', async ({ page }) => {
await page.goto('http://localhost:3000/notes');
await page.getByPlaceholder('笔记标题').fill('E2E 测试笔记');
await page.getByRole('button', { name: '提交' }).click();
await expect(page.getByText('E2E 测试笔记')).toBeVisible();
});
# 1) curl 脚本:写进 package.json 的 script,CI 里直接跑冒烟检查
curl -sf http://localhost:3000/api/health > /dev/null && echo "服务存活"
# 2) REST Client(VS Code 插件):.http 文件随仓库走,点一下就发请求
### 登录拿 token
POST http://localhost:3000/api/auth/login
Content-Type: application/json
{"username": "alice", "password": "password123"}
### 用 token 创建笔记
POST http://localhost:3000/api/notes
Authorization: Bearer {{login.response.body.token}}
Content-Type: application/json
{"title": "来自 .http 文件的笔记"}
Postman/Apifox 属于同类工具,区别是有图形界面和团队协作云;.http 文件的优势是进 Git、可评审、CI 可复用。
讲清楚要点
- app 与 listen 分离是集成测试的前置工程:导出 app(可注入、可测试)+ 独立入口(生产启动)。这一个改动同时让测试不占端口、同一测试文件可并发创建多个应用实例。
- 集成测试测什么:路由与方法匹配、中间件顺序(鉴权在业务前)、请求体解析、状态码与响应结构、持久化真的发生了(写后读)、错误分支返回体。不测什么:业务计算的每个分支(那是单测的活)、第三方服务的内部正确性(mock 掉或用契约测试)。
- 数据隔离是集成测试的生命线:测试必须能以任意顺序、任意并发执行。三种策略选一种执行到底,最怕「偶尔清理一下」——CI 挂了先怀疑测试残留,是最浪费时间的事。
- CI 里的执行顺序:lint → 单测(不依赖环境,先跑先挂先报)→ 起服务容器(
docker compose up -d 或 services 配置)→ 等待就绪(健康检查重试)→ 集成测试 → E2E(可选,最贵放最后)。
- 冒烟测试(Smoke Test):部署后的最小组验证集——服务能起、健康检查 200、核心接口通。它属于集成测试的子集,价值在「快速判断这次发布没把站点搞挂」。
你可能会懵的地方
-
集成测试把 DB mock 掉还算集成测试吗?
不算,那只是更大范围的单测。集成的本意就是验证「与真实依赖的接缝」——至少数据库、消息队列这类核心依赖要真实(跑在测试容器里)。只有真正付不起成本的外部依赖(短信、支付)才 mock。
-
功能测试和 E2E 是一回事吗?
基本同义,视角不同:功能测试从「需求」出发验证一个功能完整可用;E2E 强调「端到端」跨越全部层次。实际团队里这两个词经常混用,量级都是「少而精、只覆盖主干路径」。
-
为什么 supertest 不占端口也能测 HTTP?
它把 Express app 包成一个内存中的 Node http server,请求在进程内直达 handler。快、稳、无端口冲突。代价:测不到「端口监听、反向代理」这类部署层问题——那部分交给部署后的冒烟检查。
-
测试库的 schema 怎么和最新模型保持同步?
把「跑迁移」写进测试全局 setup:prisma migrate deploy(Prisma)或 alembic upgrade head(Python)。测试库结构必须由迁移产生,禁止手工建表——否则测出来的「通过」和真实结构无关。
-
集成测试太慢怎么办?
先诊断:多数慢在反复建连和全量清理。对策:连接池复用(beforeAll 建一次)、清表用 TRUNCATE 而不是逐行 DELETE、并行度交给「多库/schema 隔离」而不是共享数据。仍慢就砍数量——集成测试只保留「缝」,逻辑下沉回单测。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 集成测试连开发者本机共用库 | 数据互相覆盖,谁都跑不稳 | 独立测试库(容器),环境变量隔离 |
| 测试依赖执行顺序(先注册后登录) | 并行即挂 | 每个用例自建全部前置数据 |
| 断言只看状态码不看响应体 | 结构错了一样显示绿 | 关键字段 toMatchObject 逐一验 |
| 测试失败后手动重跑一次就过 | 存在随机依赖(时间/顺序/外部服务) | 复现并修根因,禁止「重跑哲学」 |
| CI 不等服务就绪就跑测试 | 连接拒绝的假失败 | 健康检查 + 重试,就绪后再执行 |
| 生产数据导进测试库 | 隐私泄漏 + 测试依赖脏数据 | 合成数据/脱敏固定夹具(fixture) |
卡点急救
- supertest 报
ECONNREFUSED:把 app 直接传给了 request(正确),却误写成了 request('http://localhost:3000') 还没启动服务。前者不需要网络,检查参数。
- 测试偶发互相污染:确认
beforeEach 清理了所有相关表/状态(含缓存 key:测试前后 FLUSHDB 测试专用 Redis);并行时给每个 worker 分配独立 schema。
TRUNCATE ... CASCADE 报表不存在:测试库没跑迁移。在 beforeAll 里先执行迁移命令,或本地初始化时 npx prisma migrate deploy 指向测试库。
- CI 里集成测试随机超时:容器没就绪就开跑。加 wait-for(
until curl -sf $DB_HOST:5432; do sleep 1; done)或 GitHub Actions 的 services + health check 配置。
- JWT 相关测试全挂:测试环境的
JWT_SECRET 与签发环境不一致。所有测试统一从 .env.test 读密钥,不要在代码里写第二份默认值。
面试常问
-
单测、集成、E2E 各自的价值与配比?
单测(70%):逻辑正确性、毫秒级、随手跑;集成(20-25%):模块接缝、真实持久化、秒级;E2E(5-10%):主干路径、分钟级。配比背后是「反馈速度 × 定位精度 × 成本」的平衡,不是拍脑袋数字。
-
如何保证测试不污染生产数据?
制度 + 技术:测试环境独立(独立库/独立实例/独立配置);连接串与生产物理隔离;CI 凭证只授测试环境;代码评审盯「测试里出现的真实地址」。若必须用类生产数据,走脱敏导出。
-
什么是冒烟测试?什么时候执行?
最小验证集:部署后立刻跑(服务存活 + 核心链路一次通过)。快速决策「这次发布是否继续」;失败即回滚,不值得细查。
-
说说你在 CI 里怎么组织测试流水线。
分层执行:lint+单测(秒级,先失败先暴露)→ 服务容器启动与健康等待 → 集成测试 → 构建产物 → 冒烟/E2E。失败即拦截合并;测试产物(覆盖率报告、失败截图)归档。
-
怎么测一个依赖第三方 API 的接口?
集成测试里 mock 第三方(替身服务或 msw 拦截),验证我方逻辑对「成功/超时/限流」各分支的响应;真正的第三方对接验证交给低频的契约测试或手工联调环境。
自测三问
- 把步骤 2 的测试改成接真 PostgreSQL(Prisma 或 pg 均可),实现策略 C 的数据清理,并验证并行执行不互相污染。
- 「注册 → 登录 → 创建笔记 → 查列表」是一条含 JWT 的完整集成链路——写出测试骨架(伪步骤即可),标注哪些数据需要跨请求传递。
- 你的项目 CI 流水线里,四层测试分别放在哪个阶段?容器化的测试依赖(PG/Redis)怎么注入?
小结一句话
集成测试验证「拼起来的缝」:app 与启动分离、supertest 免端口打请求、独立测试库加固定清理保证任意顺序可跑;功能测试守主干路径,冒烟测试守发布底线。
延伸