Trying to unit test your Next.js API route handlers? Want to avoid mucking
around with custom servers and writing boring test infra just to get some unit
tests working? Want your handlers to receive actual NextApiRequest
and
NextApiResponse
objects rather than having to hack something together
with express or node-mocks-http? Then look no further! π€©
next-test-api-route-handler
(NTARH) uses Next.js's internal API
resolver to precisely emulate API route handling. To guarantee stability, this
package is automatically tested against each release of Next.js and
Node.js. Go forth and test confidently!
npm install --save-dev next-test-api-route-handler
If you are using npm@<7
or node@<15
, you must install Next.js and its peer
dependencies manually. This is because npm@<7
does not install peer
dependencies by default. If you're using a modern version of NPM, you can
skip this step.
npm install --save-dev next@latest react
If you're also using an older version of Next.js, ensure you install the peer dependencies (like
react
) that your specific Next.js version requires!
As of version 2.1.0
, NTARH is fully backwards compatible with Next.js going
allll the way back to [email protected]
when API routes were first
introduced!
If you're working with next@<9.0.6
(so: before next-server
was merged into
next
), you might need to install next-server
manually:
npm install --save-dev next-server
// ESM
import { testApiHandler } from 'next-test-api-route-handler';
// CJS
const { testApiHandler } = require('next-test-api-route-handler');
Quick start:
/* File: test/unit.test.ts */
import { testApiHandler } from 'next-test-api-route-handler';
// Import the handler under test from the pages/api directory
import endpoint, { config } from '../pages/api/your-endpoint';
import type { PageConfig } from 'next';
// Respect the Next.js config object if it's exported
const handler: typeof endpoint & { config?: PageConfig } = endpoint;
handler.config = config;
it('does what I want', async () => {
await testApiHandler({
handler,
requestPatcher: (req) => (req.headers = { key: process.env.SPECIAL_TOKEN }),
test: async ({ fetch }) => {
const res = await fetch({ method: 'POST', body: 'data' });
await expect(res.json()).resolves.toStrictEqual({ hello: 'world' }); // β Passes!
}
});
// NTARH also supports typed response data via TypeScript generics:
await testApiHandler<{ hello: string }>({
// The next line would cause TypeScript to complain:
// handler: (_, res) => res.status(200).send({ hello: false }),
handler: (_, res) => res.status(200).send({ hello: 'world' }),
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!
}
});
});
The interface for testApiHandler
without generics looks like this:
async function testApiHandler(args: {
rejectOnHandlerError?: boolean;
requestPatcher?: (req: IncomingMessage) => void;
responsePatcher?: (res: ServerResponse) => void;
paramsPatcher?: (params: Record<string, unknown>) => void;
params?: Record<string, unknown>;
url?: string;
handler: (req: NextApiRequest, res: NextApiResponse) => Promise<void>;
test: (args: { fetch: (customInit?: RequestInit) => FetchReturnType }) => Promise<void>;
});
A function that receives an IncomingMessage
. Use this function to modify
the request before it's injected into Next.js's resolver. To just set the
request url, e.g. requestPatcher: (req) => (req.url = '/my-url?some=query')
,
use the url
shorthand, e.g. url: '/my-url?some=query'
.
More often than not, manually setting the request url is unnecessary. Only set the url if your handler expects it or you want to use automatic query string parsing instead of
params
/paramsPatcher
.
A function that receives a ServerResponse
. Use this function to modify
the response before it's injected into Next.js's resolver.
A function that receives an object representing "processed" dynamic routes, e.g.
testing a handler that expects /api/user/:id
requires
paramsPatcher: (params) => (params.id = 'test-id')
. Route parameters can also
be passed using the params
shorthand, e.g. params: { id: 'test-id', ... }
.
Due to its simplicity, favor the params
shorthand over paramsPatcher
. If
both paramsPatcher
and the params
shorthand are used, paramsPatcher
will
receive an object like { ...queryStringURLParams, ...params }
.
Route parameters should not be confused with query string parameters, which are automatically parsed out from the url and added to the params object before
paramsPatcher
is evaluated.
The actual route handler under test (usually imported from pages/api/*
). It
should be an async function that accepts NextApiRequest
and
NextApiResponse
objects as its two parameters.
As of version
2.3.0
, unhandled errors in thehandler
function are kicked up to Next.js to handle. This meanstestApiHandler
will NOT reject or throw if an unhandled error occurs inhandler
, which includes failing Jestexpect()
assertions. Instead, the response returned byfetch()
in yourtest
function will have aHTTP 500
status thanks to how Next.js deals with unhandled errors in production. Prior to2.3.0
, NTARH's behavior on unhandled errors inhandler
and elsewhere was inconsistent. Version3.0.0
further improves error handling, ensuring no errors slip by uncaught.
To guard against false negatives, you can do either of the following:
- Make sure the status of the
fetch()
response is what you're expecting:
const res = await fetch();
...
// For this test, a 403 status is what we wanted
expect(res.status).toBe(403);
...
const res2 = await fetch();
...
// Later, we expect an "unhandled" error
expect(res2.status).toBe(500);
- If you're using version
>=3.0.0
, you can userejectOnHandlerError
to tell NTARH to intercept unhandled handler errors and reject the promise returned bytestApiHandler
instead of relying on Next.js to respond withHTTP 500
. This is especially useful if you haveexpect()
assertions inside your handler function:
await expect(
testApiHandler({
rejectOnHandlerError: true, // <==
handler: (res) => {
res.status(200);
throw new Error('bad bad not good');
},
test: async ({ fetch }) => {
const res = await fetch();
// By default, res.status would be 500...
//expect(res.status).toBe(500);
}
})
// ...but since we used rejectOnHandlerError, the whole promise rejects
// instead
).rejects.toThrow('bad not good');
await testApiHandler({
rejectOnHandlerError: true, // <==
handler: async (res) => {
// Suppose this expectation fails
await expect(backend.getSomeStuff()).resolves.toStrictEqual(someStuff);
},
test: async ({ fetch }) => {
await fetch();
// By default, res.status would be 500 due to the failing expect(). If we
// don't also expect() a non-500 response status here, the failing
// expectation in the handler will be swallowed and the test will pass
// (a false negative).
}
});
// ...but since we used rejectOnHandlerError, the whole promise rejects
// and Jest reports that the test failed, which is probably what you wanted.
A function that returns a promise (or async) where test assertions can be run.
This function receives one destructured parameter: fetch
, which is a simple
node-fetch instance. Use this to send HTTP requests to the handler under
test.
Note that fetch
's url parameter, i.e. the first parameter in
fetch(...)
, is omitted.
As of version 3.1.0
, NTARH adds the x-msw-bypass: true
header to all
requests by default. If necessary, you can override this behavior by setting the
header to "false"
via fetch
's customInit
parameter (not requestPatcher
).
This comes in handy when testing functionality like arbitrary response
redirection.
For example:
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({
handler,
params: { shortId },
test: async ({ fetch }) => {
server.use(
rest.get('*', (req, res, ctx) => {
return req.url.href == realUrl.href
? res(ctx.status(200), ctx.json({ it: 'worked' }))
: req.passthrough();
})
);
const res = await fetch({ headers: { 'x-msw-bypass': 'false' } }); // <==
await expect(res.json()).resolves.toMatchObject({ it: 'worked' });
expect(res.status).toBe(200);
}
});
});
As 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) parsed by the cookie
package. 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:
import { testApiHandler } from 'next-test-api-route-handler';
it('handles multiple set-cookie headers', async () => {
expect.hasAssertions();
await testApiHandler({
handler: (_, res) => {
// NOTE: multiple calls to setHeader('Set-Cookie', ...) overwrite previous
res.setHeader('Set-Cookie', [
serializeCookieHeader('access_token', '1234', { expires: new Date() }),
serializeCookieHeader('REFRESH_TOKEN', '5678')
]);
res.status(200).send({});
// NOTE: if using node@>=14, you can use a more fluent interface, i.e.:
// res.setHeader(...).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)
},
{ refresh_token: '5678', REFRESH_TOKEN: '5678' }
]);
}
});
});
You can easily run this example yourself by copying and pasting the following commands into your terminal.
The following should be run in a nix-like environment. On Windows, that's WSL. Requires
curl
,node
, andgit
.
git clone --depth=1 https://github.com/vercel/next.js /tmp/ntarh-test
cd /tmp/ntarh-test/examples/api-routes-apollo-server-and-client
npm install --force
npm install next-test-api-route-handler jest babel-jest @babel/core @babel/preset-env graphql-tools --force
# You could test with an older version of Next.js if you want, e.g.:
# npm install [email protected] --force
# Or even older:
# npm install [email protected] next-server --force
echo 'module.exports={"presets":["next/babel"]};' > babel.config.js
mkdir test
curl -o test/my.test.js https://raw.githubusercontent.com/Xunnamius/next-test-api-route-handler/main/apollo_test_raw
npx jest
The above script will clone the Next.js repository, install NTARH and configure dependencies, download the following script, and run it with jest.
Note that passing the route configuration object (imported below as
config
) through to NTARH and settingrequest.url
to the proper value is crucial when testing Apollo endpoints!
/* File: examples/api-routes-apollo-server-and-client/tests/my.test.js */
import { testApiHandler } from 'next-test-api-route-handler';
// Import the handler under test from the pages/api directory
import handler, { config } from '../pages/api/graphql';
// Respect the Next.js config object if it's exported
handler.config = config;
describe('my-test', () => {
it('does what I want 1', async () => {
expect.hasAssertions();
await testApiHandler({
handler,
url: '/api/graphql', // Set the request url to the path graphql expects
test: async ({ fetch }) => {
const query = `query ViewerQuery {
viewer {
id
name
status
}
}`;
const res = await fetch({
method: 'POST',
headers: {
'content-type': 'application/json' // Must use correct content type
},
body: JSON.stringify({
query
})
});
await expect(res.json()).resolves.toStrictEqual({
data: { viewer: { id: '1', name: 'John Smith', status: 'cached' } }
});
}
});
});
it('does what I want 2', async () => {
// Exactly the same as the above...
});
it('does what I want 3', async () => {
// Exactly the same as the above...
});
});
Suppose we have an API endpoint we use to test our application's error handling.
The endpoint responds with status code HTTP 200
for every request except the
10th, where status code HTTP 555
is returned instead.
How might we test that this endpoint responds with HTTP 555
once for
every nine HTTP 200
responses?
/* File: test/unit.test.ts */
// Import the handler under test from the pages/api directory
import endpoint, { config } from '../pages/api/unreliable';
import { testApiHandler } from 'next-test-api-route-handler';
import type { PageConfig } from 'next';
const expectedReqPerError = 10;
// Respect the Next.js config object if it's exported
const handler: typeof endpoint & { config?: PageConfig } = endpoint;
handler.config = config;
it('injects contrived errors at the required rate', async () => {
expect.hasAssertions();
// Signal to the endpoint (which is configurable) that there should be 1
// error among every 10 requests
process.env.REQUESTS_PER_CONTRIVED_ERROR = expectedReqPerError.toString();
await testApiHandler({
handler,
test: async ({ fetch }) => {
// Run 20 requests with REQUESTS_PER_CONTRIVED_ERROR = '10' and
// record the results
const results1 = await Promise.all(
[
...Array.from({ length: expectedReqPerError - 1 }).map(() =>
fetch({ method: 'GET' })
),
fetch({ method: 'POST' }),
...Array.from({ length: expectedReqPerError - 1 }).map(() =>
fetch({ method: 'PUT' })
),
fetch({ method: 'DELETE' })
].map((p) => p.then((r) => r.status))
);
process.env.REQUESTS_PER_CONTRIVED_ERROR = '0';
// Run 10 requests with REQUESTS_PER_CONTRIVED_ERROR = '0' and record the
// results
const results2 = await Promise.all(
Array.from({ length: expectedReqPerError }).map(() =>
fetch().then((r) => r.status)
)
);
// We expect results1 to be an array with eighteen `200`s and two
// `555`s in any order
//
// https://github.com/jest-community/jest-extended#toincludesamemembersmembers
// because responses could be received out of order
expect(results1).toIncludeSameMembers([
...Array.from({ length: expectedReqPerError - 1 }).map(() => 200),
555,
...Array.from({ length: expectedReqPerError - 1 }).map(() => 200),
555
]);
// We expect results2 to be an array with ten `200`s
expect(results2).toStrictEqual([
...Array.from({ length: expectedReqPerError }).map(() => 200)
]);
}
});
});
Suppose we have an authenticated API endpoint our application uses to search for flights. The endpoint responds with an array of flights satisfying the query.
How might we test that this endpoint returns flights in our database as expected?
/* File: test/unit.test.ts */
import endpoint, { config } from '../pages/api/v3/flights/search';
import { testApiHandler } from 'next-test-api-route-handler';
import { DUMMY_API_KEY as KEY, getFlightData, RESULT_SIZE } from '../backend';
import type { PageConfig } from 'next';
// Respect the Next.js config object if it's exported
const handler: typeof endpoint & { config?: PageConfig } = endpoint;
handler.config = config;
it('returns expected public flights with respect to match', async () => {
expect.hasAssertions();
// Get the flight data currently in the test database
const expectedFlights = getFlightData();
// Take any JSON object and stringify it into a URL-ready string
const encode = (o: Record<string, unknown>) =>
encodeURIComponent(JSON.stringify(o));
// This function will return in order the URIs we're interested in testing
// against our handler. Query strings are parsed by NTARH automatically.
//
// NOTE: setting the request url manually using encode(), while valid, is
// unnecessary here; we could have used `params` or `paramPatcher` to do this
// more easily without explicitly setting a dummy request url.
//
// Example URI for `https://site.io/path?param=yes` would be `/path?param=yes`
const genUrl = (function* () {
// For example, the first should match all the flights from Spirit airlines!
yield `/?match=${encode({ airline: 'Spirit' })}`;
yield `/?match=${encode({ type: 'departure' })}`;
yield `/?match=${encode({ landingAt: 'F1A' })}`;
yield `/?match=${encode({ seatPrice: 500 })}`;
yield `/?match=${encode({ seatPrice: { $gt: 500 } })}`;
yield `/?match=${encode({ seatPrice: { $gte: 500 } })}`;
yield `/?match=${encode({ seatPrice: { $lt: 500 } })}`;
yield `/?match=${encode({ seatPrice: { $lte: 500 } })}`;
})();
await testApiHandler({
// Patch the request object to include our dummy URI
requestPatcher: (req) => {
req.url = genUrl.next().value || undefined;
// Could have done this instead of `fetch({ headers: { KEY }})` below:
// req.headers = { KEY };
},
handler,
test: async ({ fetch }) => {
// 8 URLS from genUrl means 8 calls to fetch:
const responses = await Promise.all(
Array.from({ length: 8 }).map(() =>
fetch({ headers: { KEY } }).then(async (r) => [
r.status,
await r.json()
])
)
);
// We expect all of the responses to be 200
expect(responses.some(([status]) => status != 200)).toBe(false);
// We expect the array of flights returned to match our
// expectations given we already know what dummy data will be
// returned:
// https://github.com/jest-community/jest-extended#toincludesamemembersmembers
// because responses could be received out of order
expect(responses.map(([, r]) => r.flights)).toIncludeSameMembers([
expectedFlights
.filter((f) => f.airline == 'Spirit')
.slice(0, RESULT_SIZE),
expectedFlights
.filter((f) => f.type == 'departure')
.slice(0, RESULT_SIZE),
expectedFlights
.filter((f) => f.landingAt == 'F1A')
.slice(0, RESULT_SIZE),
expectedFlights.filter((f) => f.seatPrice == 500).slice(0, RESULT_SIZE),
expectedFlights.filter((f) => f.seatPrice > 500).slice(0, RESULT_SIZE),
expectedFlights.filter((f) => f.seatPrice >= 500).slice(0, RESULT_SIZE),
expectedFlights.filter((f) => f.seatPrice < 500).slice(0, RESULT_SIZE),
expectedFlights.filter((f) => f.seatPrice <= 500).slice(0, RESULT_SIZE)
]);
}
});
// We expect these two to fail with 400 errors
await testApiHandler({
handler,
url: `/?match=${encode({ ffms: { $eq: 500 } })}`,
test: async ({ fetch }) =>
expect((await fetch({ headers: { KEY } })).status).toBe(400)
});
await testApiHandler({
handler,
url: `/?match=${encode({ bad: 500 })}`,
test: async ({ fetch }) =>
expect((await fetch({ headers: { KEY } })).status).toBe(400)
});
});
Check out the tests for more examples.
Further documentation can be found under
docs/
.
This is a dual CJS2/ES module package. That means this package exposes both CJS2 and ESM (treeshakable and non-treeshakable) entry points.
Loading this package via require(...)
will cause Node and some bundlers to use
the CJS2 bundle entry point. This can reduce the efficacy of tree
shaking. Alternatively, loading this package via
import { ... } from ...
or import(...)
will cause Node (and other JS
runtimes) to use the non-treeshakable ESM entry point in versions that support
it. Modern bundlers like Webpack and Rollup will use the
treeshakable ESM entry point. Hence, using the import
syntax is the modern,
preferred choice.
For backwards compatibility with Node versions < 14,
package.json
retains the main
key, which
points to the CJS2 entry point explicitly (using the .js file extension). For
Node versions > 14, package.json
includes the more modern
exports
key. For bundlers, package.json
includes the bundler-specific module
key (eventually superseded
by exports['.'].module
), which points to ESM source
loosely compiled specifically to support tree shaking.
Though package.json
includes
{ "type": "commonjs"}
, note that the ESM entry points are ES
module (.mjs
) files. package.json
also includes the
sideEffects
key, which is false
for optimal tree
shaking, and the types
key, which points to a TypeScript
declarations file.
Additionally, this package does maintain shared state (i.e. memoized imports, stateful error handling); regardless, it does not exhibit the dual package hazard.
New issues and pull requests are always welcome and greatly appreciated! π€© Just as well, you can star π this project to let me know you found it useful! βπΏ Thank you!
See CONTRIBUTING.md and SUPPORT.md for more information.