Skip to content

Commit

Permalink
[Testing] Export utilities to help test middleware and next.config.js…
Browse files Browse the repository at this point in the history
… routing (#70731)

This PR adds some testing utilities for testing `middleware` and
`next.config.js`. The goal is to make it easy for anyone to add tests
that assert the behavior of routing, such as how
`next.config.js#redirects`, `next.config.js#rewrites`, and `middleware`
interact, or what paths are matched by middleware. This is useful to
catch routing issues before the code ever reaches production.

This code is exported in a new `next/server/testing` export to keep it
separate from the rest of the `next/server` code.

This testing code is marked as `unstable_` since it is not reusing all
of the same code that the actual routing in `next dev` and `next start`
is using. It is an approximation to make unit testing easier.

# Middleware

A new `unstable_doesMiddlewareMatch` function was added to assert when
middleware will be run for a path.

```js
import { unstable_doesMiddlewareMatch } from 'next/server/testing'

expect(
  unstable_doesMiddlewareMatch({
    config,
    nextConfig,
    url: '/test',
  })
).toEqual(false)
```

Helpers were also created to test for whether the response was a
redirect or rewrite.

# Next.config.js

A new `unstable_getResponseFromNextConfig` function was added to run
`headers`, `redirects`, and `rewrites` functions from `next.config.js`,
calling them in the order that they would actually be executed in.

```js
import { getRedirectUrl, unstable_getResponseFromNextConfig } from 'next/server/testing'

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')
```

---------

Co-authored-by: Zack Tanner <[email protected]>
Co-authored-by: JJ Kasper <[email protected]>
  • Loading branch information
3 people authored Nov 1, 2024
1 parent 39c4cf9 commit a44a0d9
Show file tree
Hide file tree
Showing 13 changed files with 849 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,36 @@ export default async function middleware(req) {
}
```
## Unit Testing (experimental)
Starting in Next.js 15.1, the `next/experimental/testing/server` package contains utilities to help unit test middleware files. Unit testing middleware can help ensure that it's only run on desired paths and that custom routing logic works as intended before code reaches production.
The `unstable_doesMiddlewareMatch` function can be used to assert whether middleware will run for the provided URL, headers, and cookies.
```js
import { unstable_doesMiddlewareMatch } from 'next/experimental/testing/server'

expect(
unstable_doesMiddlewareMatch({
config,
nextConfig,
url: '/test',
})
).toEqual(false)
```
The entire middleware function can also be tested.
```js
import { isRewrite, getRewrittenUrl } from 'next/experimental/testing/server'

const request = new NextRequest('https://nextjs.org/docs')
const response = await middleware(request)
expect(isRewrite(response)).toEqual(true)
expect(getRewrittenUrl(response)).toEqual('https://other-domain.com/docs')
// getRedirectUrl could also be used if the response were a redirect
```
## Runtime
Middleware currently only supports APIs compatible with the [Edge runtime](/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes). APIs exclusive to Node.js are [unsupported](/docs/app/api-reference/edge#unsupported-apis).
Expand Down
26 changes: 26 additions & 0 deletions docs/02-app/02-api-reference/05-next-config-js/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,29 @@ However, none of the configs are required, and it's not necessary to understand
> Avoid using new JavaScript features not available in your target Node.js version. `next.config.js` will not be parsed by Webpack or Babel.
This page documents all the available configuration options:

## Unit Testing (experimental)

Starting in Next.js 15.1, the `next/experimental/testing/server` package contains utilities to help unit test `next.config.js` files.

The `unstable_getResponseFromNextConfig` function runs the [`headers`](/docs/app/api-reference/next-config-js/headers), [`redirects`](/docs/app/api-reference/next-config-js/redirects), and [`rewrites`](/docs/app/api-reference/next-config-js/rewrites) functions from `next.config.js` with the provided request information and returns `NextResponse` with the results of the routing.

> The response from `unstable_getResponseFromNextConfig` only considers `next.config.js` fields and does not consider middleware or filesystem routes, so the result in production may be different than the unit test.
```js
import {
getRedirectUrl,
unstable_getResponseFromNextConfig,
} 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')
```
1 change: 1 addition & 0 deletions packages/next/experimental/testing/server.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../dist/experimental/testing/server'
1 change: 1 addition & 0 deletions packages/next/experimental/testing/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../dist/experimental/testing/server')
2 changes: 2 additions & 0 deletions packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
"navigation-types",
"web-vitals.js",
"web-vitals.d.ts",
"experimental/testing/server.js",
"experimental/testing/server.d.ts",
"experimental/testmode/playwright.js",
"experimental/testmode/playwright.d.ts",
"experimental/testmode/playwright/msw.js",
Expand Down
9 changes: 9 additions & 0 deletions packages/next/src/experimental/testing/server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# next/experimental/testing/server

This directory contains helpers for unit testing Next.js server code, such as
routing using `next.config.js` or `middleware`. These utilities can be used to
verify the behavior of redirects, rewrites, adding headers, or middleware logic
before code reaches to production.

See https://nextjs.org/docs/app/building-your-application/routing/middleware
and https://nextjs.org/docs/app/api-reference/next-config-js for more details.
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { unstable_getResponseFromNextConfig } from './config-testing-utils'
import { getRewrittenUrl, isRewrite } from './utils'

describe('config-testing-utils', () => {
it('returns 200 for paths that do not match', async () => {
const response = await unstable_getResponseFromNextConfig({
url: '/test',
nextConfig: {},
})
expect(response.status).toEqual(200)
})

describe('redirects', () => {
it('handles redirect', async () => {
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(response.headers.get('location')).toEqual(
'https://nextjs.org/test2'
)
})

it('handles redirect with params', async () => {
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'
)
})

it('redirects take precedence over rewrites', async () => {
const response = await unstable_getResponseFromNextConfig({
url: 'https://nextjs.org/test/foo',
nextConfig: {
async redirects() {
return [
{
source: '/test/:slug',
destination: '/test2/:slug',
permanent: false,
},
]
},
async rewrites() {
return [
{
source: '/test/:path*',
destination: 'https://example.com/:path*',
},
]
},
},
})
expect(response.status).toEqual(307)
expect(response.headers.get('location')).toEqual(
'https://nextjs.org/test2/foo'
)
})
})

describe('rewrites', () => {
it('handles rewrite', async () => {
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')
})

it('beforeFiles rewrites take precedence over afterFiles and fallback', async () => {
const response = await unstable_getResponseFromNextConfig({
url: 'https://nextjs.org/test/subpath',
nextConfig: {
async rewrites() {
return {
beforeFiles: [
{
source: '/test/:path*',
destination: 'https://example.com/:path*',
},
],
afterFiles: [
{
source: '/test/:path*',
destination: 'https://wrong-example.com/:path*',
},
],
fallback: [
{
source: '/test/:path*',
destination: 'https://wrong-example.com/:path*',
},
],
}
},
},
})
expect(isRewrite(response)).toEqual(true)
expect(getRewrittenUrl(response)).toEqual('https://example.com/subpath')
})
})

describe('headers', () => {
it('simple match', async () => {
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',
},
],
},
]
},
},
})
expect(response.headers.get('x-custom-header')).toEqual('custom-value')
})
})

it('basePath', async () => {
const response = await unstable_getResponseFromNextConfig({
url: 'https://nextjs.org/test',
nextConfig: {
basePath: '/base-path',
async headers() {
return [
{
// When basePath is defined, basePath is automatically added to these expressions.
source: '/:path+',
headers: [
{
key: 'X-Using-Base-Path',
value: '1',
},
],
},
{
source: '/:path+',
headers: [
{
key: 'X-Custom-Header',
value: '1',
},
],
basePath: false,
},
]
},
},
})
expect(response.headers.get('x-custom-header')).toEqual('1')
expect(response.headers.get('x-using-base-path')).toBeNull()
})
})
Loading

0 comments on commit a44a0d9

Please sign in to comment.