📖

Next.js Middlewareのテストコードを書く

2024/03/28に公開

この記事では、Next.js Middlewareのテストコードを書く方法を紹介します。Middlewareはリクエスト時に最初に処理されるパーツで、認証やリダイレクトなどに使われます。パスやCookieなどに応じて分岐するため十分にテストをしないと意図しないリクエストを受け入れてしまう可能性があります。

今回はユニットテストを使ってMiddlewareの動作確認をしていきます。

まずはテスト対象のMiddleware

以下は高階関数を用いて、2つの処理を結合しているMiddlewareコード例です。

  • IPアドレスのアクセス制限 restrictIp
  • リダイレクト処理 redirectAccountsPath

ちなみに今回は高階関数で書いていますが、必ずしも高階関数である必要はありません。素朴に書いてもテストコードは書けます。書きやすい方で問題ありません。

./src/middleware.ts
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のテストは実行可能です。

まずはテストコードを全文掲載します。

./test/middleware.test.ts
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やリファラルなども必要であれば設定が可能です。

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