CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
后端入门实战/课时/集成与功能测试
RawBlame
课时目录12 节

后端入门实战

  1. 01后端入门概览
  2. 02Git 与托管
  3. 03语言选型
  4. 04PostgreSQL
  5. 05数据库进阶
  6. 06Redis 与缓存
  7. 07REST API
  8. 08认证
  9. 09ORM
  10. 10单元测试
  11. 11集成与功能测试
  12. 12综合练习

集成与功能测试

API 集成测试。

30 分钟

集成与功能测试

这一课你将能做到

  1. 用一张对照表说清单测 / 集成测试 / 功能(E2E)测试的边界、成本与数量配比
  2. 把 Express 应用拆成「可导出的 app + 独立的启动入口」,并用 supertest 写出不占端口的 API 集成测试
  3. 落地测试数据库的三种隔离策略(独立测试库 / 事务回滚 / 每轮清理),并搭一个测试专用环境

为什么要学

上一课的单测把每个零件都验过,但零件全对不代表机器能转:路由注册漏了、中间件顺序错了、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);
  });
});
npm test   # 全绿,没有占用任何端口

对比上一课:没有 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();
});

API 级功能验证(不启动浏览器)两个日常工具:

# 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 可复用。

讲清楚要点

  1. app 与 listen 分离是集成测试的前置工程:导出 app(可注入、可测试)+ 独立入口(生产启动)。这一个改动同时让测试不占端口、同一测试文件可并发创建多个应用实例。
  2. 集成测试测什么:路由与方法匹配、中间件顺序(鉴权在业务前)、请求体解析、状态码与响应结构、持久化真的发生了(写后读)、错误分支返回体。不测什么:业务计算的每个分支(那是单测的活)、第三方服务的内部正确性(mock 掉或用契约测试)。
  3. 数据隔离是集成测试的生命线:测试必须能以任意顺序、任意并发执行。三种策略选一种执行到底,最怕「偶尔清理一下」——CI 挂了先怀疑测试残留,是最浪费时间的事。
  4. CI 里的执行顺序:lint → 单测(不依赖环境,先跑先挂先报)→ 起服务容器(docker compose up -d 或 services 配置)→ 等待就绪(健康检查重试)→ 集成测试 → E2E(可选,最贵放最后)。
  5. 冒烟测试(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)

卡点急救

  1. supertest 报 ECONNREFUSED:把 app 直接传给了 request(正确),却误写成了 request('http://localhost:3000') 还没启动服务。前者不需要网络,检查参数。
  2. 测试偶发互相污染:确认 beforeEach 清理了所有相关表/状态(含缓存 key:测试前后 FLUSHDB 测试专用 Redis);并行时给每个 worker 分配独立 schema。
  3. TRUNCATE ... CASCADE 报表不存在:测试库没跑迁移。在 beforeAll 里先执行迁移命令,或本地初始化时 npx prisma migrate deploy 指向测试库。
  4. CI 里集成测试随机超时:容器没就绪就开跑。加 wait-for(until curl -sf $DB_HOST:5432; do sleep 1; done)或 GitHub Actions 的 services + health check 配置。
  5. JWT 相关测试全挂:测试环境的 JWT_SECRET 与签发环境不一致。所有测试统一从 .env.test 读密钥,不要在代码里写第二份默认值。

面试常问

  • 单测、集成、E2E 各自的价值与配比? 单测(70%):逻辑正确性、毫秒级、随手跑;集成(20-25%):模块接缝、真实持久化、秒级;E2E(5-10%):主干路径、分钟级。配比背后是「反馈速度 × 定位精度 × 成本」的平衡,不是拍脑袋数字。

  • 如何保证测试不污染生产数据? 制度 + 技术:测试环境独立(独立库/独立实例/独立配置);连接串与生产物理隔离;CI 凭证只授测试环境;代码评审盯「测试里出现的真实地址」。若必须用类生产数据,走脱敏导出。

  • 什么是冒烟测试?什么时候执行? 最小验证集:部署后立刻跑(服务存活 + 核心链路一次通过)。快速决策「这次发布是否继续」;失败即回滚,不值得细查。

  • 说说你在 CI 里怎么组织测试流水线。 分层执行:lint+单测(秒级,先失败先暴露)→ 服务容器启动与健康等待 → 集成测试 → 构建产物 → 冒烟/E2E。失败即拦截合并;测试产物(覆盖率报告、失败截图)归档。

  • 怎么测一个依赖第三方 API 的接口? 集成测试里 mock 第三方(替身服务或 msw 拦截),验证我方逻辑对「成功/超时/限流」各分支的响应;真正的第三方对接验证交给低频的契约测试或手工联调环境。

自测三问

  1. 把步骤 2 的测试改成接真 PostgreSQL(Prisma 或 pg 均可),实现策略 C 的数据清理,并验证并行执行不互相污染。
  2. 「注册 → 登录 → 创建笔记 → 查列表」是一条含 JWT 的完整集成链路——写出测试骨架(伪步骤即可),标注哪些数据需要跨请求传递。
  3. 你的项目 CI 流水线里,四层测试分别放在哪个阶段?容器化的测试依赖(PG/Redis)怎么注入?

小结一句话

集成测试验证「拼起来的缝」:app 与启动分离、supertest 免端口打请求、独立测试库加固定清理保证任意顺序可跑;功能测试守主干路径,冒烟测试守发布底线。

延伸

  • supertest 文档
  • Playwright 官方教程
  • 下一课:综合练习——用 12 课所学从零造一个完整后端服务,把知识拧成能力。
加载本节练习…
← 上一课单元测试下一课 →综合练习