JSPM

  • Created
  • Published
  • Downloads 44102
  • Score
    100M100P100Q167646F
  • License MIT

Confidently unit and integration 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

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


Black Lives Matter! Last commit timestamp Codecov Source license Uses Semantic Release!

NPM version Monthly Downloads


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 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!




Install

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

See the appendix for legacy support options.

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;

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>;
});

requestPatcher

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.

responsePatcher

A function that receives a ServerResponse. Use this function to modify the response before it's injected into Next.js's resolver.

paramsPatcher

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.

handler

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 the handler function are kicked up to Next.js to handle. This means testApiHandler will NOT reject or throw if an unhandled error occurs in handler, which includes failing Jest expect() assertions. Instead, the response returned by fetch() in your test function will have a HTTP 500 status thanks to how Next.js deals with unhandled errors in production. Prior to 2.3.0, NTARH's behavior on unhandled errors in handler and elsewhere was inconsistent. Version 3.0.0 further improves error handling, ensuring no errors slip by uncaught.

To guard against false negatives, you can do either of the following:

  1. 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);
  1. If you're using version >=3.0.0, you can use rejectOnHandlerError to tell NTARH to intercept unhandled handler errors and reject the promise returned by testApiHandler instead of relying on Next.js to respond with HTTP 500. This is especially useful if you have expect() 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.

test

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);
    }
  });
});

response.cookies

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' }
      ]);
    }
  });
});

Real-World Examples

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

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, and git.

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 --force 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 next@9.0.6 --force
# Or even older:
# npm install next@9.0.0 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 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 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...
  });
});

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 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)
      ]);
    }
  });
});

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 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.

Appendix

Further documentation can be found under docs/.

Legacy Runtime Support

As of version 2.1.0, NTARH is fully backwards compatible with Next.js going allll the way back to next@9.0.0 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

Similarly, 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.

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!

Inspiration

I'm constantly creating things with Next.js. Most of these applications have a major API component. Unfortunately, Next.js doesn't make unit testing your APIs very easy. After a while, I noticed some conventions forming around how I liked to test my APIs and NTARH was born 🙂

Of course, this all was back before the app router or edge routes existed. NTARH got app router and edge route support in version 4.

Published Package Details

This is a CJS2 package with statically-analyzable exports built by Babel for Node.js versions that are not end-of-life. For TypeScript users, this package supports both "Node10" and "Node16" module resolution strategies.

Expand details

That means both CJS2 (via require(...)) and ESM (via import { ... } from ... or await import(...)) source will load this package from the same entry points when using Node. This has several benefits, the foremost being: less code shipped/smaller package size, avoiding dual package hazard entirely, distributables are not packed/bundled/uglified, a drastically less complex build process, and CJS consumers aren't shafted.

Each entry point (i.e. ENTRY) in package.json's exports[ENTRY] object includes one or more export conditions. These entries may or may not include: an exports[ENTRY].types condition pointing to a type declarations file for TypeScript and IDEs, an exports[ENTRY].module condition pointing to (usually ESM) source for Webpack/Rollup, an exports[ENTRY].node condition pointing to (usually CJS2) source for Node.js require and import, an exports[ENTRY].default condition pointing to source for browsers and other environments, and other conditions not enumerated here. Check the package.json file to see which export conditions are supported.

Though package.json includes { "type": "commonjs" }, note that any ESM-only entry points will be ES module (.mjs) files. Finally, package.json also includes the sideEffects key, which is false for optimal tree shaking where appropriate.

License

See LICENSE.

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! ✊🏿 Or you could buy me a beer 🥺 Thank you!

See CONTRIBUTING.md and SUPPORT.md for more information.

Contributors

All Contributors

Thanks goes to these wonderful people (emoji key):

Bernard
Bernard

🚇 💻 📖 🚧 ⚠️ 👀
Kevin Jennison
Kevin Jennison

📖
jonkers3
jonkers3

📖
Valentin Hervieu
Valentin Hervieu

💻 🤔 🔬 ⚠️
Dana Woodman
Dana Woodman

🚇
Rhys
Rhys

🤔
Prakhar Shukla
Prakhar Shukla

🐛
Jake Jones
Jake Jones

🐛 🤔 🔬
Diego Esclapez
Diego Esclapez

🐛
k2xl
k2xl

🔬
Jeremy Walker
Jeremy Walker

💡
Adrian Kriegel
Adrian Kriegel

💡
hems.io
hems.io

🐛 🔬 🤔 💡
Steve Taylor
Steve Taylor

🤔
Will Nixon
Will Nixon

🐛 🔬
Sebastien Powell
Sebastien Powell

💡
Hajin Lim
Hajin Lim

🤔
Jane
Jane

💡
Jan Hesters
Jan Hesters

🐛
Bence Somogyi
Bence Somogyi

🐛 💻 🔬 ⚠️
Add your contributions

This project follows the all-contributors specification. Contributions of any kind welcome!