首页
/ Next.js 服务端单测实践:用 next/experimental/testing/server 验证 next.config 路由与 Middleware

Next.js 服务端单测实践:用 next/experimental/testing/server 验证 next.config 路由与 Middleware

2026-09-07 16:18:23作者:虞亚竹Luna

本篇技术指南聚焦 Next.js 仓库中位于 packages/next/src/experimental/testing/server 的实验性测试工具包。它提供了用于单元测试 Next.js 服务端逻辑的辅助函数——例如基于 next.config.jsheaders/redirects/rewrites 自定义路由,以及 middleware 的 matcher 匹配行为——让你无需启动真实的开发或生产服务器,就能在代码上线前以纯内存方式验证重定向、重写、响应头注入与中间件是否按预期工作。读完本文,你将掌握 unstable_getResponseFromNextConfigunstable_doesMiddlewareMatch 等 API 的参数语义、底层匹配原理与断言辅助函数,并能在自己的 Jest/单元测试体系中直接落地。

模块定位:为什么需要"测试服务端路由配置"

模块目录下的官方说明(README.md)给出了它的定位:这个目录包含用于单元测试 Next.js 服务端代码的辅助工具,例如基于 next.config.jsmiddleware 的路由逻辑。这些工具可以在代码到达生产环境之前,验证 redirects(重定向)、rewrites(重写)、添加 headers(响应头)或 middleware 逻辑的行为是否正确。

其价值在于把原本属于"运行时/端到端"范畴的验证提前到"纯单测"阶段。在真实项目中,验证一条 /test -> /test2 的重定向通常需要起服务器、发请求、断言响应;而该模块通过把自定义路由的"匹配 + 目的地址解析"逻辑在内存中执行一遍,直接返回一个标准的 NextResponse 对象供断言,让这类场景可以跑进毫秒级的单元测试。

从仓库结构看,该目录是一个独立自洽的小模块,包含四个源码文件与两份配套测试(config-testing-utils.test.tsmiddleware-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.tsconfig.matcher 匹配范围 unstable_doesMiddlewareMatchMiddlewareSourceConfig
utils.ts 对测试返回的 NextResponse 做断言 getRedirectUrlgetRewrittenUrlisRewriteconstructRequest

在包的对外入口方面,packages/next/package.jsonfiles 字段中登记了 experimental/testing/server.jsexperimental/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.jsheadersredirectsrewrites 三类自定义路由的逻辑。它的注释给出了最典型的使用示例——测试某个 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 条件中的 headerhost 匹配,以及 cookie 注入。
cookies Record<string, string> 可选。以键值对形式传入,会被拼装成 Cookie 请求头,用于 has: { type: 'cookie' } 条件匹配。

调用成功后返回一个标准的 NextResponse 对象,可通过 .status.headers 进行断言。

底层执行链路:从配置到 NextResponse

深入阅读 config-testing-utils.ts,可以还原它在内存中"模拟运行时路由"的完整流程:

  1. 解析 URL 并构造请求parse(url, true) 解析路径与 query,随后交给同目录 utils.tsconstructRequest,把请求包装成 NodeNextRequest(MockedRequest(...))(GET 方法)。若未显式传 host,会从 URL 中提取(utils.ts)。

  2. 规范化配置:调用 normalizeConfig,以 PHASE_PRODUCTION_BUILD 作为 phase。这段实现同时解释了"配置可以是异步函数"的原因——normalizeConfig 内部会执行 config(phase, { defaultConfig })await 返回值,从而兼容 async (phase, ctx) => config 风格的插件包装。

  3. 加载自定义路由:调用 loadCustomRoutes(其源码位于 load-custom-routes)拿到标准化的 headers / redirects / rewrites 三类路由,再经 buildCustomRoutebuild-custom-route)编译为带正则、可参与 has/missing 判定的内部路由结构。注意 redirects 会传入 ['/_next/'] 排除列表,即模拟了生产构建中不对 /_next/ 内部资源重定向的行为。

  4. 顺序匹配三类路由:先遍历 headers 命中并收集响应头;再遍历 redirects,命中的话返回 NextResponse.redirect(…) 并携带已收集的 headers;随后才轮到 rewrites(beforeFilesafterFilesfallback 三段的合并结果),命中即返回 NextResponse.rewrite(…)。这忠实复刻了运行时"redirects 优先于 rewrites"的语义。全部未命中时返回一个 status: 200、携带已匹配 headers 的空响应。

  5. 目的地址插值:redirect/rewrite 命中后,会调用 prepareDestination(来自 prepare-destination)把 source 提取出的 :slug:path* 等参数插值进 destination,再与 query 合并还原成完整 URL。

内部还依赖 matchRouteconfig-testing-utils.ts)完成单条路由的匹配:先用编译出的正则匹配 pathname,再用 path-to-regexpmatch 提取参数;若路由带 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')

状态码的换算来自 getRedirectStatusredirect-status):permanent: true 对应 308permanent: 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 在构建期解析与运行时路由所用的同一批函数:

  1. getMiddlewareMatchers(config.matcher, nextConfig)(位于 get-page-static-info)先把 matcher 配置编译成 matcher 元数据;
  2. getMiddlewareRouteMatcher(matchers)(位于 middleware-route-matcher)生成一个执行匹配的函数;
  3. 最终用"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 函数体与页面渲染的完整链路,即可在测试金字塔的各层之间形成互补。

参考实现与测试文件:

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388