MM

Test-driven development of NextJS API routes

NextJS’s conventions are not TDD-friendly, but with some work and a little structure we can enjoy a smooth red-green ride.

Let’s say we need a NextJS API route that gives us dictionary definitions. The route handler should validate the request, query a 3rd-party dictionary API, extract desired data from the response, and relay it to the front end.

We want a fast feedback loop and low chances of future regressions, so we decide to develop it test-first.

0/4 Prep

Install the node-mocks-http package.

We’ll assume a working jest setup. Start it in watch mode with:

npx jest --watch
# or
pnpm jest --watch

1/4 Basic test

Create the simplest possible test in src/server/api/definitions.test.ts:

import { NextApiRequest, NextApiResponse } from "next";
import {
  createMocks,
  createRequest,
  createResponse,
} from "node-mocks-http";
import { createHandler } from "./definitions";
type ApiRequest = NextApiRequest &
  ReturnType<typeof createRequest>;
type ApiResponse = NextApiResponse &
  ReturnType<typeof createResponse>;

describe("/api/definitions", () => {
  const getDeps = () => {
    return {
      fetch: jest.fn(),
      env: {
        DICTIONARY_API_KEY: "abcdef123456",
      },
    };
  };

  test("responds with error if no query was provided", async () => {
    const deps = getDeps();
    const { req, res } = createMocks<
      ApiRequest,
      ApiResponse
    >({
      method: "GET",
      url: "/api/definitions",
    });

    const handler = createHandler(deps);
    await handler(req, res);

    expect(res.statusCode).toBe(400);
  });
});

Things to note

  1. node-mocks-http allows to execute handlers in the test environment without having to launch and control a server as part of the test. Running a server would increase accuracy (we have no assurance that the mocking library 100% matches the server’s request processing) but decrease simplicity (we’d need to tightly manage the server lifecycle, ports, and state to ensure tests stay self-contained), control (harder to test for error conditions), and speed (the overhead of launching servers might slow down large test suites). We especially want test to run fast so that our attention doesn’t have a chance to drop, se we accept the trade off in favour of speed and use a mocking library.

  2. Instead of expecting ./definitions to export a handler function, we expect it to export a handler factory:

import { createHandler } from "./definitions";

We prefer a factory to a function because it allows configuration to be provided at the call site instead of being statically defined in the ./definitions module. This lets us configure the handler depending on the environment (dev/test/prod) by just passing a parameter object instead instead of doing jest.mock acrobatics.

Also note that we begin to state expectations long before the first call to test().

  1. The configuration we pass to the handler factory consists of external dependencies:
const getDeps = () => {
  return {
    fetch: jest.fn(),
    env: {
      DICTIONARY_API_KEY: "abcdef123456",
    },
  };
};

External can be thought of as “anything the function doesn’t know by looking at the arguments”.

To justify why we pass env and even fetch as external dependencies, let’s look at the handler function signature:

export declare type NextApiHandler<T = any> = (
  req: NextApiRequest,
  res: NextApiResponse<T>,
) => unknown | Promise<unknown>;
  • Can the handler know the content of process.env by just looking at req or res? No → pass env as part of the configuration.
  • Can the handler know the content of 3rd-party APIs by just looking at req or res? No → pass a fetch implementation to the factory as configuration.

If the 3rd-party dictionary service provided an SDK (or if we were willing to write a minimal one ourselves), the above could become:

const getDeps = () => {
  return {
    dictionaryClient: { lookup: jest.fn() },
  };
};
  1. We don’t place files in the usual src/pages/api folder so we don’t need to worry about Next misinterpreting test files as handlers, nor we need to modify next.config.js to prevent such misinterpretation.

(Eventually we’ll place just a minimal file there to configure the handler for dev/prod and activate the route.)

2/4 Basic implementation

Write the simplest implementation of src/server/api/definitions.ts capable of making the test pass:

import crossFetch from "cross-fetch";
import { NextApiHandler } from "next";

export const createHandler =
  ({
    fetch,
    env,
  }: {
    fetch: typeof crossFetch;
    env: {
      DICTIONARY_API_KEY: string;
    };
  }): NextApiHandler =>
  async (req, res) => {
    if (!("q" in req.query)) {
      res.status(400).end();
      return;
    }
  };

3/4 Iterate

From here on it’s the usual TDD flow:

  1. write another test;
  2. write the code that makes it pass;
  3. occasionally stop and improve other qualities of the code (understandability, performance, …) while using tests to confirm that nothing broke.

For example, work on the invocaiton of the 3rd-party API, we could start by writing the following test:

test("invokes the dictionary API", async () => {
  // Setup
  const deps = getDeps();
  deps.fetch.mockResolvedValue({
    async json() {
      return {
        word: "cat",
        definition: "A mammal popular in memes",
        usage: { queriesLeft: 100 },
      };
    },
  });
  const { req, res } = createMocks<
    ApiRequest,
    ApiResponse
  >({
    method: "GET",
    url: "/api/definitions",
    query: { q: "cat" },
  });
  const handler = createHandler(deps);

  // Execution
  await handler(req, res);

  // Assertion
  expect(res.statusCode).toBe(200);
  expect(res._getJSONData()).toEqual({
    definition: "A mammal popular in memes",
  });
  expect(deps.fetch).toHaveBeenCalledWith(
    "https://dict.example.com/api",
    {
      headers: { "x-api-key": "abcdef123456" },
    },
  );
});

And the following implementation:

import crossFetch from "cross-fetch";
import { NextApiHandler } from "next";

export const createHandler =
  ({
    fetch,
    env,
  }: {
    fetch: typeof crossFetch;
    env: {
      DICTIONARY_API_KEY: string;
    };
  }): NextApiHandler =>
  async (req, res) => {
    if (!("q" in req.query)) {
      res.status(400).end();
      return;
    }

    const dictRes = await fetch(
      "https://dict.example.com/api",
      {
        headers: {
          "x-api-key": env.DICTIONARY_API_KEY,
        },
      },
    );
    const data = await dictRes.json();

    res.json({ definition: data.definition });
  };

4/4 Make the handler available to Next

Create src/pages/api/definitions.ts:

import crossFetch from "cross-fetch";
import { createHandler } from "src/server/api/definitions";

const handler: NextApiHandler = async (req, res) => {
  const { DICTIONARY_API_KEY } = process.env;

  return createHandler({
    fetch: crossFetch,
    env: { DICTIONARY_API_KEY },
  })(req, res);
};

export default handler;

Things to note

  1. We configure the handler for production and development environments.
  2. We could have written a shorthand export default (req, res) => { return createHandler... } but TypeScript doesn’t (at least at the time of writing) allow typing default exports, so we go for the extra variable.
  3. There’s no real need to make the handler async here, but, in some cases, a dependency of the handler might have to be initialized asynchronously (e.g. const db = async createDbConnection()).

5/4 What next

If you’d like me to cover what’s left for a production-grade API route handler (validation, authorization, error handling, …), let me know via mail or twitter.

If you’d like to read more about the why’s of TDD, check out Kent Becks’ classic Aim, Fire or some of my own notes.