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.
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
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);
});
});
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.
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()
.
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>;
process.env
by just looking at req
or res
? No → pass env
as part of the configuration.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() },
};
};
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.)
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;
}
};
From here on it’s the usual TDD flow:
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 });
};
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;
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.const db = async createDbConnection()
).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 Beck’s classic Aim, Fire or some of my own notes.