Testing Next.js - getServerSideProps & API Routes -
Next.js の getServerSideProps & API Routes テスト手法についてまとめました。getServerSidePorps & API Routes に関するテストは「Cypress・Playwright」を利用することが多いと思いますが、本稿は Jest 単体テストの紹介です。以下はテストに使用するエコシステム一式です(Jest 等は略)
1.pageExtensions を設定する
本題に入る前に、pageExtensions を next.config.js
に設定します。pageExtensions はpages
に含まれるファイルのうち、指定の拡張子をもつファイルが「Page・API 実装ファイルである」ことを指定するものです。
module.exports = {
pageExtensions: ["page.tsx", "api.ts"],
};
この設定で、テストファイルをpages
に配備することが可能になります。実装ファイルのそばにテストファイルを置くことで、テストが書かれているかが一目瞭然になります。それぞれ同名の test.tsx
・test.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-httpのcreateRequest
とcreateResponse
を適用しつつ、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 のテストケース全てを代替するとまではいきませんが、多くのケースがカバー出来るのではないでしょうか。本稿サンプルコードは以下に公開しています。
Discussion