How To Unit Test Next.js 13+ App Router API Routes with Jest and React-Testing-Library. Including Prisma example
Mo Barut
Posted on March 16, 2024
GitHub repo - all the examples used in this article are in this repo. I will add more examples to this repo over time.
Testing Next.js API routes seems quite daunting at first due to lack of documentation. But fear not! In this article, I'll cover how to unit test Next.js App Router API routes. Along the way I'll include some examples, tips and warnings for potential issues you may face. That said buckle up, and let's dive into the perplexing world of Next.js API route testing! ๐
Here is what we will cover:
- Unit testing Next.js API GET route
- Unit testing Next.js API POST/PUT/DELETE routes
- Jest configurations
I'm going to assume you already have Jest set up, but if you haven't here is the link to the documentation for that: Setting up Jest with Next.js
Unit Testing Next.js GET Route
Here is what my file structure looks like:
๐งช Example - basic testing
๐app/api/items/route.ts
:
import { NextResponse } from 'next/server';
export async function GET() {
const items = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
];
return NextResponse.json(items, { status: 200 });
}
This is a simple GET endpoint that will return a json object of some items. In your situation this would probably get data from a database or some other API call and then return that to the client. The last example in this article will go cover that. That said, let's look at what the test for this would look like:
๐app/api/items/route.test.ts
:
/**
* @jest-environment node
*/
import { GET } from './route';
it('should return data with status 200', async () => {
const response = await GET();
const body = await response.json();
expect(response.status).toBe(200);
expect(body.length).toBe(2);
});
That's it. We don't actually need to mock the GET function to test it. We mainly need to mock the calls within it if needed. The last example will cover that.
โ Warning - If you're using Jest to test React components as well read this section:
By default Jest uses the node
environment, but when we test React components we need use a browser like environment in which case we would use the jsdom
environment. However, there is a caveat and let's go over that.
We specify the environment we want to use for our test in the jest.config.ts
file like so:
const customJestConfig: Config = {
testEnvironment: 'jsdom',
//...other configs
}
โ The problem is we need to use the node
environment to test the API routes. Otherwise, our tests will fail with an error that looks something like this:
This is mainly because there are some new global objects that were introduced in later versions of Node.js which are not yet in jsdom
, such as the Response
object.
So, how can we use jsdom
environment when testing React components and use the node
environment when testing the API routes?
Well, all we have to do is tell Jest to use the node
environment in the route test files, by including the following comment at the very top of the files: read more on that here
/**
* @jest-environment node
*/
That's it. When jest sees that it will use the node environment to run the tests in that test file.
Testing Response Body Schema
Let's take the previous test one step further and test the response body against a schema to ensure our API will return what we expect it to return
We will use the jest-json-schema package to test the schema, so let's go ahead an install that by running:
npm i jest-json-schema -D
If you're using TypeScript you will want to run this as well:
npm i @types/jest-json-schema -D
Ok cool, now that we have those we can use them in our tests like so:
๐งช Example - testing response body schema
๐app/api/items/route.test.ts
:
import { GET } from './route';
import { matchers } from 'jest-json-schema';
expect.extend(matchers);
it('should return data with status 200', async () => {
const response = await GET();
const body = await response.json();
const schema = {
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
},
required: ['id', 'name'],
};
expect(response.status).toBe(200);
expect(body[0]).toMatchSchema(schema);
});
Now we are ensuring that our API is returning the properties and the data types we expect. You can read more about jest-json-schema usages here
Before we move onto the other API methods here is an example of a test with search query params:
๐งช Example - testing routes with search query params
๐app/api/items/route.ts
:
import { NextRequest, NextResponse } from 'next/server';
const items = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
];
export async function GET(req: NextRequest) {
const itemId = req.nextUrl.searchParams.get('Id');
if (!itemId) {
return NextResponse.json({ error: 'Item Id is required' }, { status: 400 });
}
const item = items.find((item) => item.id === parseInt(itemId));
if (!item) {
return NextResponse.json({ error: 'Item not found' }, { status: 404 });
}
return NextResponse.json(item, { status: 200 });
}
๐app/api/items/route.test.ts
:
/**
* @jest-environment node
*/
import { GET } from './route';
it('should return data with status 200', async () => {
const requestObj = {
nextUrl: {
searchParams: new URLSearchParams({ Id: '1' }),
},
} as any;
const response = await GET(requestObj);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.id).toBe(1);
});
it('should return error with status 400 when item not found', async () => {
const requestObj = {
nextUrl: {
searchParams: new URLSearchParams({ Id: '3' }),
},
} as any;
const response = await GET(requestObj);
const body = await response.json();
expect(response.status).toBe(404);
expect(body.error).toEqual(expect.any(String));
});
Unit Testing Next.js POST/PUT/DELETE/PATCH API Routes
We can apply the same strategies we discussed earlier to test these methods. The main difference here is that we'll be passing data to the route method. Testing POST, PUT, DELETE, and PATCH methods follows a similar pattern, as they essentially boil down to a POST method under the hood. Therefore, I'll only provide examples using POST, but you can test the others in a similar fashion.
๐งช Example - testing POST/PUT/DELETE/PATCH methods
๐app/api/items/route.ts
:
import { NextRequest, NextResponse } from 'next/server';
const items = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
];
export async function POST(req: NextRequest) {
const requestBody = await req.json();
const item = {
id: items.length + 1,
name: requestBody.name,
};
items.push(item);
return NextResponse.json(item, { status: 201 });
}
๐app/api/items/route.test.ts
:
/**
* @jest-environment node
*/
import { POST } from './route';
it('should return added data with status 201', async () => {
const requestObj = {
json: async () => ({ name: 'Item 3' }),
} as any;
const response = await POST(requestObj);
const body = await response.json();
expect(response.status).toBe(201);
expect(body.id).toEqual(expect.any(Number));
expect(body.name).toBe('Item 3');
});
โ Warning - If you're using Module Path Aliases in your tsconfig.json then you need to include them in your jest.config.ts
If your project is using Module Path Aliases, your gonna want to make sure your jest.config.ts
moduleNameMapper property includes them like so:
If your tsconfig.json
paths look something like this:
{
"compilerOptions": {
//...
"baseUrl": "./",
"paths": {
"@/prisma": ["prisma/prisma.ts"],
"@/components/*": ["components/*"]
}
}
}
Then your jest.config.ts
should look like this:
moduleNameMapper: {
// ...
'^@/prisma$': '<rootDir>/prisma/prisma.ts',
'^@/components/(.*)$': '<rootDir>/components/$1',
}
๐ก TIP - However, manually updating configurations in multiple places can be a recipe for forgetfulness-induced headaches. Here's a nifty trick to programmatically ensure that your jest.config.ts
moduleNameMapper
stays in sync with your tsconfig.json
paths:
We're going to use the pathsToModuleNameMapper method from the ts-jest package, so let's go ahead and install that by running:
npm i ts-jest -D
Then update the jest.config.ts
like so:
import { Config } from 'jest';
import { pathsToModuleNameMapper } from 'ts-jest';
import { compilerOptions } from './tsconfig.json';
const customJestConfig: Config = {
//...other configs
// Map TypeScript paths to Jest module names
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/' }),
};
โ Warning - To avoid sneaky bugs: when defining paths in your tsconfig.json
, ensure they don't start with './'. I've noticed that the pathsToModuleNameMapper
function can sometimes mishandle these paths. Forgetting to exclude them might lead to errors with some imports using aliases, while others sail smoothly, making it a real head-scratcher of a bug to tackle.
๐งช Example - testing route method that makes an external requests (In this example we will use Prisma)
In most situations API routes will make requests to a database, or some other API. In this example we'll go over testing an API route that uses Prisma to make database a query.
๐ก TIP- If you're using Prisma in your project read through the following article for a better way to mock your Prisma client: Mocking Prisma Client
๐app/api/items/route.ts
:
import prisma from '@/prisma';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
try {
//Parse the request body
const requestBody = await req.json();
//Validate the request body
if (!requestBody.name) {
return NextResponse.json({
status: 400,
message: 'Name is required',
}, { status: 400 });
}
//Create the item
const item = await prisma.item.create({
data: {
name: requestBody.name,
},
});
//Return the item
return NextResponse.json(item, { status: 201 });
} catch (error) {
//Return error response
return NextResponse.json({
status: 500,
message: 'Something went wrong',
}, { status: 500 });
}
}
This route method closely mirrors real-world usage. Here is what the test's may look like:
๐app/api/items/route.test.ts
:
/**
* @jest-environment node
*/
import { POST } from './route';
import prisma from '@/prisma';
// Mock prisma
// We want to ensure we're mocking the prisma client for this test
// so we don't actually make a call to the database
jest.mock('@/prisma', () => ({
__esModule: true,
default: {
item: {
create: jest.fn(),
},
},
}));
it('should return added data with status 201', async () => {
const requestObj = {
json: async () => ({ name: 'Item 3' }),
} as any;
// Mock the prisma client to return a value
(prisma.item.create as jest.Mock).mockResolvedValue({ id: 2, name: 'Item 3' });
// Call the POST function
const response = await POST(requestObj);
const body = await response.json();
// Check the response
expect(response.status).toBe(201);
expect(body.id).toEqual(expect.any(Number));
expect(body.name).toBe('Item 3');
expect(prisma.item.create).toHaveBeenCalledTimes(1);
});
it('should return status 400 when name is missing from request body', async () => {
const requestObj = {
json: async () => ({}),
} as any;
(prisma.item.create as jest.Mock).mockResolvedValue({ id: 2, name: 'Item 3' });
const response = await POST(requestObj);
const body = await response.json();
expect(response.status).toBe(400);
expect(body.message).toEqual(expect.any(String));
expect(prisma.item.create).not.toHaveBeenCalled();
});
it('should return status 500 when prisma query rejects', async () => {
const requestObj = {
json: async () => ({ name: 'Item 3' }),
} as any;
// Mock the prisma client to reject the query
(prisma.item.create as jest.Mock).mockRejectedValue(new Error('Failed to create item'));
const response = await POST(requestObj);
const body = await response.json();
expect(response.status).toBe(500);
expect(body.message).toEqual(expect.any(String));
});
In the above example, I used Prisma, but you can mock anything in a similar fashion.
๐ก TIP - Ensure you mock your network requests in your route tests. Here is why:
Avoiding running queries against our database or making external network calls during testing offers several advantages:
Speed: Mocking database queries allows tests to run much faster compared to interacting with a real database. This speed improvement is particularly significant when running a large suite of tests.
Isolation: Testing against a mocked database ensures that tests are isolated from changes in the actual database state. It prevents unintended side effects such as altering or deleting data crucial for other tests.
Consistency: Mocked data provides consistency in test environments, ensuring that tests produce reliable results regardless of the database's current state or external factors.
Portability: Tests can be executed in various environments without dependency on a specific database configuration or connection. This portability enhances the test suite's flexibility and makes it easier to run tests in different development, staging, or continuous integration environments.
Cost: Running database queries during testing might incur additional costs, especially when testing against cloud-hosted databases or services. Mocking database interactions helps reduce these expenses.
Overall, avoiding direct interaction with the database in tests promotes faster, more reliable, and cost-effective testing practices.
Here is the GitHub repo - all the examples used in this article are in this repo. I will add more examples to this repo over time. Contributions are welcomed! So feel free to clone the repo, add more examples and create a Pull Request to help your fellow developers ๐ฏ
That's a wrap for now! I hope this walkthrough has sparked some ideas for testing your Next.js API routes or has provided you with valuable insights. If you have any questions or suggestions, don't hesitate to reach out.
Posted on March 16, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.