Next.js 13 結合テストに挑戦してみた
Next.js 13 新機能の App ディレクトリの勉強がてら、結合テストをどう書いていったらよいか考察しました。参照している公式ドキュメントが beta 版なのはもちろん、App ディレクトリそのものが beta 版なので、実運用には使えるものではないと思うので予めご了承ください。一応こんな感じで書けそう、という話です。
結合テストの準備
本稿が指す結合テストとは、App ディレクトリのルートセグメントを構成する、特別なファイル(Special Files)が、与えた状態に応じてどのように表示されるかを検証するテストを指します。
ルートが外部から受ける要因として大きいものが、URL に含まれるクエリパラメーター・パスパラメーターです。コンポーネントは、これらの値を参照して API サーバーにリクエストしたり、ORM ライブラリからクエリーを発行したりなど、コンポーネント表示に必要な処理を実行します。本稿で紹介する結合テストは、その一連の入力から出力までの範囲が観点です。
冒頭に貼ったリポジトリにコミットしたapp/test
のルートを構成するファイル群の結合テストを見ていきます。まず次のように、ディレクトリに設置しているファイルを import し、files
オブジェクトにまとめます。
import Error from "./error";
import Layout from "./layout";
import NotFound from "./not-found";
import Page from "./page";
const files = { Layout, Page, NotFound, Error };
このファイル群をrenderRoute
関数を実行してテスト時にレンダーする、というのが大筋の概要です。beta 版ドキュメントに書かれている内容から筆者が書き起こした、テスト向けの再現関数です。
await renderRoute({ ...files, searchParams, params });
引数内訳は次のとおりで、ルート URL を参照する値(searchParams, params)はテスト毎に手動で与えるものとします。
- 構成ファイル群
- searchParams: クエリパラメーター
- params: パスパラメーター
renderRoute 関数内訳
import { notFound } from "next/navigation";
import { render } from "@testing-library/react";
import { NOT_FOUND_ERROR_CODE } from "next/dist/client/components/not-found";
import { ReactNode } from "react";
export async function renderRoute<T, K>({
params,
searchParams,
Page,
Layout,
Error: ErrorPage,
Loading,
NotFound,
}: {
params?: T;
searchParams?: K;
Page: (props: {
params: T;
searchParams: K;
}) => Promise<React.ReactElement | void> | React.ReactElement;
Layout?: (props: {
params: T;
children: ReactNode;
}) => Promise<React.ReactElement> | React.ReactElement;
Loading?: () => React.ReactElement;
NotFound?: () => React.ReactElement;
Error?: (props: { error: Error; reset: () => void }) => React.ReactElement;
}) {
const resetError = jest.fn();
const { rerender, ...renderResult } = render(Loading ? <Loading /> : <></>);
const result = { ...renderResult, resetError, rerender };
try {
const Element = await Page({
params: params || ({} as T),
searchParams: searchParams || ({} as K),
});
if (!Element) throw notFound();
if (Layout) {
rerender(
await Layout({ params: params || ({} as T), children: Element })
);
} else {
rerender(Element);
}
return result;
} catch (err) {
if (err instanceof Error && ErrorPage) {
if (err.message === NOT_FOUND_ERROR_CODE && NotFound) {
rerender(<NotFound />);
} else {
rerender(<ErrorPage error={err} reset={resetError} />);
}
}
return result;
}
}
構成ファイルに含まれる、要素表示のテスト
次のように構成ファイル群を使用しrenderRoute
を実行します。はじめに、Layout コンポーネントの要素テスト対象要素(1)
と、Page コンポーネントに含まれる要素テスト対象要素(2)
がレンダーされていることを確認します。
test("When data fetch succeed, All contents will be display", async () => {
await renderRoute({ ...files });
// Layout に含まれる要素 / テスト対象要素(1)
expect(screen.getByRole("heading", { name: /Test/i })).toBeInTheDocument();
// Page に含まれる要素 / テスト対象要素(2)
expect(screen.getByRole("link", { name: "down" })).toBeInTheDocument();
});
export default function Layout({ children }: { children: ReactNode }) {
return (
<div className={styles.module}>
<h2>Test</h2> {/* テスト対象要素(1) */}
{children}
<Link href={`/`}>/</Link>
</div>
);
}
export default withZod(
{ searchParams: { greet: z.string().optional() } },
async ({ searchParams: { greet } }) => {
const { message } = await getMessage({ greet });
if (!message) throw notFound();
return (
<div className={styles.module}>
<p>{message}</p>
{greet && <p>{greet}</p>} {/* ?greet= があれば表示 */}
<Link href="/test/1">down</Link> {/* テスト対象要素(2) */}
</div>
);
}
);
動的パラメーターによる、表示分岐のテスト
次に、Query パラメーターが反映されることを検証するテストを書きます。Query パラメーターは Page コンポーネントの props searchParams
で参照できるので、?greet=Hi
というリクエストを次のテストで再現します。
test("When access with ?greet=, value will be display", async () => {
const searchParams = { greet: "Hi" };
await renderRoute({ ...files, searchParams });
// query パラメータ ?greet= に値がある場合、表示される
expect(screen.getByText(searchParams.greet)).toBeInTheDocument();
});
同じように、app/test/[id]/page.tsx
のテストで動的ルートの[id]
を参照するテストを書きます。[]
から参照するパスパラメーターは Page コンポーネントの propsparams
で参照できるので、リクエストを次のテストで再現します。
test("When data fetch succeed, all contents is displayed", async () => {
const params = { id: "123" };
await renderRoute({ ...files, params });
expect(screen.getByRole("heading", { name: "Test: 123" })).toBeInTheDocument();
});
データ取得失敗時の、エラー表示のテスト
app
ディレクトリで構成された Next.js 13 App では、データ取得でエラーが発生したとき、ルートに配置したerror.tsx
やnot-found.tsx
が表示されます。この状態を再現するため、テストに MSW を使用していきます。テストファイル冒頭で、MSW サーバーを初期化します。
import { setupMockServer } from "@/utils/jest";
import { mockGetMessage } from "./.api/getMessage/mock";
import { mockGetTime } from "./.api/getTime/mock";
const server = setupMockServer(mockGetMessage(), mockGetTime());
server.use
で MSW ハンドラーを上書きしながら、データ取得失敗時のテストを書きます。fetch
を実行する関数では、500 ステータスのとき Error を throw するように実装しています。次のテストでは、getMessage
関数のレスポンスステータスが 500 になるよう MSW ハンドラー設定しています。files
に含まれる Error コンポーネントが表示されていることが分かります。
test("When data fetch failed, An error message will be display", async () => {
server.use(mockGetMessage({ status: 500 }));
await renderRoute({ ...files });
expect(screen.getByText("message: 500")).toBeInTheDocument();
});
対象の Error コンポーネントは beta doc のものを使用しています。
"use client";
type Props = {
error: Error;
reset: () => void;
};
export default function ErrorComponent({ error, reset }: Props) {
return (
<div>
<p>message: {error.message}</p>
<button onClick={() => reset()}>Reset error boundary</button>
</div>
);
}
配列レスポンスが空のときなど、Not Found 表示を使用するケースのテストです。"next/navigation"
から import できるnotFound
を使用すると、new Error(NOT_FOUND_ERROR_CODE)
というエラーが throw されます。このエラーが throw されたとき、not-found.tsx
が表示されます。
test("When response message is empty, NotFound will be display", async () => {
const mock = jest.fn();
server.use(mockGetMessage({ status: 200, stub: { message: "" }, mock }));
await renderRoute({ ...files });
expect(mock).toHaveBeenCalled();
expect(screen.getByText("Not Found")).toBeInTheDocument();
});
対象の not-found コンポーネントは beta doc のとおり、単純なものを使用しています。
export default function NotFound() {
return <p>Not Found</p>;
}
データ取得完了前、Loading 表示のテスト
renderRoute
関数を実行した際、内部ではLoading
コンポーネントの有無にかかわらず、testing-library
のrender
関数を即座に実行しています。このようにすることで、与えたPage
コンポーネントやLayout
コンポーネントの Promise が解決するまでの間、Loading
コンポーネントが指定していれば、Loading 表示がされていることを確認できます。コツはrenderRoute
の promise が解決する前にアサーションを書くことです。
test("When data fetch succeed, all contents is displayed", async () => {
const promise = renderRoute({ ...files, params });
expect(screen.getByText("Loading...")).toBeInTheDocument();
await promise;
expect(screen.getByRole("heading", { name: "Test: 123" })).toBeInTheDocument();
});
まとめ
ルートを構成する特別なファイルには、その他にTemplate
やHead
がありますが、まだ beta 版ドキュメントにものっていないため、一旦保留としています。また、'next/navigation'
に含まれるuseSearchParams
を使用するコンポーネントを現在考慮していません。useSearchParams
戻り値の mockImplementation をrenderRoute
関数の中で実行できれば、都合が良さそうに思っています。Testing Library 公式や Next.js 公式のテスト手法が公開されるのが待ちきれないですね。
Discussion