First published February 16, 2022

How to Unit Test Next.js API Routes with TypeScript

If you need env vars, mocked data, and TypeScript types, this one's for you.

Woman coding

Introduction

Next.js is an awesome frontend framework. It's powered by React under the hood so it plays well with everything React has to offer out of the box: Hooks, Context, hot browser reloading, TypeScript integration, and then it takes it a step further than what Create React App has, and offers even more like routing, server side rendering (SSR), static site generation (SSG), all the SEO juice that comes along with both SSR and SSG, and built-in API routing - no extra Node server required to proxy API calls securely to a database, another microservice, or a third party API.

At work, a team of developers and I have been building a new application that we've open sourced to help our users get up and running faster with the Internet of Things (IoT) hardware we create.

For our first "accelerator application", the idea is that a user will get some of our IoT devices, those devices will begin collecting data like temperature, humidity, motion, etc., they'll send that environmental data to a cloud, and then they'll fork our "starter application" code to get a dashboard up and running, pulling in their own sensor data from the cloud, and displaying it in the browser.

To build this app, we decided to go with the Next.js framework because it offered so many of the benefits I listed above, one of the most important being the the ability to make secure API calls without having to set up a standalone Node server using Next.js's API routes. All of the data displayed by the application must be fetched from the cloud (or a database) where the device data is stored after it's first recorded.

And this being a production-ready application, things like automated unit and end-to-end tests to ensure the various pieces of the application work as expected are a requirement - both to give the developers and our users confidence that as new features are added already existing functionality remains intact.

While by and large, the Next.js documentation is great, one place that it does fall short is when it comes to unit testing these API routes. There is literally nothing in the documentation that touches on how to test API routes with Jest and React Testing Library - the de facto unit testing library combo when it comes to any React-based app.

Which is why today I'll be showing you how to unit test Next.js API routes, including gotchas like local environment variables, mocked data objects, and even TypeScript types for Next-specific objects like NextApiRequest.

The actual Next.js API route to test

So before we get to the tests, let me give you a brief example of the sorts of API calls this application might make. For our app, the first thing that must be fetched from the cloud is info about the "gateway devices".

NOTE: The files I've linked to in the actual repo are historical links in GitHub. The project underwent a major refactor afterwards to more cleanly divide up different layers for future ease of use and flexibility, but if you dig back far enough in the commit history (or just click on the hyperlinked file name) you can see our working code that matches what I'm describing below.

Fetch the gateway device info

The gateways are the brains of the operation - there are a number of sensors that all communicate with the gateways telling them what environmental readings they're getting at their various locations, and the gateways are responsible for sending that data from each sensor to the cloud - it's like a hub and spoke system you'd see on a bicycle wheel.

Before anything else can happen in the app, we have to get the gateway information, which can later be used to figure out which sensors and readings go with which gateways. I won't go into more details about how the app works because it's outside the scope of this post, but you can see the whole repo in GitHub here.

Let's focus on the API call going from the Next.js app to our cloud (which happens to be called Notehub). In order to query Notehub we'll need:

Below is an example of the call made to Notehub via Next.js to fetch the gateway device data. I'll break down what's happening after the code block.

Click the file name if you'd like to see the original code this file was modeled after.

pages/api/gateways/[gatewayID].ts

import type { NextApiRequest, NextApiResponse } from 'next';
import axios, { AxiosResponse } from 'axios';

export default async function gatewaysHandler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  // Only allow GET requests
  if (req.method !== 'GET') {
    res.status(405).json({ err: 'Method not allowed' });
    return;
  }

  // Gateway UID must be a string
  if (typeof req.query.gatewayID !== 'string') {
    res.status(400).json({ err: 'Invalid gateway ID' });
    return;
  }

  // Query params
  const { gatewayID } = req.query;
  // Notehub values
  const { BASE_URL, AUTH_TOKEN, APP_ID } = process.env;
  // API path
  const endpoint = `${BASE_URL}/v1/projects/${APP_ID}/devices/${gatewayID}`;
  // API headers
  const headers = {
    'Content-Type': 'application/json',
    'X-SESSION-TOKEN': AUTH_TOKEN,
  };

  // API call
  try {
    const response: AxiosResponse = await axios.get(endpoint, { headers });
    // Return JSON
    res.status(200).json(response.data);
  } catch (err) {
    // Check if we got a useful response
    if (axios.isAxiosError(err)) {
      if (err.response && err.response.status === 404) {
        // Return 404 error
        res.status(404).json({ err: 'Unable to find device' });
      }
    } else {
      // Return 500 error
      res.status(500).json({ err: 'Failed to fetch Gateway data' });
    }
  }
}

In our code, the Axios HTTP library is used to make our HTTP requests cleaner and simpler, there are environment variables passed in from a .env.local file for various pieces of the call to the Notehub project which need to be kept secret (things like APP_ID and AUTH_TOKEN), and since this project is written in TypeScript, the NextApiRequest and NextApiResponse types also need to be imported at the top of the file.

After the imports, there's a few validation checks to make sure that the HTTP request is a GET, and the gatewayID from the query params is a string (which it always should be, but it never hurts to confirm), then the URL request to the Notehub project is constructed (endpoint) along with the required headers to allow for access, and the call is finally made with Axios. Once the JSON payload is returned from Notehub, it's read for further errors like the gateway ID cannot be found, and if everything's in order, all the gateway info is returned.

There's just enough functionality and possible error scenarios to make it interesting, but no so much that it's overwhelming to test. Time to get on with writing unit tests.

Set up API testing in Next.js

Ok, now that we've seen the actual API route we want to write unit tests for, it's time to get started. Since we're just testing API calls instead of components being rendered in the DOM, Jest is the only testing framework we'll need this time, but that being said, there's still a little extra configuration to take care of.

Install the node-mocks-http Library

The first thing that we'll need to do in order to mock the HTTP requests and response objects for Notehub (instead of using actual production data, which is much harder to set up correctly every time) is to install the node-mocks-http.

This library allows for mocking HTTP requests by any Node-based application that uses request and response objects (which Next.js does). It has this handy function called createMocks(), which merges together two of its other functions createRequest() and createResponse() that allow us to mock both req and res objects in the same function. This lets us dictate what Notehub should accept and return when the gatewayHandler() function is called in our tests.

Add this library to the project's devDependencies list in the package.json file like so.

npm install --save-dev node-mocks-http

I learned the hard way that environment variables present in a Next.js project's .env.local file (the prescribed way Next wants to read environment variables) do not automatically populate to its unit tests.

Instead, we need to make a new file at the root of the project named .env.test.local to hold the test environment variables.

This file will basically be a duplicate of the env.local file.

We'll include the BASE_URL to reach our API, a valid AUTH_TOKEN, a valid APP_ID and a valid DEVICE_ID. The DEVICE_ID is the gateway device's ID, which actually comes from the app's URL query parameters but since this is just unit testing this route file's functionality, to keep all our variables in one centralized place, we'll pass the gateway's ID as an environment variable.

Here's what your test environment variables file should contain.

NOTE: Neither this file nor your actual .env.local file should ever be committed to your repo in GitHub. Make sure these are in your .gitignore file so they don't accidentally make it there where anyone could read potentially secret variables.

.env.test.local

BASE_URL=https://api.notefile.net
AUTH_TOKEN=[MY_AUTH_TOKEN]
APP_ID=[app:MY_NOTEHUB_PROJECT_ID]
DEVICE_ID=[dev:MY_GATEWAY_DEVICE_ID]

Use real env vars for your test file: Although in our final test file you won't see us importing all of these variables to construct the Notehub URL, if they are not valid and included now, the tests will error out - the tests are actually constructing valid URLs under the hood, we're just specifying what to send the receive back when the calls are placed. Undefined variables or nonsense test data variables will cause the tests to fail.

And with those two things done, we can get to testing.

Write the API tests

For keeping things in line with what Jest recommends, we can store all our test files inside of a folder at the root of the Next project named __tests__/, and to make it easy to figure out which tests go with which components, I tend to like to mimic the original file path and name for the file being tested.

If you prefer to keep your tests inline with your actual source files, that's a valid choice as well. I've worked with both kinds of code repos so it's really a matter of personal preference.

In that case, I tend to just create __tests__/ folders at the root of each folder alongside where the actual files live. So inside of the pages/api/ folder I'd make a new folder named __tests__/ and add any related test files in there.

Since this is a route API file buried within our pages/ folder, I'd recommend a similar file path inside the __tests__/ folder: __tests__/pages/api/gateways/[gatewayID].test.ts. In this way, a quick glance at the file name should tell us exactly what this file is testing.

Then, we come up with possible test cases to cover.

Some scenarios to test include:

  • Testing a valid response from Notehub with a valid authToken, APP_ID and DEVICE_ID which results in a 200 status code.
  • Testing that an invalid gateway ID for a device that doesn't exist and throws a 404 error.
  • Testing that no gateway ID results in a 400 error.
  • And testing that trying to make any type of HTTP call besides a GET results in a 405 error.

Below is what my tests look like to test this API endpoint. We'll dig into the details after the big code block.

Click the file name if you'd like to see the original code this file was modeled after.

__tests__/pages/api/gateways/[gatewayUID].test.ts

/**
 * @jest-environment node
 */
import { createMocks, RequestMethod } from 'node-mocks-http';
import type { NextApiRequest, NextApiResponse } from 'next';
import gatewaysHandler from '../../../../../src/pages/api/gateways/[gatewayUID]';

describe('/api/gateways/[gatewayUID] API Endpoint', () => {
  const authToken = process.env.AUTH_TOKEN;
  const gatewayID = process.env.DEVICE_ID;

  function mockRequestResponse(method: RequestMethod = 'GET') {
    const {
      req,
      res,
    }: { req: NextApiRequest; res: NextApiResponse } = createMocks({ method });
    req.headers = {
      'Content-Type': 'application/json',
      'X-SESSION-TOKEN': authToken,
    };
    req.query = { gatewayID: `${gatewayID}` };
    return { req, res };
  }

  it('should return a successful response from Notehub', async () => {
    const { req, res } = mockRequestResponse();
    await gatewaysHandler(req, res);

    expect(res.statusCode).toBe(200);
    expect(res.getHeaders()).toEqual({ 'content-type': 'application/json' });
    expect(res.statusMessage).toEqual('OK');
  });

  it('should return a 404 if Gateway UID is invalid', async () => {
    const { req, res } = mockRequestResponse();
    req.query = { gatewayID: 'hello_world' }; // invalid gateway ID

    await gatewaysHandler(req, res);

    expect(res.statusCode).toBe(404);
    expect(res._getJSONData()).toEqual({ err: 'Unable to find device' });
  });

  it('should return a 400 if Gateway ID is missing', async () => {
    const { req, res } = mockRequestResponse();
    req.query = {}; // Equivalent to a null gateway ID

    await gatewaysHandler(req, res);

    expect(res.statusCode).toBe(400);
    expect(res._getJSONData()).toEqual({
      err: 'Invalid gateway UID parameter',
    });
  });

  it('should return a 405 if HTTP method is not GET', async () => {
    const { req, res } = mockRequestResponse('POST'); // Invalid HTTP call

    await gatewaysHandler(req, res);

    expect(res.statusCode).toBe(405);
    expect(res._getJSONData()).toEqual({
      err: 'Method not allowed',
    });
  });
});

Handle the imports

Before writing our tests we need to import the createMocks and RequestMethod variables from the node-mocks-http library. As I noted earlier, createMocks() allows us to mock both the req and res objects in one function, instead of having to mock them separately.

Additionally, since this is a TypeScript file, we'll need to import the NextApiRequest and NextApiResponse types from next - just like for the real API route file.

And finally, we need to import the real gatewayHandler function - it's what we're trying to unit test after all.

Create a reusable mockRequestResponse() helper function

After creating a describe block to house all the unit tests, I created a reusable helper function to set up the mocked API call for each test.

This reusable mockRequestResponse() function, allows us to only have to construct our mocked HTTP call once, cuts down on the amount of duplicate code in the test files, and makes overall readability easier. Although we may change various parts of the req or res object based on what scenario is being tested, writing this function once and being able to call it inside of each test is a big code (and time) saver.

const authToken = process.env.AUTH_TOKEN;
const gatewayID = process.env.DEVICE_ID;

function mockRequestResponse(method: RequestMethod = 'GET') {
  const {
    req,
    res,
  }: { req: NextApiRequest; res: NextApiResponse } = createMocks({ method });
  req.headers = {
    'Content-Type': 'application/json',
    'X-SESSION-TOKEN': authToken,
  };
  req.query = { gatewayID: `${gatewayID}` };
  return { req, res };
}

Above, I've pulled out a snippet from the larger code block that focuses just on the mockRequestResponse() function and the two environment variables it needs to during its construction authToken and gatewayID. After declaring the function name we specify its method using the node-http-mocks RequestMethod object: method:RequestMethod="GET", and then we destructure and set the req and res object types that come from the createMocks() function as NextApiRequest and NextApiResponse (just like in our real code).

We create the same req.headers object that Notehub requires with our test-version authToken, and set the mocked query parameter gatewayID equal to the gatewayID being supplied by our .env.test.local file.

Write each test

With our mockRequestResponse() function built, we can simply call it inside of each test to get our mocked req and res objects, call the actual gatewayHandler() function with those mocked objects, and make sure the responses that come back are what we expect.

If a property on the req object needs to be modified before the call to gatewayHandler is made, it's as straight forward as calling the mockRequestResponse() function and then modifying whatever property of the req object needs to be updated.

const { req, res } = mockRequestResponse();
req.query = { gatewayID: 'hello_world' };

To check response objects, especially for error scenarios where different error strings are passed when a gateway ID is missing or invalid, we can use the res._getJSONData() function to actually read out the contents of the response. That way we can check actual error message along with the HTTP status codes.

Pretty handy, right?

Check the test code coverage

If you're using Jest's code coverage reporting features, now's a good time to run that function and check out the code coverage for this file in the terminal printout or the browser.

You can open the code coverage report via the command line by typing: open coverage/lcov-report/index.html

And hopefully, when you navigate to the code coverage for the pages/api/ routes, you'll see some much better code coverage for this file now.

Now go forth and add unit tests to all other API routes as needed.

Conclusion

I'm a fan of the Next.js framework - it's React at its heart with lots of niceties like SEO and API routes baked in. While Next fits the bill for many projects nowadays and helps get us up and running fast with projects, its testing documentation leaves something to be desired - especially for some of its really great additions like API routes.

Automated testing is a requirement in today's modern software world, and being able to write unit tests to continue to confirm an app's functionality works as expected isn't something to be ignored or glossed over. Luckily, the node-mocks-http library helps make setting up mocked req and res objects simple, so that we can test our Next.js app from all angles - from presentational components in the DOM down to API routes on the backend.

Check back in a few weeks — I’ll be writing more about JavaScript, React, IoT, or something else related to web development.

Thanks for reading. I hope learning how to unit test API routes helps you out in your next Next.js project (no pun intended!).

References & Further Resources

Want to be notified first when I publish new content? Subscribe to my newsletter.