使用 SuperTest 为 Express 路由与控制器编写单元测试:从模块拆分到请求断言
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
导读
在 Express 应用中,路由(Route)与控制器(Controller)承载了绝大部分业务逻辑,而测试它们往往比测试纯函数更棘手——因为一个 HTTP 请求会完整地穿过中间件链、路由匹配与响应生成。本文以本仓库 NodeJS 课程体系为背景,讲解如何使用supertest在不真正启动服务器的情况下对 Express 路由与控制器进行单元测试,涵盖模块导出前提、app.listen规避、.expect()断言链、done回调以及基于 Promise 的请求编排。学完本文,你将能够为任意 Express 路由编写可复现、可维护的测试用例,并理解 SuperTest 与 SuperAgent 的关系。
为什么需要单独测试 Express 路由与控制器
单元测试的重要性无需赘述——如果你已经完成本仓库前端 JavaScript 课程中的测试章节,应该已经接触过单元测试。本篇的目的不是重新讲授测试哲学或测试语法,而是回答一个更具体的问题:单元测试如何应用到 Express 应用与 API 上。
Express 应用的请求处理链路是:请求进入 → 应用级中间件 → 路由匹配 → 控制器(本质上是终止请求-响应循环的中间件)。整条链路的任意一环出错,都会导致接口行为异常。因此,对路由与控制器进行测试,实际上是在验证这条链路的关键环节是否符合预期。
本仓库的 controllers.md 明确指出:控制器在 Express 世界中本身就是一种中间件,由路由处理器使用,负责从req中提取参数、调用模型/数据库、并通过res发送响应。测试路由与控制器,就是验证“给定一个请求,能否得到正确的响应”。
前提:被测试的代码必须位于导出的模块中
对任何代码进行测试的最基本要求是:它必须处于一个导出的模块中。这条规则对自定义中间件和路由/控制器同样适用。因此,测试前的第一步,是把路由和控制器从启动文件里分离出来,放进各自的模块——如果它们还没被分离的话。
在路由的场景下,你已经知道如何用Express.Router完成这件事。本仓库的 routing.md 讲解了路由器的用法:通过Router()创建路由实例,在其上定义.get、.post等方法,最后module.exports导出,再在app.js中用app.use("/path", router)挂载。
下面是一个最基础的示例。首先是应用的启动文件app.js:
//// app.js const express = require("express"); const app = express(); app.use(express.urlencoded({ extended: false })); const indexRouter = require("./index"); app.use("/", indexRouter); app.listen(3000, (error) => { if (error) { throw error; } console.log("running"); });然后是独立的路由模块index.js:
//// index.js const express = require("express"); const index = express.Router(); const array = []; index.get("/", (req, res) => { res.json({ name: "frodo" }); }); index.get("/test", (req, res) => res.json({ array })); index.post("/test", (req, res) => { array.push(req.body.item); res.send('success!'); }); module.exports = index;这两个文件定义了几条路由,然后搭建并启动了 Express 应用。请注意这里的职责划分:
app.js目前不需要测试:它只包含启动和运行 Express 应用的代码,没有任何自有业务逻辑。它做的只是挂载中间件、挂载路由、调用app.listen。index.js包含需要测试的内容:array状态、GET /、GET /test、POST /test这些路由处理逻辑都在这里。
这种“启动文件与路由模块分离”的结构,正是本仓库 project_mini_message_board.md 等项目中反复出现的模式——消息板项目同样把messages数组放在 index 路由器顶部,并通过导出的路由器暴露接口。它让测试变得可行,也让应用更易维护。
可测试性设计要点:如果路由处理器、控制器逻辑全部内联在
app.js中且未被导出,测试将无从下手。导出模块是单元测试的第一步,也是代码结构是否健康的试金石。
引入 SuperTest 与 Jest
为了真正测试这些路由,我们将使用一个名为SuperTest的库,并且为了让测试语法更熟悉,本课示例将其与Jest搭配使用。
npm install jest supertest --save-dev安装的同时,值得花几分钟浏览 SuperTest 的 README(下文也会解释它的核心设计)。需要强调:
- SuperTest 与 Jest 是不同的东西:Jest 提供测试运行器、断言与测试生命周期(
test、beforeEach等);SuperTest 负责向 Express 应用发起真实的 HTTP 请求并断言响应,而不需要真的把端口监听起来。 - 它俩是协作关系:Jest 驱动测试的执行,SuperTest 提供请求与响应断言的 API。
第一个测试文件:完整示例
下面是我们完整的测试文件:
const index = require("../index"); const request = require("supertest"); const express = require("express"); const app = express(); app.use(express.urlencoded({ extended: false })); app.use("/", index); test("index route works", done => { request(app) .get("/") .expect("Content-Type", /json/) .expect({ name: "frodo" }) .expect(200, done); }); test("testing route works", done => { request(app) .post("/test") .type("form") .send({ item: "hey" }) .then(() => { request(app) .get("/test") .expect({ array: ["hey"] }, done); }); });接下来我们逐步拆解这段代码。
导入被测模块
首先,导入我们要测试的模块——也就是上面的index.js:
const index = require("../index");这是整个测试的入口:没有这个导入,测试就无从谈起。被测对象必须是可导入、可挂载的模块,这再次印证了第一节的“导出前提”。
为什么在测试里新建一个 Express 应用
接着引入supertest和express,并新建一个 Express 应用,把之前导入的 index 路由器挂载上去:
const request = require("supertest"); const express = require("express"); const app = express(); app.use(express.urlencoded({ extended: false })); app.use("/", index);为什么必须在这里重新搭一个 app,而不是直接测原来的app.js?
- 避免调用
app.listen、不启动真实服务器:这是最主要的原因。SuperTest 可以把 Express 应用对象直接作为请求目标,在内存中模拟 HTTP 请求,因此完全不需要占用端口。这也意味着测试更快、更干净,不会与开发中的服务器冲突。 - 按需组装应用:在更大的应用中,我们可以跳过一些可选的配置步骤,只包含测试所必需的中间件。例如这里只需要
express.urlencoded来解析表单体(POST /test依赖req.body.item),就不需要挂载静态资源、会话等与测试无关的中间件。 - 隔离性:测试环境与生产启动逻辑解耦,避免
app.js中的任何启动副作用污染测试。
express.urlencoded({ extended: false })的作用是把表单提交的数据解析进req.body,这正是本仓库 forms_and_data_handling.md 与 project_mini_message_board.md 中处理表单的标准姿势——在消息板项目中,同样的中间件被用于把<input name="messageText">的值解析为req.body.messageText。测试中必须复现这一中间件,否则POST /test拿不到req.body,断言必然失败。
在更大的测试套件中,这段“组装 app”的代码很可能被抽象成独立文件(例如testApp.js),被每个测试文件导入复用——这与路由、控制器被拆分成模块的思路一脉相承。
第一个测试:GET 请求与.expect()断言链
test("index route works", done => { request(app) .get("/") .expect("Content-Type", /json/) .expect({ name: "frodo" }) .expect(200, done); });得益于 SuperTest,测试本身相当直白:我们把request函数调用在我们的 Express 应用上,传入路由,然后使用.expect()断言响应与期望的类型和内容一致。
逐行解读这条断言链:
request(app):发起针对该 app 的请求。.get("/"):指定 HTTP 方法与路径,等价于向GET /发请求。.expect("Content-Type", /json/):断言响应头Content-Type匹配正则/json/。这里用正则而非精确字符串,是因为 Express 实际返回的可能是application/json; charset=utf-8,正则匹配更宽容、更稳健。.expect({ name: "frodo" }):断言响应体是一个对象{ name: "frodo" }——与index.js中GET /返回的res.json({ name: "frodo" })完全对应。.expect(200, done):断言状态码为 200,并把done回调交给 SuperTest 代为调用。
done参数:异步测试的完成信号
注意传入测试回调的done参数。大多数测试库用它来标记异步操作的测试完成时机:当done()被调用时,Jest 才会认为该测试结束;若不调用,测试将一直挂起直至超时。
SuperTest 提供的一个便利之处是:你可以把done直接传入最后一个.expect(),SuperTest 会在断言全部通过后替你调用它:
.expect(200, done);等价于手动写法:
.expect(200) .end((err) => { if (err) throw err; done(); });关于done与.end()的差异,需要特别注意错误处理方式:使用.end(callback)时,断言失败的错误会作为回调参数传入,需要你手动处理;而把done直接传给.expect(),SuperTest 会在断言失败时直接把错误交给测试框架。理解这两种写法的区别,是知识检查中的重点之一。
第二个测试:POST + Promise 编排
第二个测试与第一个很相似,但测试的是post方法:
test("testing route works", done => { request(app) .post("/test") .type("form") .send({ item: "hey" }) .then(() => { request(app) .get("/test") .expect({ array: ["hey"] }, done); }); });.post("/test"):向POST /test发送请求。.type("form"):把请求体编码类型设置为表单(application/x-www-form-urlencoded),确保服务端的express.urlencoded中间件能正确解析。.send({ item: "hey" }):发送请求体数据,对应index.js中array.push(req.body.item)要读取的req.body.item。
最后一段很重要:SuperTest 的请求返回一个 Promise(底层由 SuperAgent 提供)。在这里我们等待 POST 请求完成,当该 Promise 解析后,再发起 GET 请求,检查item是否真的被 push 进了array:
.then(() => { request(app) .get("/test") .expect({ array: ["hey"] }, done); });这验证的是状态变化:POST 写入数据 → GET 读取数据 → 断言数据确实被写入。这类“先写后读”的测试是路由集成测试的典型形态。
SuperTest 与 SuperAgent:一脉相承的 API
SuperTest 实际上从另一个相关项目SuperAgent中获取了核心能力。二者关系可以概括为:
- SuperAgent是一个 Node.js 端的 HTTP 客户端库,负责实际的请求发送与响应解析。
- SuperTest构建在 SuperAgent 之上,把“向真实 HTTP 服务器发请求”的能力“重定向”为“直接向 Express 应用对象发请求”。
- 凡是在 SuperAgent 中可调用的方法,在 SuperTest 中同样可以调用。
这意味着,SuperTest 的能力边界不止于本课示例中的.get()、.post()、.type()、.send()、.expect()。举一些常用的扩展场景:
- Multipart 请求:SuperAgent 提供了处理 multipart 请求的方法(如
attach()附加文件、field()附加表单字段),SuperTest 同样可用——这也是知识检查中要求掌握的点。 - 请求头与认证:可通过
.set()设置自定义请求头、携带 Authorization 凭证。 - 重定向与超时:可通过
.redirects()、.timeout()等方法控制请求行为。
建议的查阅路径:先通读 SuperTest 的文档(README)掌握其核心 API,再浏览 SuperAgent 的文档以了解所有可用的请求方法。本仓库的 testing_database_operations.md 还展示了 SuperTest 在集成测试场景下的进一步用法(配合测试数据库与 Prisma)。
数据层测试的注意事项
如果我们的代码使用了真实数据库,那么测试时就应该采用测试数据库或模拟数据库,相关内容会在后续独立课程中展开。现阶段只需牢记一条铁律:
绝不要在生产数据库上运行测试代码!
在 Express 项目中,测试数据库的配套方案(如以test_前缀命名独立数据库、通过TEST_DATABASE_URL环境变量切换连接串、用beforeEach重置表数据、为 Jest 添加--runInBand保证串行执行等),详见 testing_database_operations.md。本课的路由测试全部基于内存中的array,天然避开了数据库污染问题——这也是把可变状态收进模块、用 SuperTest 做内存请求的好处之一。
课程回顾与自检清单
完成本课学习后,你应该能回答以下问题(也可作为自测):
- SuperTest 存在的动机是什么?它允许开发者在不启动真实 HTTP 服务器的情况下,用接近真实请求的方式测试 Express 应用——直接向应用对象注入请求并断言响应。
done的用途是什么?SuperTest 提供了什么便利?done用于向测试框架报告异步测试完成;SuperTest 允许把它直接传给最后一个.expect(),由库自动调用。.end()与.expect()配合使用时的错误处理差异?.end(callback)会把断言错误作为回调参数传入,需要手动处理;直接传done给.expect()则由库代为抛给测试框架。- SuperAgent 提供了哪些处理 multipart 请求的方法?例如
attach()与field(),且这些方法在 SuperTest 中同样可用。
此外,本课的核心结论可以浓缩为三条工程实践:
- 分离即测试的前提:把路由/控制器放入导出的模块(如
index.js),启动逻辑留在app.js; - 组装最小化测试应用:在测试文件中新建 Express 应用,仅挂载测试所需中间件与路由器,绕开
app.listen; - 断言与编排:用
.expect()链断言状态码、响应头(正则匹配)与响应体;用 Promise 链编排“写后读”等多步请求。
将这些实践应用到本仓库 project_mini_message_board.md、project_inventory_application.md 等项目上,你就能为消息板、库存管理等真实 Express 应用搭建起第一层自动化的路由与控制器测试防线。
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考