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:
- An authorization token,
- A Notehub project's ID,
- And a gateway device's ID.
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 is 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
Add an .env.test.local
file for test-related environment variables
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 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 thepages/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
andDEVICE_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.
If you’d like to make sure you never miss an article I write, sign up for my newsletter here: https://paigeniedringhaus.substack.com
Thanks for reading. I hope learning how to unit test API routes helps you out in your next Next.js project (no pun intended!).
Top comments (0)