Testing with Next.js
先日、Next.js におけるテスト手法について、公式ドキュメントが追加され話題になりました。
取り上げられている 2 者はよく知られており、いずれかに触れたことがある方も多いかと思います。この公式ドキュメントページでは「何を使って」を紹介しているのみなので、どちらを選ぶべきか悩んだ方もいるのではないでしょうか?
- Cypress
- Jest & React Testing Library
この判断についてはドキュメントに書かれていなかったので、筆者なりの見解を紹介していきたいと思います。
お勧めは「Jest & React Testing Library に寄せる」
Cypress は GUI が素晴らしく、テストを書く環境としてはとても体験が良いです。しかしテストが増えていくにつれ、以下のような点で DX 低下を招くことがあります。
- CI の実行コストが高く、実行時間が長い
-
cypress open
では成功していたテストがcypress run
では失敗する -
next dev
では成功していたテストがnext start
では失敗する
いわゆる「Flaky Test」になりがちで、Cypress バージョンを上げただけでテストが通らなくなった、ということも筆者は経験しました(not beaking change 対応漏れ)Flaky ではなかったとしても、実行コスト・実行時間に関してはどうしようもないので「なるべく Jest & React Testing Library に寄せた方が良い」と考えています。
「寄せる」という言葉のとおり、どちらでも担保可能なテストケースは多いです。「Jest & React Testing Library だけでもここまで担保可能」という例を見ていきましょう。
Jest & React Testing Library による結合テスト
Testing Library を利用するとレンダリング結果をテスト可能とするだけでなく、Component 同士の結合テストも可能になります。ブラウザ E2E と同じように、クリックしたり input 要素に入力したり、というユーザー操作をエミュレートすることができます。
Component 内部の結合テスト
次の例では、Form のバリデーションエラーが表示されることをテストしています。この様に、フロントエンド側がバリデーションロジックを抱えている場合などには、Component 仕様を明文化する目的としても役立ちます。
import { render, waitFor } from "@testing-library/react";
import { BirthdayInput } from "@/components/molecules/BirthdayInput";
describe("molecules/BirthdayInput", () => {
describe("不正な値を入力し、送信を試みた時", () => {
beforeEach(() => {
const { findByPlaceholderText, getByRole } = render(<BirthdayInput />);
const input = await waitFor(() => findByPlaceholderText("誕生日"));
const button = await waitFor(() => getByRole("button"));
fireEvent.change(input, { target: { value: "abcde" } });
fireEvent.click(button);
});
test("エラー文言が表示される", async () => {
await waitFor(() =>
expect(screen.getByRole("入力形式が不正です")).toBeInTheDocument()
);
});
});
});
Component 外部との結合テスト
Context に依存した機能も、Jest & React Testing Library で担保できます。MSW などでモック API レスポンスを用意しておけば、SWR や React Query などを含んだ、Render as you Fetch な Component のテストも可能です(MSW に関してはこちらで解説しています)。
プロダクションコードとは「別の」Provider ファクトリ関数(下記withProvider
関数)をテスト向けに用意し、Context に保持している状態初期値を注入しテストします。Context に初期値を注入する構造はこちらの記事で解説しているとおりで、記事内にある様な GlobalUI の結合テストも、ここで担保することが出来ます。
import { render, waitFor } from "@testing-library/react";
import { MyProvider } from "@/components/providers/MyProvider";
import { MyTemplate } from "@/components/templates/MyTemplate";
import { SWRConfig } from "swr";
describe("templates/MyTemplate", () => {
const withProvider = (children: React.ReactNode, user: User) =>
render(
<SWRConfig value={{ dedupingInterval: 0 }}>
<MyProvider user={user}>{children}</MyProvider>
</SWRConfig>
);
test("MyProvider の値が注入されている", async () => {
const { getByText } = withProvider(<MyTemplate />, { name: "takepepe" });
await waitFor(() =>
expect(getByText("Hello takepepe!")).toBeInTheDocument()
);
});
});
Jest & React Testing Library で担保出来ないテストケース
Jest & React Testing Library を併用していれば、Cypress で担保するテストケースは絞られてきます。以下は、API レスポンス status に応じて、ページタイトルを出し分ける Next.js のページ例です。ここで注目する点は next/head によるタイトルの動的書き換えです(API レスポンスのインターセプトは Jest & React Testing Library でも可能)
describe("templates/MyTemplate", () => {
const page = "/users/takepepe";
describe("正常レスポンスの場合", () => {
describe("ページに遷移すると", () => {
beforeEach(() => {
cy.visit(page);
});
it("ユーザー名が含まれたタイトルになる", () => {
cy.title().should("eq", "takepepe の紹介ページ");
});
});
});
describe("ユーザーが退会している場合", () => {
beforeEach(() => {
cy.intercept("GET", "/api/users/*", { statusCode: 410 });
});
describe("ページに遷移すると", () => {
beforeEach(() => {
cy.visit(page);
});
it("退会ユーザー画面のタイトルになる", () => {
cy.title().should("eq", "このユーザーは退会しています");
});
});
});
});
他にも Cypress でしか担保出来ないテストケースは、複数画面を横断する機能、ブラウザ API に依存する機能などです。
- 特定リンクからの遷移で起こる特異なケース
- 画面スクロールや window に生えている API を使うケース
- ファイルダウンロードを行うケース
Next.js 公式ドキュメントにも「推奨」として書かれていますが、Cypress の実行対象はnext dev
で立ち上げた開発サーバーではなく、next build && next start
で立ち上げたプロダクションビルドに近いアプリを対象にしましょう。
Discussion