Next.js Middlewareのテストコードを書く
この記事では、Next.js Middlewareのテストコードを書く方法を紹介します。Middlewareはリクエスト時に最初に処理されるパーツで、認証やリダイレクトなどに使われます。パスやCookieなどに応じて分岐するため十分にテストをしないと意図しないリクエストを受け入れてしまう可能性があります。
今回はユニットテストを使ってMiddlewareの動作確認をしていきます。
まずはテスト対象のMiddleware
以下は高階関数を用いて、2つの処理を結合しているMiddlewareコード例です。
- IPアドレスのアクセス制限
restrictIp
- リダイレクト処理
redirectAccountsPath
ちなみに今回は高階関数で書いていますが、必ずしも高階関数である必要はありません。素朴に書いてもテストコードは書けます。書きやすい方で問題ありません。
import {
type NextFetchEvent,
type NextMiddleware,
type NextRequest,
NextResponse,
} from "next/server";
type MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware;
export const middleware = chain([
restrictIp,
redirectAccountsPath,
]);
// 複数の処理を連結するための関数
function chain(
functions: MiddlewareFactory[],
index = 0,
): NextMiddleware {
const current = functions[index];
if (current) {
const next = chain(functions, index + 1);
return current(next);
}
return () => {
console.log("chain end, return NextResponse.next()");
return NextResponse.next();
};
}
// IPアドレスのアクセス制限
function restrictIp(middleware: NextMiddleware) {
return async (request: NextRequest, event: NextFetchEvent) => {
const { pathname } = new URL(request.url);
if (/^\/restrict-ip(\/|$)/.test(pathname)) {
const ip = request.ip ?? request.headers.get("x-forwarded-for") ?? "";
const ok = "::1";
if (ip !== ok) {
return NextResponse.json("", { status: 403 });
}
}
return middleware(request, event);
};
}
// リダイレクト処理
function redirectAccountsPath(middleware: NextMiddleware) {
return async (request: NextRequest, event: NextFetchEvent) => {
const { pathname } = new URL(request.url);
if (/^\/accounts(\/|$)/.test(pathname)) {
return NextResponse.redirect(
new URL(pathname.replace("/accounts", "/users"), request.url),
);
}
return middleware(request, event);
};
}
今回は以下の3つのケースをテストしていきます。
-
/restrict-ip
に許可されていないIPアドレスでリクエストして制限されるか -
/restrict-ip
に許可されたIPアドレスでリクエストして制限されるか -
/accounts
にリクエストして/users
にリダイレクトされるか
テストコード例
それでは実際にテストコードを書いていきましょう。この記事ではテストツールにBunを利用していますが、Bunである必要はなく、VitestやJestなどお好きなテストツールを使ってもMiddlewareのテストは実行可能です。
まずはテストコードを全文掲載します。
import { describe, expect, test } from "bun:test";
import { middleware } from "@/middleware";
import { FetchEvent } from "next/dist/compiled/@edge-runtime/primitives";
import { type NextFetchEvent, NextRequest } from "next/server";
describe("Restrict IP", () => {
test("Deny IP", async () => {
const request = new NextRequest("http://localhost:3000/restrict-ip");
const response = await middleware(
request,
{} as NextFetchEvent,
);
expect(response?.ok).toBe(false);
expect(response?.status).toBe(403);
});
test("Allow IP", async () => {
const request = new NextRequest("http://localhost:3000/restrict-ip", {
headers: {
"x-forwarded-for": "::1",
},
});
const response = await middleware(
request,
{} as NextFetchEvent,
);
expect(response?.ok).toBe(true);
});
});
describe("Redirect path", () => {
test("/accounts to /users", async () => {
const request = new NextRequest("http://localhost:3000/accounts");
const response = await middleware(
request,
{} as NextFetchEvent,
);
expect(response?.ok).toBe(false);
expect(response?.headers.get("location")).toInclude("/users");
expect(response?.status).toBe(307);
});
});
それではポイントを絞って解説をしていきます。
リクエストデータを生成する
Middlewareは、 NextRequest
オブジェクトを受け取って処理をします。
なので、テストをするには、テスト用のリクエストデータを生成し、Middleware関数に直接渡してあげます。NextRequest
にはURL以外にもリクエストヘッダを付与することができますので、Cookieやリファラルなども必要であれば設定が可能です。
const request = new NextRequest("http://localhost:3000/auth", {
headers: {
cookie: `session=${dummyJwt};`,
},
});
NextFetchEventオブジェクトについて
Middleware関数の第二引数には NextFetchEvent
オブジェクトが必要です。ただし、Middlewareの処理で利用していないなら空のオブジェクトを渡して騙しても問題はありません。多くのケースでは不要でしょう。
もし、重い処理を waitUntil
で非同期実行させている場合のテストでは以下のようにする必要があります。
// 重い処理のMiddleware例
function heavyTask(middleware: NextMiddleware) {
return async (request: NextRequest, event: NextFetchEvent) => {
const task = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("heavyTask done");
};
// ここで非同期に処理をさせている
event.waitUntil(task());
return middleware(request, event);
};
}
上記の処理をテストするには new FetchEvent
でオブジェクトを生成して渡してあげます。本来はNextFetchEvent
オブジェクトにするのが正しいのですが、現時点では NextFetchEvent
はPublicになっていないため互換性のある FetchEvent
で代用しています。
test("Done task", async () => {
const request = new NextRequest("http://localhost:3000/");
// イベントオブジェクトを生成する
const event = new FetchEvent(request);
const response = await middleware(
request,
event as unknown as NextFetchEvent, // 無理やり型を合わせている・・・
);
expect(response?.ok).toBe(true);
await sleep(1100);
// 処理が終わったことをテスト
expect(spy.mock.calls.flat()).toContain("heavyTask done");
});
NextResponseのテスト項目
Middlewareは実行を完了すると NextResponse
オブジェクトを返します。このオブジェクトの状態をテストすることでMiddlewareが意図通りに動いているかを確認できます。
まず、異常系のテストが分かりやすいので、こちらから。確認する項目は、シンプルにHTTPステータスコードや、リダイレクト先URLなどになります。
// レスポンスが失敗していることを確認
expect(response?.ok).toBe(false);
// リダイレクト先がログインページになっていることを確認
expect(response?.headers.get("location")).toInclude("/api/auth/login");
// HTTPステータスが307であることを確認
expect(response?.status).toBe(307);
一方、正常系のテストをする際には注意が必要です。例えば /foo
というパスにリクエストして、Middleware上は正常な処理だった場合に以下のようにテストコードを書きたくなります。
expect(response?.url).toBe("/foo");
しかしこのテストは response.url
は "/"
とトップページのパスを返してくるため失敗してしまいます。/foo
へのリクエストにも関わらずレスポンスは異なるパスになっているということは、Middlewareの処理で誤ってリダイレクトしてしまったのか?と思うかもしれませんが、そうではありません。
// Middlewareが正常に処理されると、URLは常に "/" になる
expect(response?.url).toBe("/");
少しややこしいのですが、Middlewareではリクエスト情報(パスなど)を含んだレスポンスデータを返すことはありません。基本、正常系のリクエストでMiddlewareが返すオブジェクトは NextResponse.next()
で生成したレスポンスです。
この NextResponse.next()
は中身は何にもない空っぽの初期化されただけのレスポンスオブジェクトです。Middlewareは何をレスポンスするかは関与しません。それは当然で、Middleware時点ではまだレスポンスする処理(LayoutやPageなど)を開始していないからです。
Middlewareの役割はあくまでリクエスト情報に基づいて、そのままレスポンスすべきか、レスポンスヘッダーを改変するかのみになります。
つまり、Middlewareの正常系テストをする場合には、URLではなくステータスのみをテストすればOKです。
// OKはHTTPステータスが200台の時 true になる
expect(response?.ok).toBe(true);
あとは必要に応じてレスポンスヘッダーの確認をすればOKです。
// Cookieがセットされているかの確認
expect(response?.headers.get("set-cookie")).toInclude("key=value; Path=/");
// CacheControlが設定されているかの確認
expect(response?.headers.get("cache-control")).toBe("s-maxage=86400, stale-while-revalidate");
このようにMiddlewareといえど、普通の関数と代わりはないのでポイント押さえれば他と同じようにユニットテストを書くことができます。
Discussion