Package Exports
- next-test-api-route-handler
- next-test-api-route-handler/package
- next-test-api-route-handler/package.json
Readme
next-test-api-route-handler
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? Then look no further! 🤩 This package allows you to test your Next.js API routes/handlers in an isolated Next.js-like environment simply, quickly, and without hassle.
This package uses Next.js's internal API resolver to precisely emulate API route handling. Therefore, this package is automatically tested for compatibility with each full release of Next.js.
Install
npm install --save-dev next-test-api-route-handler
Note: this is a dual CJS2/ES module package
If you're looking for a version of this package compatible with a very old version of Next.js, consult CHANGELOG.md.
Usage
// ESM
import { testApiHandler } from 'next-test-api-route-handler'
// CJS
const { testApiHandler } = require('next-test-api-route-handler');
The interface for testApiHandler
looks like this:
async function testApiHandler({ requestPatcher, responsePatcher, params, handler, test }: {
requestPatcher?: (req: IncomingMessage) => void,
responsePatcher?: (res: ServerResponse) => void,
params?: Record<string, unknown>,
handler: (req: NextApiRequest, res: NextApiResponse) => Promise<void>
test: (obj: { fetch: (init?: RequestInit) => ReturnType<typeof fetch> }) => Promise<void>,
})
requestPatcher
is a function that receives an
IncomingMessage.
Use this function to modify the request before it's injected into Next.js's
resolver.
responsePatcher
is a function that receives an
ServerResponse.
Use this function to modify the response before it's injected into Next.js's
resolver.
params
is an object representing "processed" dynamic routes, e.g. testing a
handler that expects /api/user/:id
requires params: { id: ...}
. This should
not be confused with requiring query string parameters, which are parsed out
from the url and added to the params object automatically.
handler
is 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.
test
is a function that returns a promise (or async) where test assertions can
be run. This function receives one parameter: fetch
, which is a simple
unfetch instance (note
that the url parameter, i.e. the first parameter in
fetch(...)
, is
omitted). Use this to send HTTP requests to the handler under test.
Examples
Testing an Unreliable API Handler @ pages/api/unreliable
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?
import * as UnreliableHandler from '../pages/api/unreliable'
import { testApiHandler } from 'next-test-api-route-handler'
import { shuffle } from 'fast-shuffle'
import array from 'array-range'
import type { WithConfig } from '@ergodark/next-types'
// Import the handler under test from the pages/api directory and respect the
// Next.js config object if it's exported
const unreliableHandler: WithConfig<typeof UnreliableHandler.default> = UnreliableHandler.default;
unreliableHandler.config = UnreliableHandler.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 = '10';
const expectedReqPerError = parseInt(process.env.REQUESTS_PER_CONTRIVED_ERROR);
// Returns one of ['GET', 'POST', 'PUT', 'DELETE'] at random
const getMethod = () => shuffle(['GET', 'POST', 'PUT', 'DELETE'])[0];
// Returns the status code from a response object
const getStatus = async (res: Promise<Response>) => (await res).status;
await testApiHandler({
handler: unreliableHandler,
test: async ({ fetch }) => {
// Run 20 requests with REQUESTS_PER_CONTRIVED_ERROR = '10' and
// record the results
const results1 = await Promise.all([
...array(expectedReqPerError - 1).map(_ => getStatus(fetch({ method: getMethod() }))),
getStatus(fetch({ method: getMethod() })),
...array(expectedReqPerError - 1).map(_ => getStatus(fetch({ method: getMethod() }))),
getStatus(fetch({ method: getMethod() }))
].map(p => p.then(s => s, _ => null)));
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(expectedReqPerError).map(_ => getStatus(fetch({ method: getMethod() }))),
].map(p => p.then(s => s, _ => null)));
// 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(expectedReqPerError - 1).map(_ => 200),
555,
...array(expectedReqPerError - 1).map(_ => 200),
555
]);
// We expect results2 to be an array with ten `200`s
expect(results2).toStrictEqual([
...array(expectedReqPerError).map(_ => 200),
]);
}
});
});
Testing a Flight Search API Handler @ pages/api/v3/flights/search
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?
import * as V3FlightsSearchHandler 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 array from 'array-range'
import type { WithConfig } from '@ergodark/next-types'
// Import the handler under test from the pages/api directory and respect the
// Next.js config object if it's exported
const v3FlightsSearchHandler: WithConfig<typeof V3FlightsSearchHandler.default> = V3FlightsSearchHandler.default;
v3FlightsSearchHandler.config = V3FlightsSearchHandler.config;
it('returns expected public flights with respect to match', async () => {
expect.hasAssertions();
// Get the flight data currently in the test database
const flights = 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 automatically, though we
// also could have used `params` or `fetch({ ... })` itself instead.
//
// Example URI for `https://google.com/search?params=yes` would be
// `/search?params=yes`
const genUrl = function*() {
yield `/?match=${encode({ airline: 'Spirit' })}`; // i.e. we want all the flights matching Spirit airlines!
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: v3FlightsSearchHandler,
test: async ({ fetch }) => {
// 8 URLS from genUrl means 8 calls to fetch:
const responses = await Promise.all(array(8).map(_ => {
return fetch({ headers: { KEY }}).then(r => r.ok ? r.json() : r.status);
}));
// We expect all of the responses to be 200
expect(responses.some(o => !o?.success)).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([
flights.filter(f => f.airline == 'Spirit').slice(0, RESULT_SIZE),
flights.filter(f => f.type == 'departure').slice(0, RESULT_SIZE),
flights.filter(f => f.landingAt == 'F1A').slice(0, RESULT_SIZE),
flights.filter(f => f.seatPrice == 500).slice(0, RESULT_SIZE),
flights.filter(f => f.seatPrice > 500).slice(0, RESULT_SIZE),
flights.filter(f => f.seatPrice >= 500).slice(0, RESULT_SIZE),
flights.filter(f => f.seatPrice < 500).slice(0, RESULT_SIZE),
flights.filter(f => f.seatPrice <= 500).slice(0, RESULT_SIZE),
]);
}
});
// We expect these two to fail with 400 errors
await testApiHandler({
handler: v3FlightsSearchHandler,
requestPatcher: req => { req.url = `/?match=${encode({ ffms: { $eq: 500 }})}` },
test: async ({ fetch }) => expect((await fetch({ headers: { KEY }})).status).toBe(400)
});
await testApiHandler({
handler: v3FlightsSearchHandler,
requestPatcher: req => { req.url = `/?match=${encode({ bad: 500 })}` },
test: async ({ fetch }) => expect((await fetch({ headers: { KEY }})).status).toBe(400)
});
});
See test/index.test.ts for more examples.
Documentation
Documentation can be found under docs/
and can be built with
npm run build-docs
.
Contributing
New issues and pull requests are always welcome and greatly appreciated! If you submit a pull request, take care to maintain the existing coding style and add unit tests for any new or changed functionality. Please lint and test your code, of course!
NPM Scripts
Run npm run list-tasks
to see which of the following scripts are available for
this project.
Using these scripts requires a linux-like development environment. None of the scripts are likely to work on non-POSIX environments. If you're on Windows, use WSL.
Development
npm run repl
to run a buffered TypeScript-Babel REPLnpm test
to run the unit tests and gather test coverage data- Look for HTML files under
coverage/
- Look for HTML files under
npm run check-build
to run the integration testsnpm run check-types
to run a project-wide type checknpm run test-repeat
to run the entire test suite 100 times- Good for spotting bad async code and heisenbugs
- Uses
__test-repeat
NPM script under the hood
npm run dev
to start a development server or instancenpm run generate
to transpile config files (underconfig/
) from scratchnpm run regenerate
to quickly re-transpile config files (underconfig/
)
Building
npm run clean
to delete all build process artifactsnpm run build
to compilesrc/
intodist/
, which is what makes it into the published packagenpm run build-docs
to re-build the documentationnpm run build-externals
to compileexternal-scripts/
intoexternal-scripts/bin/
npm run build-stats
to gather statistics about Webpack (look forbundle-stats.json
)
Publishing
npm run start
to start a production instancenpm run fixup
to run pre-publication tests, rebuilds (like documentation), and validations- Triggered automatically by publish-please
NPX
npx publish-please
to publish the packagenpx sort-package-json
to consistently sortpackage.json
npx npm-force-resolutions
to forcefully patch security audit problems
Package Details
You don't need to read this section to use this package, everything should "just work"!
This is a dual CJS2/ES module package. That means this package exposes both CJS2 and ESM entry points.
Loading this package via require(...)
will cause Node to use the CJS2
bundle entry point, disable tree shaking in Webpack 4,
and lead to larger bundles in Webpack 5. Alternatively, loading this package via
import { ... } from ...
or import(...)
will cause Node to use the ESM entry
point in versions that support it and in Webpack. Using the
import
syntax is the modern, preferred choice.
For backwards compatibility with Webpack 4 and Node versions < 14,
package.json
retains the module
key, which
points to the ESM entry point, and the main
key, which
points to both the ESM and CJS2 entry points implicitly (no file extension). For
Webpack 5 and Node versions >= 14, package.json
includes the
exports
key, which points to both entry points explicitly.
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.
This package does not maintain shared state and so does not exhibit the dual package hazard.
Release History
See CHANGELOG.md.