JSPM

  • Created
  • Published
  • Downloads 44102
  • Score
    100M100P100Q157332F
  • License Unlicense

Confidently unit test your Next.js API routes/handlers in an isolated Next.js-like environment

Package Exports

  • next-test-api-route-handler
  • next-test-api-route-handler/package
  • next-test-api-route-handler/package.json

Readme

Black Lives Matter! Maintenance status Last commit timestamp Open issues Pull requests codecov Source license NPM version semantic-release

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 quickly test your Next.js API routes/handlers in an isolated Next.js-like context.

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. Go forth and test confidently!

Looking for a version of this package compatible with an earlier major release of Next.js? Breaking changes are documented in CHANGELOG.md.

Install

Note: NPM versions >=7 may need npm install --legacy-peer-deps until upstream peer dependency problems are resolved.

npm install --save-dev next-test-api-route-handler

If you don't have Next.js available, ensure it is installed as it is a required peer dependency of this package.

npm install --save-dev next
[additional details]

Note: you probably don't need to read through this! This information is primarily useful for those attempting to bundle this package or for people who have an opinion on ESM versus CJS.

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 and Webpack 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, as will 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 the CJS2 entry point explicitly (using the .js 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.

Additionally, this package does not maintain shared state and so does not exhibit the dual package hazard.

Usage

// 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;

await testApiHandler({
  handler,
  requestPatcher: (req) => (req.headers = { key: process.env.SPECIAL_TOKEN }),
  test: async ({ fetch }) => {
    const res = await fetch({ method: 'POST', body: 'data' });
    console.log(await res.json()); // ◄ outputs: "{hello: 'world'}"
  }
});

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: ({
    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 a 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.

Real-World Examples

Testing Next.js's Official Apollo Example @ pages/api/graphql

You can run this example yourself by cloning the Next.js repository, navigating to examples/api-routes-apollo-server-and-client, running npm install followed by npm install next-test-api-route-handler jest babel-jest @babel/core @babel/preset-env, copying and pasting the following JavaScript source (perhaps at tests/my.test.js), and finally running npx jest.

Note that passing the route configuration object (imported below as config) through to NTARH and setting request.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', async () => {
    expect.hasAssertions();

    await testApiHandler({
      // Set the request url to the path the graphql handler expects
      requestPatcher: (req) => (req.url = '/api/graphql'),
      handler,
      test: async ({ fetch }) => {
        const query = `query ViewerQuery {
          viewer {
            id
            name
            status
          }
        }`;

        const res = await fetch({
          method: 'POST',
          headers: {
            // Must have the correct content type too
            'content-type': 'application/json'
          },
          body: JSON.stringify({
            query
          })
        });

        expect(await res.json()).toStrictEqual({
          data: { viewer: { id: '1', name: 'John Smith', status: 'cached' } }
        });
      }
    });
  });
});

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?

/* 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 { shuffle } from 'fast-shuffle';
import array from 'array-range';

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('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,
    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?

/* 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 array from 'array-range';

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 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* () {
    // 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(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,
    requestPatcher: (req) => {
      req.url = `/?match=${encode({ ffms: { $eq: 500 } })}`;
    },
    test: async ({ fetch }) =>
      expect((await fetch({ headers: { KEY } })).status).toBe(400)
  });

  await testApiHandler({
    handler,
    requestPatcher: (req) => {
      req.url = `/?match=${encode({ bad: 500 })}`;
    },
    test: async ({ fetch }) =>
      expect((await fetch({ headers: { KEY } })).status).toBe(400)
  });
});

Check out the tests for more examples.

Documentation

Project documentation can be found under docs/.

Contributing and Support

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.