How do you test Next.js app router API route handlers? A guide to testing Next.js API routes and mocking next-auth to properly test authenticated routes.
One of the fun things about software security is learning about unbelievably complex and sophisticated attack methodologies. A favorite of mine was the walkthrough by Google’s Project Zero of the Turing complete CPU that was implemented within a fake GIF inside the iOS PDF rendering component to develop a zero click exploit for iMessage. So elaborate!
Whilst interesting to read about, it’s highly unlikely any normal people will ever be affected by these vulnerabilities. In reality, most attacks are drive-by opportunistic attempts to exploit low-hanging fruit - things like default passwords, known issues in outdated software, or simple logic bugs in code.
The most common form of exploit from the OWASP Top 10 2021 is broken access control i.e. the ability to access data or accounts you shouldn’t have access to. These come about through easy-to-miss errors in permissions checking, missing access control for other HTTP methods like POST or DELETE, or accessing accounts owned by other people due to insecure direct object references or session bugs.
These types of security problems are difficult to detect during development, but often come up during penetration testing. However, it should be relatively easy to write tests to verify the intended behavior and avoid regressions as the code evolves.
Although developers primarily use Arcjet through our security SDKs, we also have a web dashboard for reviewing decisions and request analytics. This is built with Next.js and contains both the frontend and the API the frontend calls (our low-latency gRPC API is implemented in Rust, Go & WebAssembly). We need to make sure we're able to test those endpoints.
Unfortunately, Next.js does not make it easy to test API route handlers. The official guides only discuss testing frontend components and when you start digging into testing the API routes you find it’s not straightforward.
Next.js has moved towards using standard Request and Response types from TypeScript 5.2+, but there are still extended NextRequest and NextResponse types, Next.js patches the global fetch function, it uses custom APIs for accessing headers or cookies, has optional segment configurations and offers different runtimes.
Then you need to figure out how to mock your own libraries and start a server to be able to make the requests.Some have reported using node-mocks-http is an option, but I couldn’t get it to work. And what if you want to test for the Edge runtime?
How to test Next.js API route handlers
Thankfully, the next-test-api-route-handler package by Xunnamius solves most of these problems. It is test framework agnostic and allows you to emulate Next.js route handlers using the app router, pages router, and the edge runtime.
The package readme provides multiple examples, but assumes you already have a test harness set up. In our case, we followed the Next.js guide for testing with Jest but set the testEnvironment to node by installing jest-environment-node rather than jsdom.
This resulted in the following base files:
// /jest.config.ts
import type { Config } from "jest";
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: "./",
});
// Add any custom config to be passed to Jest
const config: Config = {
coverageProvider: "v8",
testEnvironment: "node",
moduleNameMapper: {
// Uncomment to provides the Next.js cache function
//react: "next/dist/compiled/react/cjs/react.development.js",
"^@/(.*)$": "<rootDir>/$1",
},
setupFilesAfterEnv: ["./jest.setup.ts"],
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config);
// /app/api/hello/route.test.ts
import { testApiHandler } from "next-test-api-route-handler"; // Must always be first
import type { Session } from "next-auth";
import * as appHandler from "./route";
let mockedSession: Session | null = null;
// This mocks our custom helper function to avoid passing authOptions around
jest.mock("@/lib/auth", () => ({
authOptions: {
adapter: {},
providers: [],
callbacks: {},
},
}));
// This mocks calls to getServerSession
jest.mock("next-auth/next", () => ({
getServerSession: jest.fn(() => Promise.resolve(mockedSession)),
}));
afterEach(() => {
mockedSession = null;
});
it("GET returns 403 unauthorized when not authenticated", async () => {
mockedSession = null;
await testApiHandler({
appHandler,
test: async ({ fetch }) => {
const response = await fetch({ method: "GET" });
const json = await response.json();
expect(response.status).toBe(401);
await expect(json).toStrictEqual({
error: "Unauthorized",
});
},
});
});
it("GET returns 200 when authenticated", async () => {
mockedSession = {
expires: "expires",
user: {
id: "test",
},
};
await testApiHandler({
appHandler,
test: async ({ fetch }) => {
const response = await fetch({ method: "GET" });
const json = await response.json();
expect(response.status).toBe(200);
await expect(json).toStrictEqual({
hello: true,
});
},
});
});
Conclusions
There’s a lot more you can do here, such as mocking database calls, splitting the mocks into separate files and testing authorization (does this user have access to the right data?), but this sets up the basics so you can ensure that your sensitive Next.js API routes are properly tested for authentication.
Nosecone is an open source library to set security headers like Content Security Policy (CSP) and HTTP Strict Transport Security (HSTS) on Next.js, SvelteKit, and other JavaScript frameworks using Bun, Deno, or Node.js. Security headers as code.
Server actions are an elegant way to handle simple functions for common actions like form submissions, but they're a public API so you still need to consider security.