🎪

Testing Next.js - getServerSideProps & API Routes -

2022/05/11に公開

Next.js の getServerSideProps & API Routes テスト手法についてまとめました。getServerSidePorps & API Routes に関するテストは「Cypress・Playwright」を利用することが多いと思いますが、本稿は Jest 単体テストの紹介です。以下はテストに使用するエコシステム一式です(Jest 等は略)

1.pageExtensions を設定する

本題に入る前に、pageExtensionsnext.config.jsに設定します。pageExtensions はpagesに含まれるファイルのうち、指定の拡張子をもつファイルが「Page・API 実装ファイルである」ことを指定するものです。

module.exports = {
  pageExtensions: ["page.tsx", "api.ts"],
};

この設定で、テストファイルをpagesに配備することが可能になります。実装ファイルのそばにテストファイルを置くことで、テストが書かれているかが一目瞭然になります。それぞれ同名の test.tsxtest.ts が対応するテストファイルです。

機能 実装ファイル テストファイル
getServerSideProps pages/example.page.tsx pages/example.test.tsx
API Routes pages/api/example.api.ts pages/api/example.test.ts

2.MSW Handler factory 関数

モックには MSW を利用します。ハンドラー関数は一度セットしてお終いではなく、テストケースによってレスポンスを変更したい事が多いです。そこで、以下の様な「ハンドラー関数を作るファクトリー関数」を用意しておくと、テストケースごとに API 詳細を意識する必要がなくなります。例えば以下のファクトリー関数は、200 | 400 のいずれかのレスポンスを返すハンドラーを作れます。

export const createHandler = (status: 200 | 400 = 200) =>
  rest.post<Data, { id: string }, Data | Err>(path(), (req, res, ctx) => {
    if (status === 400 || !req.body.title)
      return res(
        ctx.status(400),
        ctx.json({ message: "Bad Request", status: 400 })
      );
    return res(ctx.json(req.body));
  });

ハンドラーをインターセプトする際はserver.useを使用しますが、簡潔になることが見てとれます。ここでは単純なファクトリー関数としていますが、I/O はいくらでも工夫の余地があります。

test("400", async () => {
  // Intercept mock Error
  server.use(createHandler(400));
  // some test case
});

余談ですが、このハンドラーファクトリー関数をデータ取得関数(fetcher)とセットで定義しておくと、スコープを特定しやすく・資材をまとめやすくなります。

src/fetcher
├── posts
│   ├── create
│   │   ├── index.ts
│   │   └── mock.ts
│   ├── delete
│   │   ├── index.ts
│   │   └── mock.ts
│   ├── list
│   │   ├── index.ts
│   │   └── mock.ts
│   ├── show
│   │   ├── index.ts
│   │   └── mock.ts
│   └── update
│       ├── index.ts
│       └── mock.ts
└── type.ts

mock.tsにハンドラーファクトリー関数が含まれます。関連する型定義などを同包しても良いでしょう。aspida など、データ取得 client を生成している場合も同様です。

3.getServerSideProps のテスト手法

いよいよテストケースの作成です。プロジェクトで定義している MSW ハンドラーをセット、これがデフォルトの API モックとなります。

const server = setupMockServer(...handlers);

正常系の表示テスト

getServerSideProps関数内部に実装されている一連のデータ取得は、デフォルトの API モックレスポンスを得た状態になります。その結果を、<Page />コンポーネントに展開し、期待する正常系コンポーネント表示に至ったかを検証します。

test("If the data acquisition is successful, the title will be displayed.", async () => {
  const res = await getServerSideProps(gsspCtx());
  assertHasProps(res);
  render(<Page {...res.props} />);
  expect(screen.getByText("Posts")).toBeInTheDocument();
});

異常系の表示テスト

正常系のデフォルト API モックをserver.useで異常系に上書きします。以下の例では、データ取得に失敗(500 エラー)するものとしています。期待する異常系コンポーネント表示に至ったかを検証します。

test("If data acquisition fails, an error will be displayed", async () => {
  server.use(postListHandler(500)); // Intercept mock Error
  const res = await getServerSideProps(gsspCtx());
  assertHasProps(res);
  render(<Page {...res.props} />);
  expect(screen.getByText("Internal Server Error")).toBeInTheDocument();
});

Dynamic route のテスト

以下のようにgsspCtx({ query: { id: "lorem-ipsum" } })とすることで、getServerSideProps関数が参照する query param や path param を模すことができます。パラメーターに応じてコンポーネントを出し分けるページでは、パラメーター名称変更に伴うリグレッションを防げるでしょう。

test("If the data acquisition is successful, the title will be displayed.", async () => {
  const res = await getServerSideProps(
    gsspCtx({ query: { id: "lorem-ipsum" } })
  );
  assertHasProps(res);
  render(<Page {...res.props} />);
  expect(screen.getByText("Post: Lorem ipsum")).toBeInTheDocument();
});

gsspCtx 関数内訳

先の例で使用していたgsspCtx()関数は、getServerSideProps関数の引数ctx作成関数です。node-mocks-httpcreateRequestcreateResponseを適用しつつ、query など値を注入できるようにしていました。

export const gsspCtx = (
  ctx?: Partial<GetServerSidePropsContext>
): GetServerSidePropsContext => ({
  req: createRequest(),
  res: createResponse(),
  params: undefined,
  query: {},
  resolvedUrl: "",
  ...ctx,
});

assertHasProps 関数内訳

res をアサートしてるのはgetServerSideProps関数戻り値にpropsが含まれないことがあるからです。Assertion Functions として定義したassertHasProps関数を通過すると「props が存在する」と判定されます。

class AssertionError extends Error {}

export function assertHasProps<T>(
  res: GetServerSidePropsResult<T>
): asserts res is { props: T } {
  const hasProps =
    typeof res === "object" &&
    (res as any)["props"] &&
    typeof (res as any).props === "object";
  if (!hasProps) throw new AssertionError("no props");
}

4.API Routes のテスト手法

API Routes のテストは next-test-api-route-handler を使うと簡単に行えます。testApiHandler関数のパラメータに、API Routes 定義であるhandlerを渡します。(MSW のハンドラーではありません)特に凝ったことはしていないので、詳細は next-test-api-route-handler の README をご参考ください。

test("201", async () => {
  await testApiHandler({
    handler,
    url: "/api/posts",
    test: async ({ fetch }) => {
      const res = await fetch(requestInit);
      await expect(res.json()).resolves.toStrictEqual(body);
    },
  });
});

MSW ハンドラーをインターセプトする要領はgetServerSideProps関数と同じです。server.use(postCreateHandler(400));で 400 レスポンスを再現します。

test("400", async () => {
  // Intercept mock Error
  server.use(postCreateHandler(400));
  await testApiHandler({
    handler,
    url: "/api/posts",
    test: async ({ fetch }) => {
      const res = await fetch(requestInit);
      await expect(res.json()).resolves.toStrictEqual({
        message: "Bad Request",
      });
    },
  });
});

まとめ

Cypress・Playwright のテストケース全てを代替するとまではいきませんが、多くのケースがカバー出来るのではないでしょうか。本稿サンプルコードは以下に公開しています。

https://github.com/takefumi-yoshii/testing-nextjs-gssp

Discussion