
Confidently test your Next.js API routes in an isolated Next-like environment
[![Black Lives Matter!][x-badge-blm-image]][x-badge-blm-link] [![Last commit timestamp][x-badge-lastcommit-image]][x-badge-repo-link] [![Codecov][x-badge-codecov-image]][x-badge-codecov-link] [![Source license][x-badge-license-image]][x-badge-license-link] [![Uses Semantic Release!][x-badge-semanticrelease-image]][x-badge-semanticrelease-link]
[![NPM version][x-badge-npm-image]][x-badge-npm-link] [![Monthly Downloads][x-badge-downloads-image]][x-badge-npm-link]
Trying to unit test your Next.js API routes? Tired of hacking something
together with express or node-mocks-http or writing a bunch of boring dummy
infra just to get some passing tests? And what does a "passing test" mean anyway
when your handlers aren't receiving actual [NextRequest][1] objects and
aren't being run by Next.js itself?
Next.js patches the global
fetchfunction, for instance. If your tests aren't doing the same, you're making space for bugs!
Is it vexing that everything explodes when your [App Router][2] handlers call
headers() or cookies() or any of the other route-specific [helper
functions][3]? Or maybe you want your [Pages Router][4] handlers to receive
actual [NextApiRequest][5] and [NextApiResponse][5] objects?
Sound interesting? Then want no longer! 🤩
[next-test-api-route-handler][x-badge-repo-link] (NTARH) uses Next.js's
internal resolvers to precisely emulate route handling. To guarantee stability,
this package is [automatically tested][6] against [each release of Next.js][7]
and Node.js. Go forth and test confidently!
Note that App Router support begins with next@14.0.4 ([why?][8])
appHandlerpagesHandlertestrejectOnHandlerErrorrequestPatcher (url)responsePatcherparamsPatcher (params)npm install --save-dev next-test-api-route-handler
See [the appendix][9] for legacy Next.js support options.
Also see [the appendix][10] if you're using
jestandjest-environment-jsdom.
[!IMPORTANT]
NTARH must always be the first import in your test file. This is due to the way Next.js is written and distributed. See [the appendix][11] for technical details.
// ESM
import { testApiHandler } from 'next-test-api-route-handler'; // ◄ Must be first
... all other imports ...
// CJS
const { testApiHandler } = require('next-test-api-route-handler'); // ◄ Must be first
... all other imports ...
/* File: test/unit.test.ts */
import { testApiHandler } from 'next-test-api-route-handler'; // ◄ Must be first import
// Import the handler under test from the app directory
import * as appHandler from '../app/your-endpoint/route';
it('does what I want', async () => {
await testApiHandler({
appHandler,
// requestPatcher is optional
requestPatcher(request) {
request.headers.set('key', process.env.SPECIAL_TOKEN);
},
// responsePatcher is optional
async responsePatcher(response) {
const json = await response.json();
return Response.json(
json.apiSuccess ? { hello: 'world!' } : { goodbye: 'cruel world' }
);
},
async test({ fetch }) {
const res = await fetch({ method: 'POST', body: 'dummy-data' });
await expect(res.json()).resolves.toStrictEqual({ hello: 'world!' }); // ◄ Passes!
}
});
});
/* File: test/unit.test.ts */
import { testApiHandler } from 'next-test-api-route-handler'; // ◄ Must be first import
// Import the handler under test from the app directory
import * as edgeHandler from '../app/your-edge-endpoint/route';
it('does what I want', async function () {
// NTARH supports optionally typed response data via TypeScript generics:
await testApiHandler<{ success: boolean }>({
// Only appHandler supports edge functions. The pagesHandler prop does not!
appHandler: edgeHandler,
// requestPatcher is optional
requestPatcher(request) {
return new Request(request, {
body: dummyReadableStream,
duplex: 'half'
});
},
async test({ fetch }) {
// The next line would cause TypeScript to complain:
// const { luck: success } = await (await fetch()).json();
await expect((await fetch()).json()).resolves.toStrictEqual({
success: true // ◄ Passes!
});
}
});
});
/* File: test/unit.test.ts */
import { testApiHandler } from 'next-test-api-route-handler'; // ◄ Must be first import
// Import the handler under test and its config from the pages/api directory
import * as pagesHandler from '../pages/api/your-endpoint';
it('does what I want', async () => {
// NTARH supports optionally typed response data via TypeScript generics:
await testApiHandler<{ hello: string }>({
pagesHandler,
requestPatcher: (req) => {
req.headers = { key: process.env.SPECIAL_TOKEN };
},
test: async ({ fetch }) => {
const res = await fetch({ method: 'POST', body: 'data' });
// The next line would cause TypeScript to complain:
// const { goodbye: hello } = await res.json();
const { hello } = await res.json();
expect(hello).toBe('world'); // ◄ Passes!
}
});
});
NTARH exports a single function, testApiHandler(options), that accepts an
options object as its only parameter.
At minimum, options must contain the following properties:
appHandler or pagesHandler options, but not both.test option.For example:
[!CAUTION]
Ensuring
testApiHandleris imported [before][12] any Next.js package (like'next/headers'below) is crucial to the proper function of NTARH. Doing otherwise will result in undefined behavior.
import { testApiHandler } from 'next-test-api-route-handler';
import { headers } from 'next/headers';
await testApiHandler({
appHandler: {
dynamic: 'force-dynamic',
async GET(_request) {
return Response.json(
{
// Yep, those fancy helper functions work too!
hello: (await headers()).get('x-hello')
},
{ status: 200 }
);
}
},
async test({ fetch }) {
await expect(
(await fetch({ headers: { 'x-hello': 'world' } })).json()
).resolves.toStrictEqual({
hello: 'world'
});
}
});
appHandler⪢ API reference: [
appHandler][13]
The actual route handler under test (usually imported from app/*). It should
be an object and/or exported module containing one or more [valid uppercase HTTP
method names][14] as keys, each with an [async handling function][15] that
accepts a [NextRequest][1] and ["segment data"][16] (i.e. { params }) as its
two parameters. The object or module can also export [other configuration
settings recognized by Next.js][17].
await testApiHandler({
params: { id: 5 },
appHandler: {
async POST(request, { params: { id } }) {
return Response.json(
{ special: request.headers.get('x-special-header'), id },
{ status: 200 }
);
}
},
async test({ fetch }) {
expect((await fetch({ method: 'POST' })).status).toBe(200);
const result2 = await fetch({
method: 'POST',
headers: { 'x-special-header': 'x' }
});
expect(result2.json()).toStrictEqual({ special: 'x', id: 5 });
}
});
See also: [rejectOnHandlerError][18] and the section [Working Around Next.js
fetch Patching][19].
pagesHandler⪢ API reference: [
pagesHandler][20]
The actual route handler under test (usually imported from pages/api/*). It
should be an async function that accepts [NextApiRequest][5] and
[NextApiResponse][5] objects as its two parameters.
await testApiHandler({
params: { id: 5 },
pagesHandler: (req, res) => res.status(200).send({ id: req.query.id }),
test: async ({ fetch }) =>
expect((await fetch({ method: 'POST' })).json()).resolves.toStrictEqual({
id: 5
})
});
See also: [rejectOnHandlerError][18].
test⪢ API reference: [
test][21]
An async or promise-returning function wherein test assertions can be run. This
function receives one destructured parameter: fetch, which is a wrapper around
Node's [global fetch][22] function. Use this to send HTTP requests to the
handler under test.
[!CAUTION]
Note that
fetch'sresourceparameter, i.e. [the first parameter infetch(...)][23], is omitted.
Starting with version 4.0.4, NTARH sets the [fetch(...) options][24]
parameter's [redirect property to 'manual'][25] by default. This prevents
the WHATWG/undici fetch function from throwing a
fetch failed/redirect count exceeded error.
If you want to change this value, call fetch with your own custom options
parameter, e.g. fetch({ redirect: 'error' }).
Starting with version 4.0.0, NTARH ships with [Mock Service Worker (msw)][26]
support by adding the [x-msw-intention: "bypass"][27] and
x-msw-bypass: "true" headers to all requests.
If necessary, you can override this behavior by setting the appropriate headers
to some other value (e.g. "none") via fetch's customInit parameter (not
requestPatcher). This comes in handy when testing functionality like
[arbitrary response redirection][28] (or via the [Pages Router][29]).
For example:
import { testApiHandler } from 'next-test-api-route-handler';
import { http, passthrough, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(/* ... */);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers();
});
afterAll(() => server.close());
it('redirects a shortened URL to the real URL', async () => {
expect.hasAssertions();
// e.g. https://xunn.at/gg => https://www.google.com/search?q=next-test-api-route-handler
// shortId would be "gg"
// realLink would be https://www.google.com/search?q=next-test-api-route-handler
const { shortId, realLink } = getUriEntry();
const realUrl = new URL(realLink);
await testApiHandler({
appHandler,
params: { shortId },
test: async ({ fetch }) => {
server.use(
http.get('*', async ({ request }) => {
return request.url === realUrl.href
? HttpResponse.json({ it: 'worked' }, { status: 200 })
: passthrough();
})
);
const res = await fetch({
headers: { 'x-msw-intention': 'none' } // <==
});
await expect(res.json()).resolves.toMatchObject({ it: 'worked' });
expect(res.status).toBe(200);
}
});
});
response.cookiesAs of version 2.3.0, the response object returned by fetch() includes a
non-standard cookies field containing an array of objects representing
[set-cookie response header(s)][30] parsed by [the cookie package][31]. Use
the cookies field to easily access a response's cookie data in your tests.
Here's an example taken straight from the [unit tests][32]:
```typescript import { testApiHandler } from 'next-test-api-route-handler';
it('handles multiple set-cookie headers', async () => { expect.hasAssertions();
await testApiHandler({ pagesHandler: (_, res) => { // Multiple calls to setHeader('Set-Cookie', ...) overwrite previous, so // we have to set the Set-Cookie header properly res .setHeader('Set-Cookie', [ serializeCookieHeader('access_token', '1234', { expires: new Date() }), serializeCookieHeader('REFRESH_TOKEN', '5678') ]) .status(200) .send({}); }, test: async ({ fetch }) => { expect((await fetch()).status).toBe(200); await expect((await fetch()).json()).resolves.toStrictEqual({}); expect((await fetch()).cookies).toStrictEqual([ { access_token: '1234', // Lowercased cookie property keys are available expires: expect.any(String), // Raw cookie property keys are also available Expires: expect.any(String)
$ claude mcp add next-test-api-route-handler \
-- python -m otcore.mcp_server <graph>