DEV Community

Rubén Chiquin
Rubén Chiquin

Posted on

Effortless API Testing: Node.js Techniques for Next.js Route handlers

Have you ever tried to test a metaframework? It's a nightmare. These frameworks tend to give you an opinion for almost everything, but often times, when it comes to testing, they turn a blind eye and pretend it's none of their business.

Well, I took it upon myself to figure out how to test Next.js's route handlers, and in this post I'll explain my solution. Feel free to comment any alternatives you might have found!

The usual way

If you've ever done any type of testing with Node.js, you most likely have come up with libraries like Jest, Mocha or Vitest. This is because Node.js for a long time has never provided a native way to do testing. If you wanted some type of testing, you'd have to draw on testing libraries.

But these libraries come with their own problems. For starters, have you ever tried testing a typescript project with Jest? Let me walk you through a simple example.

First, you can't use jest right out of the box, becuase Jest does not support ECMA script nor Typescript. For that, you need to use another dependency like ts-jest, which is a wrapper of jest itself. And maybe even a Babel preset if I need it to work with Babel.. Let's see what we have to do to get this to work.

So you first have to create a config file like this to support ts files:

 // jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  // [...]
  transform: {
    // '^.+\\.[tj]sx?$' to process ts,js,tsx,jsx with `ts-jest`
    // '^.+\\.m?[tj]sx?$' to process ts,js,tsx,jsx,mts,mjs,mtsx,mjsx with `ts-jest`
    '^.+\\.tsx?$': [
      'ts-jest',
      {
        // ts-jest configuration goes here
      },
    ],
  },
}
Enter fullscreen mode Exit fullscreen mode

But wait a minute! This still does not support ESM imports, so we have to figure out reading the docs how that works. Turns out you have to add some more config:

// jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  // [...]
  extensionsToTreatAsEsm: ['.ts'],
  moduleNameMapper: {
    '^(\\.{1,2}/.*)\\.js$': '$1',
  },
  transform: {
    // '^.+\\.[tj]sx?$' to process ts,js,tsx,jsx with `ts-jest`
    // '^.+\\.m?[tj]sx?$' to process ts,js,tsx,jsx,mts,mjs,mtsx,mjsx with `ts-jest`
    '^.+\\.tsx?$': [
      'ts-jest',
      {
        useESM: true,
      },
    ],
  },
}
Enter fullscreen mode Exit fullscreen mode

But wait a minute! You have import aliases in your tsconfig now (being able to import something with a @utils instead of ../../../utils), so you have to add them here as well, and remember to update them in both places if you ever have to...

// jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  // [...]
  extensionsToTreatAsEsm: ['.ts'],
  moduleNameMapper: {
    '^(\\.{1,2}/.*)\\.js$': '$1',
    '^@app/(.*)$': '<rootDir>/src/$1',
    '^@utils/(.*)$': '<rootDir>/common/$1',
  },
  transform: {
    // '^.+\\.[tj]sx?$' to process ts,js,tsx,jsx with `ts-jest`
    // '^.+\\.m?[tj]sx?$' to process ts,js,tsx,jsx,mts,mjs,mtsx,mjsx with `ts-jest`
    '^.+\\.tsx?$': [
      'ts-jest',
      {
        useESM: true,
      },
    ],
  },
}
Enter fullscreen mode Exit fullscreen mode

All of this just to get a simple expect(1+1).toBe(true) to work... Seems like a lot!

Here's what scared me the most: looking at the ts-jest docs, I found this image explaining the process flow of ts-jest. Talk about overengineering...

Image description

There has to be an easier way to do this, right?

A fresh alternative. No dependency testing

Since Node.js v20, there has been (finally) a native test runner. This means, no dependencies and no complexity just to run tests! Let's see how that looks like.

First, we add this test script to your package.json:

// package.json
{
   ...
  "scripts": {
    "test": "node --test '**/*.test.ts'"
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

Then, we create a file that ends with .test.ts like so:

//example.test.ts
import { describe, it } from "node:test";
import assert from "assert";

describe("Always passing test", () => {
  it("should always pass", () => {
    assert.strictEqual(true, true);
  });
});

Enter fullscreen mode Exit fullscreen mode

So, does that work now? Can we simply npm run test? Really? Well, almost. Unfortunately, Node, in comparison to other runtimes like Bun or Deno still is JavaScript only, so we have to transpile from TypeScript it to JavaScript first.

Luckily, this is a very common thing and Tsx, a can help us with this. We simply have to import this module and we'll be able to execute Typescript code with Node.js:

// package.json
{
   ...
  "scripts": {
    "test": "node --import tsx --test '**/*.test.ts'"
  },
  "devDependencies": {
    ...
    "tsx": "^4.16.2",
  }
}
Enter fullscreen mode Exit fullscreen mode

And that's it 🎉 We've succesfully tested Typescript code with ESM modules without any hastle or complex workflow, by simply using the native Node.js test suite!

Image description

Next.js API routes

Well, how does that play out into a big metaframework like Next.js?

Let's imagine we have a simple endpoint to get all the data from a user by their session:

// app/api/profile/route.ts
export async function GET(req: Request) {
  const session = await getSession(req);
  if (!session) {
    return new Response("Unauthorized", {
      status: 401,
    });
  }

  const posts = getPostsByUserId(session.userId);
  return Response.json(posts);
}
Enter fullscreen mode Exit fullscreen mode

Okay, let's test for the two type of responses that the route can give (401 unauthorized or 200 OK). How can we go about it?

Fortunately there's this amazing article that explains it in detail. Props to David Mytton for it! It has been the inspiration of writing this article.

In his article, it comes out to explain this amazing library called NTARH that provides an accesible way to test api routes in Next.js.

In our example, using this library, it would look like so:

// app/api/profile/route.test.ts
import { testApiHandler } from "next-test-api-route-handler";
import test, { describe } from "node:test";
import * as appHandler from "./route";
import assert from "assert";

describe("GET /api/profile", () => {
  test("should return 401", async () => {
    await testApiHandler({
      appHandler, // We provide our route handler here
      test: async ({ fetch }) => {
        const response = await fetch(); // We call the endpoint without any session
        assert.equal(response.status, 401);
      },
    });
  });

  test("should return 200", async () => {
    await testApiHandler({
      appHandler,
      test: async ({ fetch }) => {
        const response = await fetch({
          headers: { Authorization: createTestingSession() }, // Here we do provide the session token
        });
        assert.equal(response.status, 200);
      },
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

This, although requires still a bit of configuration, is nothing compared to previous alternatives. What do you think? How do you test your Next.js API routes?

Notes

  1. The Node.js --test flag in v20 only supports declaring files one by one. If you want to match it to all files ending in test.ts, this only works in node.js v22. (Luckily v22 will become LTS in October!) '

  2. This is the first time I write an article. Let me know what you think. Any constructive criticism is welcome!

Top comments (0)