Next.js 服务端单测实践:用 next/experimental/testing/server 验证 next.config 路由与 Middleware
本篇技术指南聚焦 Next.js 仓库中位于 packages/next/src/experimental/testing/server 的实验性测试工具包。它提供了用于单元测试 Next.js 服务端逻辑的辅助函数——例如基于 next.config.js 的 headers/redirects/rewrites 自定义路由,以及 middleware 的 matcher 匹配行为——让你无需启动真实的开发或生产服务器,就能在代码上线前以纯内存方式验证重定向、重写、响应头注入与中间件是否按预期工作。读完本文,你将掌握 unstable_getResponseFromNextConfig、unstable_doesMiddlewareMatch 等 API 的参数语义、底层匹配原理与断言辅助函数,并能在自己的 Jest/单元测试体系中直接落地。
模块定位:为什么需要"测试服务端路由配置"
模块目录下的官方说明(README.md)给出了它的定位:这个目录包含用于单元测试 Next.js 服务端代码的辅助工具,例如基于 next.config.js 或 middleware 的路由逻辑。这些工具可以在代码到达生产环境之前,验证 redirects(重定向)、rewrites(重写)、添加 headers(响应头)或 middleware 逻辑的行为是否正确。
其价值在于把原本属于"运行时/端到端"范畴的验证提前到"纯单测"阶段。在真实项目中,验证一条 /test -> /test2 的重定向通常需要起服务器、发请求、断言响应;而该模块通过把自定义路由的"匹配 + 目的地址解析"逻辑在内存中执行一遍,直接返回一个标准的 NextResponse 对象供断言,让这类场景可以跑进毫秒级的单元测试。
从仓库结构看,该目录是一个独立自洽的小模块,包含四个源码文件与两份配套测试(config-testing-utils.test.ts、middleware-testing-utils.test.ts),并在根级 index.ts 统一对外导出。
模块入口与整体导出结构
packages/next/src/experimental/testing/server/index.ts 汇总了全部公共 API:
export * from './config-testing-utils'
export * from './middleware-testing-utils'
export { getRedirectUrl, getRewrittenUrl, isRewrite } from './utils'
从源码结构看,模块按"被测对象"拆成三个文件:
| 文件 | 面向的被测对象 | 核心导出 |
|---|---|---|
| config-testing-utils.ts | next.config.js 中的 headers / redirects / rewrites |
unstable_getResponseFromNextConfig |
| middleware-testing-utils.ts | middleware.ts 的 config.matcher 匹配范围 |
unstable_doesMiddlewareMatch、MiddlewareSourceConfig |
| utils.ts | 对测试返回的 NextResponse 做断言 |
getRedirectUrl、getRewrittenUrl、isRewrite、constructRequest |
在包的对外入口方面,packages/next/package.json 的 files 字段中登记了 experimental/testing/server.js 与 experimental/testing/server.d.ts 两个文件,它们分别指向构建产物 dist/experimental/testing/server(对应 taskfile.js 中把 src/experimental/testing/** 编译到 dist/experimental/testing 的流程)。因此,在安装 Next.js 的项目里实际使用的导入路径是:
import {
unstable_getResponseFromNextConfig,
unstable_doesMiddlewareMatch,
getRedirectUrl,
isRewrite,
getRewrittenUrl,
} from 'next/experimental/testing/server'
两个核心 API 均带 unstable_ 前缀,说明当前处于实验阶段,接口签名在未来版本中可能调整(本仓库中 packages/next/package.json 的版本为 16.4.0-canary.17,使用时应关注对应 Next.js 版本的发布说明)。
单元测试 next.config.js 自定义路由:unstable_getResponseFromNextConfig
这是整个模块的核心函数,用于测试 next.config.js 中 headers、redirects、rewrites 三类自定义路由的逻辑。它的注释给出了最典型的使用示例——测试某个 URL 是否产生重定向(见 config-testing-utils.ts):
import {
unstable_getResponseFromNextConfig,
getRedirectUrl,
} from 'next/experimental/testing/server'
const response = await unstable_getResponseFromNextConfig({
url: 'https://nextjs.org/test',
nextConfig: {
async redirects() {
return [{ source: '/test', destination: '/test2', permanent: false }]
},
},
})
expect(response.status).toEqual(307)
expect(getRedirectUrl(response)).toEqual('https://nextjs.org/test2')
函数签名与参数语义
函数声明位于 config-testing-utils.ts,其参数与对应语义为:
| 参数 | 类型 | 说明 |
|---|---|---|
url |
string |
被测请求的 URL,可携带 host 与 query。会同时用于构造请求与解析匹配路径。 |
nextConfig |
NextConfig | (…args) => NextConfig | Promise<NextConfig> |
被测的配置对象。可以传入函数——许多插件(如 withSentry 等)把配置包装成 async (phase, ctx) => config 的形式,该函数内部会透明地解析。 |
headers |
IncomingHttpHeaders |
可选。模拟请求头,可用于 has/missing 条件中的 header、host 匹配,以及 cookie 注入。 |
cookies |
Record<string, string> |
可选。以键值对形式传入,会被拼装成 Cookie 请求头,用于 has: { type: 'cookie' } 条件匹配。 |
调用成功后返回一个标准的 NextResponse 对象,可通过 .status 与 .headers 进行断言。
底层执行链路:从配置到 NextResponse
深入阅读 config-testing-utils.ts,可以还原它在内存中"模拟运行时路由"的完整流程:
-
解析 URL 并构造请求:
parse(url, true)解析路径与 query,随后交给同目录utils.ts的constructRequest,把请求包装成NodeNextRequest(MockedRequest(...))(GET 方法)。若未显式传host,会从 URL 中提取(utils.ts)。 -
规范化配置:调用 normalizeConfig,以
PHASE_PRODUCTION_BUILD作为 phase。这段实现同时解释了"配置可以是异步函数"的原因——normalizeConfig内部会执行config(phase, { defaultConfig })并await返回值,从而兼容async (phase, ctx) => config风格的插件包装。 -
加载自定义路由:调用
loadCustomRoutes(其源码位于 load-custom-routes)拿到标准化的 headers / redirects / rewrites 三类路由,再经buildCustomRoute(build-custom-route)编译为带正则、可参与has/missing判定的内部路由结构。注意 redirects 会传入['/_next/']排除列表,即模拟了生产构建中不对/_next/内部资源重定向的行为。 -
顺序匹配三类路由:先遍历 headers 命中并收集响应头;再遍历 redirects,命中的话返回
NextResponse.redirect(…)并携带已收集的 headers;随后才轮到 rewrites(beforeFiles、afterFiles、fallback三段的合并结果),命中即返回NextResponse.rewrite(…)。这忠实复刻了运行时"redirects 优先于 rewrites"的语义。全部未命中时返回一个status: 200、携带已匹配 headers 的空响应。 -
目的地址插值:redirect/rewrite 命中后,会调用
prepareDestination(来自 prepare-destination)把source提取出的:slug、:path*等参数插值进destination,再与 query 合并还原成完整 URL。
内部还依赖 matchRoute(config-testing-utils.ts)完成单条路由的匹配:先用编译出的正则匹配 pathname,再用 path-to-regexp 的 match 提取参数;若路由带 has/missing 条件,则调用 matchHas 基于请求的 headers、cookies、query 做二次判定,条件不满足时返回 undefined(视为不命中)。
各类场景的实测断言
仓库自带的 config-testing-utils.test.ts 用 Jest 覆盖了上述行为的各个分支,可直接作为业务用例的模板:
1. 未命中任何路由时返回 200
const response = await unstable_getResponseFromNextConfig({
url: '/test',
nextConfig: {},
})
expect(response.status).toEqual(200)
2. 带路径参数的重定向
const response = await unstable_getResponseFromNextConfig({
url: 'https://nextjs.org/test/foo',
nextConfig: {
async redirects() {
return [
{ source: '/test/:slug', destination: '/test2/:slug', permanent: false },
]
},
},
})
expect(response.status).toEqual(307)
expect(response.headers.get('location')).toEqual('https://nextjs.org/test2/foo')
3. 重定向 + query 参数回写与 308 永久重定向
const response = await unstable_getResponseFromNextConfig({
url: 'https://nextjs.org/test/foo',
nextConfig: {
async redirects() {
return [
{
source: '/test/:slug',
destination: '/test2?slug=:slug',
permanent: true,
},
]
},
},
})
expect(response.status).toEqual(308)
expect(response.headers.get('location')).toEqual('https://nextjs.org/test2?slug=foo')
状态码的换算来自 getRedirectStatus(redirect-status):permanent: true 对应 308,permanent: false 对应 307——这与本模块测试中 307/308 的断言一致。
4. has/missing 条件参与判定
匹配依赖请求级信息(header/host/cookie/query)。下面的用例中,has: [{ type: 'host', value: 'nextjs.org' }] 命中、同时 missing 排除 othersite.com,因此仍然返回 307;而若 host 是 othersite.com,路由会被跳过、回落到 200:
const response = await unstable_getResponseFromNextConfig({
url: 'https://nextjs.org/test/foo',
nextConfig: {
async redirects() {
return [
{
source: '/test/:slug',
destination: '/test2/:slug',
permanent: false,
has: [{ type: 'host', value: 'nextjs.org' }],
missing: [{ type: 'host', value: 'othersite.com' }],
},
]
},
},
})
expect(response.status).toEqual(307)
5. rewrites 命中后仍携带 headers 规则注入的响应头
这是"headers 与 rewrites 叠加"的验证:一个 URL 同时命中 headers 规则与 rewrite 规则时,返回的响应应同时包含注入头与重写目标(测试见 config-testing-utils.test.ts):
const response = await unstable_getResponseFromNextConfig({
url: 'https://nextjs.org/test/subpath',
nextConfig: {
async headers() {
return [
{
source: '/test/:path+',
headers: [{ key: 'X-Custom-Header', value: 'custom-value' }],
},
]
},
async rewrites() {
return [{ source: '/test/:path*', destination: 'https://example.com/:path*' }]
},
},
})
expect(isRewrite(response)).toEqual(true)
expect(getRewrittenUrl(response)).toEqual('https://example.com/subpath')
expect(response.headers.get('x-custom-header')).toEqual('custom-value')
6. beforeFiles 的 rewrite 优先级高于 afterFiles/fallback
当 rewrites 以三段式对象返回时,模块按 beforeFiles → afterFiles → fallback 的顺序合并匹配,beforeFiles 命中后不再继续(测试见 config-testing-utils.test.ts)。
7. 函数式/异步配置(插件包装场景)
把整个 nextConfig 作为 async (phase, ctx) => config 函数传入会被透明解析(测试见 config-testing-utils.test.ts),这一能力由 normalizeConfig 保证。
8. basePath 的语义一致性
当配置声明 basePath 时,Next.js 运行时会自动把 basePath 加到自定义路由的 source 上;本模块同样复现了这一点。配置中额外提供 basePath: false 的路由则不做前缀处理(测试见 config-testing-utils.test.ts)。这意味着你写在测试里的路由应与生产 next.config.js 保持一致,而无需手动拼接 basePath。
对响应对象做语义化断言:getRedirectUrl / isRewrite / getRewrittenUrl
拿到 unstable_getResponseFromNextConfig 返回的 NextResponse 后,还需要解读它的语义。实现位于 utils.ts:
getRedirectUrl(response): string | null—— 若响应是重定向,返回location响应头的值;否则返回null。内部实现就是读取response.headers.get('location'),这是 Next.js 重定向的载体。getRewrittenUrl(response): string | null—— 若响应是重写,返回目标 URL;否则返回null。实现读取x-middleware-rewrite响应头。isRewrite(response): boolean—— 是"重写到不同 URL"的响应则为true,等价于Boolean(getRewrittenUrl(response))。
配合上述函数,断言可以写得更具语义可读性:
const response = await unstable_getResponseFromNextConfig({ url, nextConfig })
// 重定向场景
expect(response.status).toEqual(307)
expect(getRedirectUrl(response)).toEqual('https://nextjs.org/test2')
// 重写场景
expect(isRewrite(response)).toEqual(true)
expect(getRewrittenUrl(response)).toEqual('https://example.com/subpath')
验证 Middleware 的匹配范围:unstable_doesMiddlewareMatch
next.config.js 之外,middleware 是另一个"在请求进入页面组件前"执行的服务端逻辑,其执行范围由 middleware.ts 顶部导出的 config.matcher 决定。第二组 API 用于单测 matcher 是否在该执行时执行、不该执行时不执行,而无需真正调用 middleware 函数。
函数位于 middleware-testing-utils.ts:
unstable_doesMiddlewareMatch({
config: MiddlewareSourceConfig,
url: string,
headers?: IncomingHttpHeaders,
cookies?: Record<string, string>,
nextConfig?: NextConfig,
}): boolean
config:即 middleware 的配置对象,当前仅关注其中的config.matcher,其类型MiddlewareSourceConfig定义为{ matcher?: MiddlewareConfigMatcherInput }(见 middleware-testing-utils.ts)。matcher支持字符串、字符串数组,以及带has/missing条件的对象数组。url:被测请求路径,可含 query。headers/cookies:为has/missing条件提供请求信息。nextConfig:可选。主要解决basePath场景——matcher 路径不包含 basePath,而实际请求 URL 包含它时,需要传入配置让匹配逻辑对齐(见下文测试)。- 返回值:命中返回
true,未命中返回false。
底层原理:复用生产同款匹配函数
值得强调的是,该函数没有自造一套匹配逻辑,而是复用了 Next.js 在构建期解析与运行时路由所用的同一批函数:
getMiddlewareMatchers(config.matcher, nextConfig)(位于 get-page-static-info)先把 matcher 配置编译成 matcher 元数据;getMiddlewareRouteMatcher(matchers)(位于 middleware-route-matcher)生成一个执行匹配的函数;- 最终用"pathname + 构造出的 mock 请求 + query"去调用这个函数得出布尔结果。
由此可以推断:测试中对 matcher 的判定结果,与真实生产环境(含 Turbopack/webpack 构建、edge 运行时)基本保持一致,这也是"测试 middleware 会不会跑"这一需求的可靠基础。
另外,config.matcher 缺省时函数直接返回 true——这对应 Next.js "不配置 matcher 即匹配全部路径"的行为(实现见 middleware-testing-utils.ts)。
实测用例
仓库配套的 middleware-testing-utils.test.ts 提供了完整的行为基准:
1. 无 matcher 匹配一切
expect(
unstable_doesMiddlewareMatch({ config: { matcher: undefined }, url: '/test' })
).toEqual(true)
2. 字符串 matcher 精确匹配路径(query 不影响匹配)
const config = { matcher: '/test' }
expect(unstable_doesMiddlewareMatch({ config, url: '/test' })).toEqual(true)
expect(unstable_doesMiddlewareMatch({ config, url: '/test?q=1' })).toEqual(true)
expect(unstable_doesMiddlewareMatch({ config, url: '/other-path' })).toEqual(false)
3. 数组 matcher 支持正则与 path-to-regexp 语法
const config = { matcher: ['/test', '/test/(.*)', '/test2/:path+'] }
expect(unstable_doesMiddlewareMatch({ config, url: '/test' })).toEqual(true)
expect(unstable_doesMiddlewareMatch({ config, url: '/test/slug' })).toEqual(true)
expect(unstable_doesMiddlewareMatch({ config, url: '/test2/slug' })).toEqual(true)
4. has 条件:header / cookie / query 必须在请求中存在且值匹配
const config = {
matcher: [
{
source: '/test',
has: [{ type: 'header', key: 'x-test-header', value: '1' }],
},
],
}
expect(unstable_doesMiddlewareMatch({ config, url: '/test' })).toEqual(false)
expect(
unstable_doesMiddlewareMatch({ config, url: '/test', headers: { 'x-test-header': '1' } })
).toEqual(true)
has 中的 cookie 通过 cookies: { 'x-test-cookie': '1' } 参数注入,query 则直接拼在 url 上(如 '/test?q=1')。值得注意的是,测试断言明确区分了三条信息通道:header 用 headers 传、cookie 用 cookies 传、query 在 url 上——三者不会被混淆。
5. missing 条件:请求中不存在该 header/cookie/query 才命中
const config = {
matcher: [
{
source: '/test',
missing: [{ type: 'header', key: 'x-test-header' }],
},
],
}
expect(unstable_doesMiddlewareMatch({ config, url: '/test' })).toEqual(true)
expect(
unstable_doesMiddlewareMatch({ config, url: '/test', headers: { 'x-test-header': '1' } })
).toEqual(false)
6. basePath 场景需要传 nextConfig
在 basePath: '/base' 的项目里,matcher 仍写作 '/test',但实际到达 middleware 的 URL 是 /base/test。只有传入 nextConfig 后匹配才能对齐运行时行为(测试见 middleware-testing-utils.test.ts):
const nextConfig = { basePath: '/base' }
const config = { matcher: ['/test'] }
expect(unstable_doesMiddlewareMatch({ config, url: '/test', nextConfig })).toEqual(false)
expect(unstable_doesMiddlewareMatch({ config, url: '/base/test', nextConfig })).toEqual(true)
在项目中如何落地这些测试
综合仓库源码与测试的组织方式,可以在自己的项目里按以下方式引入:
第一步:确认 Next.js 版本可用该入口
该模块位于 next/experimental/testing/server 导出路径之下,是实验性 API。以本仓库 packages/next/package.json 为例,包体已内置 experimental/testing/server.js 与类型声明。如果依赖的是正式发布版本,请先确认该版本中上述构建产物与导出文件是否存在,再在 CI 或本地测试中使用。
第二步:编写测试
将"被测配置"从 next.config.ts 抽出或直接内联一份等价的 nextConfig,覆盖你认为关键的路由规则。由于返回的是纯 NextResponse,无需任何 mock 服务器:
import {
unstable_getResponseFromNextConfig,
unstable_doesMiddlewareMatch,
getRedirectUrl,
isRewrite,
} from 'next/experimental/testing/server'
describe('next.config 自定义路由', () => {
const nextConfig = {
async redirects() {
return [{ source: '/docs/:slug', destination: '/new-docs/:slug', permanent: true }]
},
async headers() {
return [{ source: '/api/:path*', headers: [{ key: 'X-Api', value: '1' }] }]
},
}
it('命中重定向规则时返回 308 与新地址', async () => {
const res = await unstable_getResponseFromNextConfig({
url: 'https://example.com/docs/intro',
nextConfig,
})
expect(res.status).toEqual(308)
expect(getRedirectUrl(res)).toEqual('https://example.com/new-docs/intro')
})
it('middleware matcher 对受保护路径生效', () => {
expect(
unstable_doesMiddlewareMatch({
config: { matcher: ['/dashboard/:path*'] },
url: '/dashboard/settings',
})
).toEqual(true)
})
})
第三步:纳入常规测试命令
这些 API 是纯同步匹配加异步配置解析,适合与现有单测框架(Jest 等,仓库自身即以 describe/it/expect 用例运行于根目录 jest.config.js 体系中)一起执行,作为每次提交前校验路由规则正确性的快速回归手段。
边界与限制
从源码实现可以归纳出该工具的使用边界,写测试时需要注意:
- 不启动真实服务器、不执行 middleware 函数体。
unstable_getResponseFromNextConfig只验证"请求经过自定义路由后应当产生什么响应";unstable_doesMiddlewareMatch只回答"middleware 会不会被触发"。middleware 函数内部的业务逻辑(改写响应头、放行逻辑等)仍需另做测试,不属于本模块职责。 - 遵循运行时路由优先级,但不会走到 Pages/App 渲染层。所有路由都未命中时仅返回 200 空响应,不能据此断言页面组件的最终行为。
unstable_前缀意味着接口不稳定。API 名称、签名、参数语义都可能随版本变化,升级 Next.js 后应同步留意测试是否仍然通过。nextConfig的语义对齐生产构建。内部以PHASE_PRODUCTION_BUILD解析配置,因此那些依赖运行阶段(如 dev)才生效的配置差异不会在此体现。- headers 传参的底层映射。传入的
cookies会被拼成Cookie请求头、host缺省时取自url,理解这一构造方式有助于排查has/missing条件不命中的问题(见 utils.ts)。
把路由决策的验证前置到单元测试层,是减少线上回归事故的务实手段。next/experimental/testing/server 的价值正在于用几行断言把"配置是否按预期工作"从端到端测试里解放出来;在此基础上,再配合常规的端到端测试验证 middleware 函数体与页面渲染的完整链路,即可在测试金字塔的各层之间形成互补。
参考实现与测试文件:
- 模块导出:index.ts
- 自定义路由测试主体:config-testing-utils.ts、config-testing-utils.test.ts
- Middleware 匹配测试:middleware-testing-utils.ts、middleware-testing-utils.test.ts
- 响应断言与请求构造:utils.ts
- 底层复用的运行时函数:config-shared.ts、middleware-route-matcher、prepare-destination、redirect-status
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00