🏕️

Next.js 13 結合テストに挑戦してみた

2022/10/28に公開

Next.js 13 新機能の App ディレクトリの勉強がてら、結合テストをどう書いていったらよいか考察しました。参照している公式ドキュメントが beta 版なのはもちろん、App ディレクトリそのものが beta 版なので、実運用には使えるものではないと思うので予めご了承ください。一応こんな感じで書けそう、という話です。

https://github.com/takefumi-yoshii/nextjs-sandbox-2022

結合テストの準備

本稿が指す結合テストとは、App ディレクトリのルートセグメントを構成する、特別なファイル(Special Files)が、与えた状態に応じてどのように表示されるかを検証するテストを指します。

https://beta.nextjs.org/docs/routing/fundamentals#special-files

ルートが外部から受ける要因として大きいものが、URL に含まれるクエリパラメーター・パスパラメーターです。コンポーネントは、これらの値を参照して API サーバーにリクエストしたり、ORM ライブラリからクエリーを発行したりなど、コンポーネント表示に必要な処理を実行します。本稿で紹介する結合テストは、その一連の入力から出力までの範囲が観点です。

冒頭に貼ったリポジトリにコミットしたapp/testのルートを構成するファイル群の結合テストを見ていきます。まず次のように、ディレクトリに設置しているファイルを import し、filesオブジェクトにまとめます。

app/test/page.test.tsx
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)がレンダーされていることを確認します。

app/test/page.test.tsx
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();
});
app/test/layout.tsx
export default function Layout({ children }: { children: ReactNode }) {
  return (
    <div className={styles.module}>
      <h2>Test</h2> {/* テスト対象要素(1) */}
      {children}
      <Link href={`/`}>/</Link>
    </div>
  );
}
app/test/page.tsx
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というリクエストを次のテストで再現します。

app/test/page.test.tsx
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で参照できるので、リクエストを次のテストで再現します。

app/test/[id]/page.test.tsx
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.tsxnot-found.tsxが表示されます。この状態を再現するため、テストに MSW を使用していきます。テストファイル冒頭で、MSW サーバーを初期化します。

app/test/page.test.tsx
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 コンポーネントが表示されていることが分かります。

app/test/page.test.tsx
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 のものを使用しています。

app/test/[id]/error.tsx
"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が表示されます。

app/test/page.test.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 のとおり、単純なものを使用しています。
https://beta.nextjs.org/docs/api-reference/file-conventions/not-found

app/test/[id]/not-found.tsx
export default function NotFound() {
  return <p>Not Found</p>;
}

データ取得完了前、Loading 表示のテスト

renderRoute関数を実行した際、内部ではLoadingコンポーネントの有無にかかわらず、testing-libraryrender関数を即座に実行しています。このようにすることで、与えたPageコンポーネントやLayoutコンポーネントの Promise が解決するまでの間、Loadingコンポーネントが指定していれば、Loading 表示がされていることを確認できます。コツはrenderRouteの promise が解決する前にアサーションを書くことです。

app/test/[id]/page.test.tsx
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();
});

まとめ

ルートを構成する特別なファイルには、その他にTemplateHeadがありますが、まだ beta 版ドキュメントにものっていないため、一旦保留としています。また、'next/navigation'に含まれるuseSearchParamsを使用するコンポーネントを現在考慮していません。useSearchParams戻り値の mockImplementation をrenderRoute関数の中で実行できれば、都合が良さそうに思っています。Testing Library 公式や Next.js 公式のテスト手法が公開されるのが待ちきれないですね。

Discussion