ガオラボ

スタートアップスタジオGAOGAOの0→1開発情報メディア

Next.jsでスナップショットテストをする

2021-02-25 kourinNext.js

image

こんにちは、 GAOGAO に所属している @kourin と申します。宜しくお願いいたします。 この記事では、Next.js のスナップショットテストについて説明します。

まえがき

Next.jsReact 向けに サーバーサイドレンダリング (SSR) や 静的サイト生成(SSG) を可能にするフレームワークで、React を使ったWebアプリ開発プロジェクトで Next.js を使う機会が増えてきています。

React, Next.js で開発していると、コンポーネントの修正時に意図していない別のページの内容まで変化してしまうことがあります。プロジェクトが大規模化するにつれて、共有コンポーネントが増えていき、コンポーネント修正時に変化する部分を補足することが難しくなります。このような場合にスナップショットテストが有効です。スナップショットテストでは、テスト実行時にコンポーネントを構築し、その中身が前回のテスト時と同じかどうかをチェックします。スナップショットテストを行うことで、コンポーネントを修正した際に、その修正がどのコンポーネントに影響を及ぼしているかを容易に確認することができます。

この記事では、Reactのテストで使われている jestReact Testing Library と、Next.js のページをテストで扱えるようにする next-page-tester というライブラリを組み合わせることで、ページ単位でスナップショットテストを行う方法について解説します。

サンプルコードは https://github.com/Kourin1996/next-snapshot-test-sample にあります。

プロジェクト構築

まず、Next.js のプロジェクトとそのテスト環境を構築します。

Next.js + TypeScriptを導入

npx create-next-app コマンドを使って、Next.js のプロジェクトを新規作成します。

npx create-next-app next-snapshot-test-sample

next-snapshot-test-sample というディレクトリが作成され、その中に Next.js のプロジェクトが用意されます。
next-snapshot-test-sample に移動し、TypeScript 関係のパッケージを追加します。

cd next-snapshot-test-sample
touch tsconfig.json
yarn add -D typescript @types/react @types/node

そして、yarn dev を実行し、ブラウザから http://localhost:3000 にアクセスすると、画像のようなページが表示されます。

yarn dev

image

next-snapshot-test-sample ディレクトリの中には pages ディレクトリがあり、各ページ用のコンポーネントが置いてあります。 src ディレクトリをプロジェクトルート以下に作り、src/pages, src/styles となるように pages, styles ディレクトリを src ディレクトリの中に移動します。

テスト環境導入

次に、テスト環境を導入します。必要なパッケージを追加します。

yarn add -D jest jest-dom @testing-library/react @testing-library/jest-dom @testing-library/dom babel-jest identity-obj-proxy react-test-renderer fetch-mock ts-jest @types/jest @types/react-test-renderer @types/fetch-mock

setupTests.jsnext-snapshot-test-sample 直下に作り、以下を記述します。

import "@testing-library/jest-dom/extend-expect";

テスト環境で React,Next.js のコンポーネントや TypeScript のコードを動かすために、JavaScriptへのトランスパイラである Babel の設定をします。 .babelrcnext-snapshot-test-sample 直下に作り、以下を記述します。

{
  "presets": ["next/babel"]
}

jest.config.jsnext-snapshot-test-sample 直下に作り、以下を記述します。

module.exports = {
  testPathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/"],
  setupFilesAfterEnv: ["<rootDir>/setupTests.js"],
  transform: {
    "^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
  },
  moduleNameMapper: {
    "\\.(css|less)$": "identity-obj-proxy",
  },
};

package.jsonscripts にテスト用のスクリプトを追加します。

{
  ...
  "scripts": {
    ...
    "test": "jest"
  },
}

試しに簡単なテストを実行してみます。
src/tests ディレクトリを作り、その中にhello.test.tsx を作成し、以下を記述します。 このテストは、src/pages/index.tsx で定義されているコンポーネントに、Welcome to Next.js! という内容が含まれていたらテストが成功します。

import { render, screen } from "@testing-library/react";
import App from "../pages/index";

describe("App", () => {
  it("renders without crashing", () => {
    render(<App />);
    expect(
      screen.getByRole("heading", { name: "Welcome to Next.js!" })
    ).toBeInTheDocument();
  });
});

yarn test を実行すると、テストが実行されます。 PASS と表示され、テストが成功します。

yarn test

image

next-page-tester 導入

次に、Next.js のページをテストで扱えるようにするためのライブラリである next-page-tester を導入します。
パッケージを追加します。

yarn add -D next-page-tester

setupTests.js を修正して、以下のようにコードを追記します。

import "@testing-library/jest-dom/extend-expect";
// 以下を追記
import { initTestHelpers } from 'next-page-tester';
initTestHelpers();

src/tests/index.test.tsx を以下のように修正します。

import { getPage } from "next-page-tester";
import { render, screen } from "@testing-library/react";

describe("index page", () => {
  test('should have "Welcome to"', async () => {
    const { render } = await getPage({
      route: "/",
    });

    render();
    expect(screen.getByText("Welcome to")).toBeInTheDocument();
  });
});

yarn test を実行し、テストが成功したらnext-page-tester の導入は完了です。

yarn test

next-page-tester 使い方

next-page-tester の簡単な使い方について説明します。

import { getPage } from "next-page-tester";
import { screen } from "@testing-library/react";

describe("Index page", () => {
  test("renders index page", async () => {
    const { page, render } = await getPage({
      route: "/",
    });

    render();
    expect(screen.getByText("Welcome to")).toBeInTheDocument();
  });
});

next-page-tester は src/pages 以下にあるページコンポーネントをテストで扱えるようにします。これらのコンポーネントでは, ファイル中に getServerSideProps 関数を定義することができます。この関数はサーバサイドで実行され、その結果が props としてコンポーネントに渡されます。next-page-tester は、テスト中に getServerSideProps を実行し、結果をコンポーネントに渡した上でテストが実行されます。

next-page-tester を使う際は、getPage 関数を呼び出します。引数の route にはテストしたいページのパスを指定します。上のテストの場合は、パスが / のページである src/pages/index.tsx のテストを行っています。

getPage 関数は pagerender を返します。page はページの Reactコンポーネントを返し、render は実行するとページの JSDOM を返します。


getServerSideProps 内で fetch を使って外部からデータを取得している場合には、テスト環境では fetch 関数が未定義で、エラーが発生しテストが失敗してしまいます。 そのため、 fetch-mock を使って fetch 関数のモックを作ります。以下は fetch-mock を使ったテストコードのサンプルです。

import { getPage } from "next-page-tester";
import { screen } from "@testing-library/react";
import fetchMock from "fetch-mock"

describe("Index page", () => {
  beforeAll(() => {
    fetchMock.get("http://sample.hoge", { "name": "sample" })
  })

  test("renders index page", async () => {
    const { page, render } = await getPage({
      route: "/",
    });

    render();
    expect(screen.getByText("Welcome to")).toBeInTheDocument();
  });
});
import React from "react"

const IndexPage: React.Page<{ name: string }> = (props) => {
  const { name } = props;
  return (
     <div>
     {`Welcome to ${name}`}
     </div>
  );
}

export const getServerSideProps = async () => {
  const res = await fetch("https://sample.hoge")
                    .then((res) => res.json());
  return { props: res };
};

export default Index;

fetch-mock では、URLとそのURLが返すデータを渡します。サンプルコードでは、テスト中に http://sample.hoge というURLで fetch が呼ばれた場合に {"name":"sample"} というデータを返すように設定します。このようにすることで、 getServerSideProps の中で fetch('http://sample.hoge') が呼ばれた場合は {"name":"sample"} というデータを得ることができます。

next-page-tester を使ったページのスナップショットテスト

それでは実際のコードを用いて、スナップショットテストの解説をしていきます。

作成するページ

今回は、2種類のページ /album/[id]/post/[id] を作ります。各ページに表示される内容は、以下の画像の通りになっています。
album では、パスで与えられたIDのアルバムのデータを取得し、タイトルと作成者情報、そしてアルバムに含まれる画像を表示します。
post では、指定したIDの投稿データを取得し、タイトル、本文、投稿者情報、そして投稿に対するコメントを表示します。
albumpost では、作成者の情報を表示するコンポーネントが共通となっています。(スクリーンショットの右側のコンポーネント)

image

image

表示するデータは、ダミーデータを返す https://jsonplaceholder.typicode.com/ という既存のAPIから取得します。

各ページの実装

album, post ページ用のコンポーネントとユーザ情報を表示する共通のUIコンポーネントを追加します。

src/pages/album/[id].tsx を作成し、以下を記述します。

import React from "react";
import { GetServerSideProps, NextPage } from "next";
import { User, Album, Photo } from "../../domain";
import { UserCard } from "../../components/UserCard";

type AlbumPageProps = {
  user: User;
  album: Album;
  photos: Photo[];
};

const AlbumPage: NextPage<AlbumPageProps> = (props) => {
  const { user, album, photos } = props;

  return (
    <div className="album__page">
      <h1>{album.title}</h1>
      <div className="album__body">
        <div className="album__body__list">
          {photos.map((photo) => {
            const { id, title, url } = photo;
            return (
              <div key={id} className="album__body__list__item">
                <img alt={title} src={url} className="album__body__list__item__image" />
                <p className="album__body__list__item__title">{title}</p>
              </div>
            );
          })}
        </div>
        <div className="album__body__author">
          <UserCard user={user} />
        </div>
      </div>
    </div>
  );
};

export const getServerSideProps: GetServerSideProps<AlbumPageProps> = async (
  context
) => {
  const id = Array.isArray(context.params["id"])
    ? context.params["id"][0]
    : context.params["id"];

  const [album, photos] = await Promise.all([
    fetch(`https://jsonplaceholder.typicode.com/albums/${id}`)
      .then((res) => res.json())
      .then(
        (data): Album => {
          if (Object.keys(data).length === 0) {
            throw new Error("Not Found");
          }
          return data;
        }
      ),
    fetch(`https://jsonplaceholder.typicode.com/albums/${id}/photos`)
      .then((res) => res.json())
      .catch(() => []),
  ]);
  const user = await fetch(
    `https://jsonplaceholder.typicode.com/users/${album.userId}`
  ).then((res) => res.json());

  return {
    props: {
      user,
      album,
      photos,
    },
  };
};

export default AlbumPage;

次に src/pages/post/[id].tsx を作成し、以下を記述します。

import React from "react";
import { GetServerSideProps, NextPage } from "next";
import { User, Post, Comment } from "../../domain";
import { UserCard } from "../../components/UserCard";

type PostPageProps = {
  user: User;
  post: Post;
  comments: Comment[];
};

const PostPage: NextPage<PostPageProps> = (props) => {
  const { user, post, comments } = props;

  return (
    <div className="post__page">
      <div className="post__main">
        <div className="post__contents">
          <h1 className="post__main__post__title">{post.title}</h1>
          <p>{post.body}</p>
        </div>
        <div className="post__main__author">
          <UserCard user={user} />
        </div>
      </div>
      <div className="post__comments">
        <h2 className="post__comments__title">Comment</h2>
        {comments.map((comment) => {
          const { id, name, email, body } = comment;

          return (
            <div key={id} className="post__comments__comment">
              <p className="post__comments__comment__header">{`${name} (${email})`}</p>
              <p className="post__comments__comment__content">{body}</p>
            </div>
          );
        })}
      </div>
    </div>
  );
};

export const getServerSideProps: GetServerSideProps<PostPageProps> = async (
  context
) => {
  const id = Array.isArray(context.params["id"])
    ? context.params["id"][0]
    : context.params["id"];

  const [post, comments] = await Promise.all([
    fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
      .then((res) => res.json())
      .then((data) => {
        if (Object.keys(data).length === 0) {
          throw new Error("Not Found");
        }
        return data;
      }),
    fetch(`https://jsonplaceholder.typicode.com/posts/${id}/comments`)
      .then((res) => res.json())
      .catch(() => []),
  ]);
  const user = await fetch(
    `https://jsonplaceholder.typicode.com/users/${post.userId}`
  ).then((res) => res.json());

  return {
    props: {
      user,
      post,
      comments,
    },
  };
};

export default PostPage;

最後に album ページと post ページの両方で使用している、UserCard コンポーネントを追加します。
src/components/UserCard/index.tsx を作成し、以下を記述します。

import React from "react";
import { User } from "../../domain";

type UserCardProps = React.HTMLAttributes<HTMLDivElement> & {
  children?: never;
  user: User;
};

export const UserCard: React.FC<UserCardProps> = (props) => {
  const { user } = props;

  return (
    <div className="user-card">
      <p className="user-card__name">{user.name}</p>
      <p className="user-card__email">{user.email}</p>
      <p className="user-card__phone">{user.phone}</p>
      <p className="user-card__website">{user.website}</p>
    </div>
  );
};

スナップショットテストを追加

次に album ページのスナップショットテストをするために、テストを追加します。

src/tests/album/index.test.tsx を追加し、以下を記述します

import { getPage } from "next-page-tester";
import fetchMock from "fetch-mock";
import renderer from "react-test-renderer";

describe("album page", () => {
  beforeAll(() => {
    fetchMock
      .get("https://jsonplaceholder.typicode.com/albums/1", {
        id: 1,
        userId: 1,
        title: "Test Title",
      })
      .get("https://jsonplaceholder.typicode.com/albums/1/photos", [
        {
          id: 1,
          albumId: 1,
          title: "Test Photo",
          url: "/test.png",
          thumbnailUrl: "/test-thumbnail.png",
        },
        {
          id: 2,
          albumId: 1,
          title: "Test Photo2",
          url: "/test-2.png",
          thumbnailUrl: "/test-thumbnail-2.png",
        },
      ])
      .get("https://jsonplaceholder.typicode.com/users/1", {
        id: 1,
        name: "Adam",
        username: "adam1980",
        email: "adam@hoge.org",
        address: {
          street: "Kulas Light",
          suite: "Apt. 556",
          city: "Gwenborough",
          zipcode: "92998-3874",
          geo: {
            lat: "-37.3159",
            lng: "81.1496",
          },
        },
        phone: "1-770-736-8031 x56442",
        website: "adam.org",
        company: {
          name: "Test-Company",
          catchPhrase: "Next generation platform",
          bs: "photo",
        },
      });
  });

  test("should render correctly", async () => {
    const { page } = await getPage({
      route: "/album/1",
    });

    const tree = renderer.create(page).toJSON();
    expect(tree).toMatchSnapshot();
  });
});

上のテストでは、/album/1 にアクセスした時のページに関して、スナップショットテストを行います。前回のテスト実行時と同じ内容が表示される場合はテストが成功します。(初回の場合、前回の結果が無いため必ず成功します)

また、beforeAll の中で fetch 関数のモックを設定し、 getServerSideProps で fetch が呼ばれた場合、URLに対応するダミーデータを返すように設定します。

yarn test を実行してみると、以下のような結果が得られます。
初回のスナップショットテストなので、テストは成功し 1 snapshot written と表示されます。スナップショットテストをすると、テストのファイルと同じ階層に __snapshots__ ディレクトリが自動で作成されます。このディレクトリの中には、次回のスナップショットテストで使うスナップショットのデータが入っているため、gitなどのバージョン管理システムの監視対象として追加します。

image

UIコンポーネントの修正とスナップショットテストの実行

次に、post ページに表示されている作成者情報に、ユーザの企業情報も表示するように修正します。

image

src/components/UserCard/index.tsx を修正します。

import React from "react";
import { User } from "../../domain";

type UserCardProps = React.HTMLAttributes<HTMLDivElement> & {
  children?: never;
  user: User;
};

export const UserCard: React.FC<UserCardProps> = (props) => {
  const { user } = props;

  return (
    <div className="user-card">
      <p className="user-card__name">{user.name}</p>
      <p className="user-card__email">{user.email}</p>
      <p className="user-card__phone">{user.phone}</p>
      <p className="user-card__website">{user.website}</p>
      <div className="user-card__company">
        <p className="user-card__company__title">Company Info</p>
        <p className="user-card__company__name">{user.company.name}</p>
        <p className="user-card__company__catch-phrase">
          {user.company.catchPhrase}
        </p>
      </div>
    </div>
  );
};

UserCard は、album ページでも使用しているコンポーネントのため、この修正により album ページの内容も変化してしまいます。そのため、yarn test でテストを再度行うと、以下のようにテストが失敗します。

image

テストの結果を見ると、ハイライトされている部分が企業情報の部分となっており、新たに追加した部分と対応していることが分かります。このように、コンポーネントを修正した事で、ページが前回のテストから変化したことが分かります。もしこの変化が意図するものであれば、yarn test -u を実行することで、スナップショットを更新することができます。

まとめ

jest, React Testing Libray, next-page-tester を組み合わせることで、 Next.js のプロジェクトで getServerSideProps の実行も含めてページ単位でテストを行うことができます。スナップショットテストをページ単位で行うことで、コンポーネントの修正がどのページに影響を及ぼすかを容易に補足することができ、意図しない変化を防ぐことができるようになります。

最後に

弊社 GAOGAO は現在副業含めて40名以上のエンジニアの方が参画し、グローバル(シンガポール、バンコク、US、日本など)で20社以上お客様の開発のお手伝いをさせていただいております。

もしグローバルにお仕事をしたいというエンジニアの方(デザイナーの方も)いましたら、お気軽に @tejitak までご連絡いただければ幸いです。