How To Unit Test Next.js 13+ App Router API Routes with Jest and React-Testing-Library. Including Prisma example

dforrunner

Mo Barut

Posted on March 16, 2024

How To Unit Test Next.js 13+ App Router API Routes with Jest and React-Testing-Library. Including Prisma example

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:
File structure image


๐Ÿงช 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 });
}



Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode

โ• 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:

Code snippet

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
 */


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

If you're using TypeScript you will want to run this as well:



npm i @types/jest-json-schema -D


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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



Enter fullscreen mode Exit fullscreen mode

๐Ÿ“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));
});


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

๐Ÿ“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');
});


Enter fullscreen mode Exit fullscreen mode

โš  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/*"]
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

Then your jest.config.ts should look like this:



moduleNameMapper: {
  // ...
  '^@/prisma$': '<rootDir>/prisma/prisma.ts',
  '^@/components/(.*)$': '<rootDir>/components/$1',
}


Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก 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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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



Enter fullscreen mode Exit fullscreen mode

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:

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

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

  3. Consistency: Mocked data provides consistency in test environments, ensuring that tests produce reliable results regardless of the database's current state or external factors.

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

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

๐Ÿ’– ๐Ÿ’ช ๐Ÿ™… ๐Ÿšฉ
dforrunner
Mo Barut

Posted on March 16, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

ยฉ TheLazy.dev

About