How to Test Serverless Functions with Jest & Next.js API Routes
- What is Jest?
- What are Next.js API routes?
- Why is testing serverless functions important?
- What are we going to build?
- Step 0: Creating a new Next.js app and serverless function
- Step 1: Installing and configuring Jest
- Step 2: Creating our first test with Jest
- Step 3: Testing Next.js serverless functions with Jest
- Step 4: Abstracting and testing serverless function logic
- What else can we do?
Tests are critical part of any codebase, making sure our application is behaving as expected, but how does that apply to testing APIs like Next.js serverless functions?
While there are great tools like Postman that can make actual requests to an endpoint, how can we test the code that actually gets executed inside of the function?
What is Jest?
Jest is a JavaScript testing framework built by the team at Facebook. It’s become popularized in the world of React and generally in the JavaScript community.
Typically when writing tests, you need a tool to run the test. Additionally, you need to write test assertions, meaning, you need to expect that your code is working a certain way.
This is where Jest comes in, handling running tests along with a wide variety of features on top.
What are Next.js API routes?
Next.js, the web and React framework, supports the ability to build serverless functions which correspond to an API route.
One popular example of where you might see a serverless function is the service AWS Lambda, where the goal is to create a function with typical input and output that allows developers to run code that’s more commonly seen serverside.
Why is testing serverless functions important?
Just like any other function, serverless functions can contain important business logic that is critical to your product’s operations.
I like to use an example of an ecommerce store, where if you’re using an API to allow your customers to work through checkout clientside, you need to make sure your order logic and calculations are working as expected. If not, you run the risk of costing your business money!
Because serverless functions are interfaced with as APIs, its common to see these functions tested with API tools like Postman, which works great, but because with Next.js we’re really creating an exported function, we can pull that right into a Jest test and keep it close to the rest of our code.
What are we going to build?
We’re going to work through how we can use Jest to test Next.js serverless functions.
To start off, we’re going to start up a simple example of what a serverless function and API endpoint could look like for an ecommerce store and how we can make sure that endpoint is running calculations properly. To do that
We’ll also take this a step further and abstract some of that logic into individual functions outside of the endpoint. While we have the option of always testing the entire input and output of the serverless function itself, being able to create focused logic and tests can help to ensure that logic will still work while other code inevitably moves around it.
Note: this tutorial won’t work through testing components with Jest, our focus will be on serverless functions and abstractions used by them.
Step 0: Creating a new Next.js app and serverless function
We’re going to spin up a new Next.js application using a Starter I created for this walkthrough that includes some UI that can help us test out our API.
To get started, open up your terminal and run:
yarn create next-app my-next-jest -e https://github.com/colbyfayock/demo-next-function-starter
# or
npx create-next-app my-next-jest -e https://github.com/colbyfayock/demo-next-function-starter
This will create a new project inside of the directory my-next-jest
.
Note: feel free to change
my-next-jest
to the directory and project name of your choice!
Once everything is installed, navigate to that new directory:
cd my-next-jest
Then, start up the new project by running:
yarn dev
# or
npm run dev
Which will start up a local development server at http://localhost:3000 where you can now access your new Next.js app!
Before we move on, let’s get a bit familiar with what we just created.
In the browser, we can see we have two columns.
The left column includes some forms that allow you to add additional items along with another form that allows you to configure a discount and tax value.
On the right, we have a list of the items along with a Calculate Order button where when clicked, reaches out to an API along with all the order details to calculate the subtotal and total price for the order.
While we’re not going to really get into the app itself, this cart endpoint is what we’ll use to learn how to write tests with Jest, and you can use the application to poke around and see what’s happening.
You can find the code for this endpoint at pages/api/cart.js
which you can get familiar with before we move on!
Step 1: Installing and configuring Jest
There isn’t much to do to get started with using Jest.
First, let’s install Jest by running:
yarn add jest -D
# or
npm install jest --save-dev
Next, a common way to run tests is to set up an npm script.
Inside package.json
, add a new property under scripts
for our tests:
"test": "jest"
At this point, we don’t have any tests, so if you run the test command, it will state that it can’t find any matches.
Before we write our first test though, let’s do one more optional thing for convenience.
When writing my functions, I like to use the import syntax instead of using a require statement. The problem with that though, is if you try to use import in a Jest test, it will fail to parse it.
To fix this, we can add a quick tweak to our Babel config.
In the root of the project, add a new file called .babelrc
and add:
{
"presets": ["next/babel"],
"env": {
"test": {
"plugins": ["@babel/plugin-transform-modules-commonjs"]
}
}
}
The first thing we do is add our Next.js Babel preset. By default, Next.js uses this preset and we don’t even need a configuration. But because we’re creating a new config, we need to add that back, otherwise, our config will override the Next.js config.
We’re then creating a specific rule for our test environment, where we’re importing a plugin that will transform our code so that it can recognize how to use the import keyword.
That means, we also need to install this plugin by running:
yarn add @babel/plugin-transform-modules-commonjs -D
# or
npm install @babel/plugin-transform-modules-commonjs --save-dev
But now, Jest is ready for us to get started writing tests!
Step 2: Creating our first test with Jest
Before we actually write a test for our functions, let’s stub a generic test out so we can understand how this will work.
First, let’s start off by creating our testing directory. Inside the root of the project add a new directory called tests
.
Because we’ll reuse this generic test for our serverless functions, let’s also organize it as such. Create a new directory api
inside of the tests
directory.
Now finally, create a new file cart.test.js
inside of tests/api
and add the following:
test('calculates order total', () => {
const price = 5.00;
const quantity = 2;
expect(price * quantity).toEqual(10);
});
Before walking through what this does, let’s head over to our terminal and run:
yarn test
# or
npm test
We can see that Jest went through and ran our test. Now back to our code, here’s what’s happening:
- We first use the
test
function which allows us declare a new test - We pass a phrase, particularly “calculates order total” which is our way of explaining what the test should actually be testing
- Inside of that function’s callback, which is where the test itself runs, we set up a few variables for our sample test
- We then use the
expect
function which allows us to write an assertion which is basically saying that whatever we pass into thatexpect
function, the following chained method should describe its value - So then using
.toEqual
, we’re saying we expect that the value of our price multiplied by our quantity should equal 10
If we change any of those values, such as changing the quantity to 3, we can see that we get a failure.
Now this is a very static example just to show how this works, but we can see what happens when this isn’t working as expected.
As we’ll see in the next step, the goal will be not to write tests about our static calculations, but to test functions that we create as part of our application. We want to make sure that whenever we run the functions we write, that they’re working as we expect them to!
Step 3: Testing Next.js serverless functions with Jest
Now that we have our basic test, let’s use it to actually test the serverless function in our app.
If we open up our serverless function at pages/api/cart.js
, we notice a few things:
- The function is made up of several calculations
- Those calculations are based on data that are parsed from a body
- That calculated data is returned
- Most importantly, the entire thing is a function that we’re exporting
The nice thing about serverless functions is what’s in its name—its a function.
That means, we can import this function and write a test just like we would any other test.
So to get started, let’s first update our test. Inside tests/api/cart.test.js
, import our function at the top of the file:
import cart from '../../pages/api/cart';
We ultimately want to invoke this function. So we can start off by updating our test:
test('calculates order total', () => {
cart();
});
Now typically, we would want to save the output of an invoked function and test against that, but if we look at the function’s code, we’re not actually returning anything.
Instead, the function uses a callback at the end, notifying the function handler that it’s completed along with the returned data and status code. Particularly, it calls the status
method and chained json
method off of the res
object.
What we can do is create a mock of those functions using Jest, which importantly, will allow us to see when that mocked function was called along with the data that it was called with.
To start, let’s build our our two arguments:
test('calculates order total', () => {
const req = {};
const res = {};
cart(req, res);
});
Starting with our req
object, if we look in our function, the only thing we’re using is the body property, which is where the data that’s passed to the function is stored as a string.
Knowing that we’re passing three variables (discount, tax, and items), we can recreate that data to simulate a body getting passed to the function:
const req = {
body: JSON.stringify({
discount: .2,
tax: .06,
items: [
{
id: 1,
price: 19.99,
quantity: 2
},
{
id: 2,
price: 43.49,
quantity: 1
}
]
})
}
Notice here we’re also using JSON.stringify
on the body, as the function expects it to be a string, which it later parses. This is how the serverless function actually works in practice via an API endpoint.
Moving on to the res
object, the important part happens at the end, when we’re using it to respond to the request.
Now if we were to build it as a simple object like the above, that might look like:
const res = {
status: () => ({
json: () => {}
})
}
But we have two important things to consider:
- We need to use the Jest mock functionality so that we can listen to its invocation
- We need to be able to reference the
json
property and by nesting it inside of mock functions, we lose the ability to do that
So instead, we’re going to create a series of mocked functions to build our res
object:
const json = jest.fn();
const status = jest.fn(() => {
return {
json
}
})
const res = {
status
}
If we see we’re using jest.fn()
as the value, which is how we can mock a function using Jest.
Now at this point, if we try running this test, we’ll notice that it passes, but it’s not really showing the entire picture. We don’t have any assertions, so there’s nothing to actually test against!
Like I mentioned a little earlier, when using the Jest mock functionality, we gain the ability to see when and how that function was called. This is stored in the variable that we set the mock function to.
We can see what that looks like by adding a console log statement at the end of the test:
cart(req, res);
console.log(json.mock);
console.log(json.mock.calls);
In the above, I’m intentionally logging out two levels deep so that we can see the output of each.
When we run our tests, we should see them both:
The first log shows that we get a variety of information from our mocked function including when it was called along with the results.
But for our test, we’re more interested in what it was called with, which is what leads us to the calls
property.
As we can see, the value of json.mock.calls
is an array, which represents each time our mocked function was called, which in our case was just once. So we can drill down through the two levels of an array where we can access the data that’s returned from our function:
{
items: [Array],
discount: 0.2,
tax: 0.06,
subtotal: 83.47,
total: 70.78255999999999
}
We can see that we have both our subtotal
and total
values calculated from the input that we passed in to our function!
So let’s write an assertion for this to make sure it doesn’t break.
Let’s verify that we’re always getting the correct subtotal:
cart(req, res);
expect(json.mock.calls[0][0].subtotal).toEqual(83.47);
If we again run our tests, we can see that it still works!
But let’s make sure that if something breaks that it’s going to actually continue working.
Inside of pages/api/cart.js
let’s break our function by changing the subtotal
calculation line:
const subtotal = items.reduce((subtotal, { price, quantity }) => {
return subtotal + price;
}, 0);
We’re no longer taking into consideration the quantity of the items! 😱
But now let’s run our test and see what happens:
Phew, we can see that our test caught our error, so we can make sure to look back at our code where we can see exactly where we have an issue!
But rest assured, when we undo and fix our subtotal calculation, we’re running smoothly with our API working exactly as expected.
Step 4: Abstracting and testing serverless function logic
Now finally, as our code grows, we’ll eventually want to abstract it to make it a little more manageable.
Additionally, as we abstract it and write tests for those abstractions, we’ll be able to have a little more insight into what exactly was broken when all of those tests start to fail.
So to get started, let’s first abstract a small piece of our code. In the last step, we tested what happened when we broke the subtotal calculation, so let’s abstract that and try it again!
To start, let’s create a new directory in the root of our project called lib
and inside it create a file called orders.js
.
Next, let’s create a new function and move our subtotal calculation. So inside lib/orders.js
add:
export function calculateSubtotalFromItems(items) {
return items.reduce((subtotal, { price, quantity }) => {
return subtotal + ( price * quantity );
}, 0);
}
Back inside of our serverless function, we can now use that function. At the top of pages/api/cart.js
add:
import { calculateSubtotalFromItems } from '../../lib/orders';
Then we can replace our subtotal
constant:
const subtotal = calculateSubtotalFromItems(items);
Now, if we run our tests again, we can see we’re still passing! So next, let’s write a test for that individual function.
Back inside of the tests
directory, add a new folder called lib
and inside create a new file called orders.test.js
.
Note: Notice how I’m creating a directory tree similar to where the API function and abstracted functions live. It’s one way of organizing your tests to make it easier to know where to expect them.
Now inside of tests/lib/orders.tests.js
let’s add our new test:
import { calculateSubtotalFromItems } from '../../lib/orders';
test('returns calculated subtotal from items', () => {
const items = [
{
id: 1,
price: 19.99,
quantity: 2
},
{
id: 2,
price: 43.49,
quantity: 1
}
];
expect(calculateSubtotalFromItems(items)).toEqual(83.47);
});
In this snippet, we can see some code that’s really similar to our other test. Here we’re:
- Importing our
calculateSubtotalFromItems
function - Setting up a new test
- Using the same items that we found in our last test
- Passing those items to our function
- Expecting that the value returned is the correct subtotal
Tip: when writing tests, it’s good to write multiple variations of the expect statement, especially when you find a bug, to make sure you’re adding as much coverage as you can!
And if we run our tests, we can see that we now have two passing tests!
Similar to the last step, we can also test what happens when this fails.
If we update our abstracted function:
export function calculateSubtotalFromItems(items) {
return items.reduce((subtotal, { price, quantity }) => {
return subtotal + price;
}, 0);
}
And run our tests:
We can see that we now have two failing tests, but because our abstracted function is failing, we now know exactly where to start looking when trying to resolve the bug!
What else can we do?
Write tests for React components
While this tutorial doesn’t cover writing tests for React, we can use Jest along with other testing tools to make sure we’re providing coverage everywhere.
Check out React Testing Library!
Write integration tests
You can also write even stronger tests by testing the application inside of the browser itself.
To do so, we can use tools like Cypress and Playwright which will test both the front end code but also that it’s working correctly with the API.
Write visual tests
We can also take this all to another level with visual testing, which compares screenshots of the application, providing broad coverage for what the people visiting your app actually see.
Check out my tutorial here on spacejelly.dev to get started!