株式会社HAMWORKS
🔰

Next.js (v14) + Jest を使った単体テスト入門

2024/04/22に公開
2

はじめに

最近仕事で単体テストを書くようになったので、備忘録がてら Next.js (v14) + Jest を使って単体テストの復習をしていきます。

後述するリポジトリに置いてあるコードと照らし合わせながら一緒にテストを書いていきましょう。

対象読者

この記事は以下の方を読者として想定しています。

  • Next.js v14 (App Router)を利用している方
  • 最近テストコードを書き始めた方
  • そもそもテストコードの書き方をよくわかっていない方
  • これからフロントエンドでテストを書こうと思っている方

実行環境

  • node: v20.11.0
  • npm: v9.5.1

リポジトリ

記事で利用しているファイルは下記リポジトリを参照してください。

https://github.com/nagasawaaaa/jest-introduction-example

下準備

テストに必要なパッケージを導入してきます。

Next.js インストール

npx create-next-app@latest

筆者は下記のように設定しました。基本的に全部Yesです。
※各々お好きに設定していただいてOKです

✔ What is your project named? … jest-introduction-example
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … Yes
✔ What import alias would you like configured? … @/*

Jest インストール

公式にある下記コマンドで必要なパッケージを導入しましょう。

npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom

typescript に対応させる

上記だけではJestがTypeScriptに対応していないので下記コマンドで必要なパッケージを導入しましょう。

npm install --save-dev ts-jest ts-node @types/jest

Jest 設定

Next.js 公式サイトを参考に testEnvironmentsetupFilesAfterEnvmoduleNameMapper の値を追加、変更します。

jest.config.ts
import type { Config } from 'jest';
import nextJest from 'next/jest.js';

const createJestConfig = nextJest({
  dir: './',
});

const config: Config = {
  coverageProvider: 'v8',
  testEnvironment: 'jest-environment-jsdom',
  // Add more setup options before each test is run
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  moduleNameMapper: {
    '@/(.*)$': '<rootDir>/src/$1',
  },
};

export default createJestConfig(config);

testing-library/jest-dom はコンポーネント状態を確認するための便利なMatcherが使えるようになります。
各テストファイルで逐一 import しなくてすむよう、 jest.setup.ts に追加しておきましょう。

jest.setup.ts
import '@testing-library/jest-dom';

テストするためにコンポーネントを作る

Container / Presentation パターンを用いるとテストがしやすいです。

APIを叩くService層と、コンポーネントを描画するView層で責務を分けられるメリットです。

Container / Presentation パターンについては以下の記事がとてもわかりやすく説明されています。

https://zenn.dev/buyselltech/articles/9460c75b7cd8d1

Container コンポーネント

まずはシンプルにPresentationコンポーネントを描画するだけのContainerコンポーネントを作成しましょう。

src/app/page.tsx
import HomePresentation from './_components/HomePresentation';

export default async function Home() {
  return <HomePresentation message="Hello Jest!!" />;
}

Presentation コンポーネント

Propsで受け取った message と、後述する todo一覧ページへのリンクが存在するシンプルなPresentationコンポーネントを作成します。

src/app/_components/HomePresentation.tsx
import Link from 'next/link';

interface Props {
  message: string;
}

export function HomePresentation({ message }: Props) {
  return (
    <div className="space-y-4">
      <h1 className="text-2xl font-bold">{message}</h1>
      <div>
        <Link href="/todos" className="underline hover:no-underline">
          Go to Todo List
        </Link>
      </div>
    </div>
  );
}

export default HomePresentation;

Presentation コンポーネントのテスト

受け取った message を表示できるかテストしましょう。

HomePresentationmessage に適当な文字列を渡して、描画出来ているかテストします。

src/app/_components/HomePresentation.test.tsx
import { render } from '@testing-library/react';
import HomePresentation from './HomePresentation';

describe('Home Render', () => {
  test('<HomePresentation /> の message にテキストを渡せる', () => {
    const { getByRole } = render(<HomePresentation message="Hello Jest!!" />);
    expect(getByRole('heading', { name: 'Hello Jest!!' })).toBeInTheDocument();
  });
});

テストには getByRole という testing-library/react のクエリ(メソッド)を使います。

第一引数には取得したい要素のrole名を設定します。
テスト対象のコンポーネントの h1 タグを探したいので heading を設定しましょう。

第二引数はオプションですが、ここではどんな文字列を期待するかを設定しましょう。
引数に渡すオブジェクトの name プロパティに、コンポーネントを描画するときに渡した Hello Jest!! という文字列を設定します。

次の行で getByRole で取得した要素が、描画したコンポーネントに存在するかを実際に検証しています。

expect(getByRole('heading', { name: 'Hello Jest!!' })).toBeInTheDocument();

ちなみに getByRole の詳しい使い方は下記を参照してください。

https://testing-library.com/docs/queries/byrole

ここまで書いたら次のコマンドを叩いて、テストを実行しましょう。
無事にテストが通るはずです。

npm run test src/app/_components/HomePresentation.test.tsx

試しに 描画する(renderに渡した)コンポーネントの message を次のように変えて、テストコマンドを叩いた場合、テストは失敗することでしょう。

const { getByRole } = render(<HomePresentation message="Hello Next.js!!" />);

Service層の実装/テストを行う

次は JSONPlaceholder というサービスを使って、Todoリストを取得するというService層を作ってテストしましょう。

JSONPlaceholder はいくつかの種類のダミーデータを扱う無料のREST APIです。

https://jsonplaceholder.typicode.com/

src/constants/apiEndpoint.ts
export const apiEndpoint = 'https://jsonplaceholder.typicode.com';
src/types/todo.ts
export interface Todo {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}
src/services/todos/getTodoList.ts
import { Todo } from '@/types';
import { apiEndpoint } from '@/constants/apiEndpoint';

export default async function getTodoList(): Promise<Todo[]> {
  const response = await fetch(`${apiEndpoint}/todos`);
  if (!response.ok) throw new Error('Failed to fetch');
  return await response.json();
}

todoリストを取得するエンドポイントを叩いて、リクエストに成功したらデータをjson形式で返却し、失敗したらエラーを投げるというシンプルな実装です。

Todoを取得するServiceのテスト

前述の getTodoList のテストを書いていきましょう。

ここでは2つのテストを書いていきます。

  • リクエストが成功した場合はtodo一覧を取得できる
  • リクエストが失敗した場合はエラーを投げる
src/services/todos/getTodoList.test.ts
import getTodoList from './getTodoList';

describe('getTodoList', () => {
  test('todo一覧を取得できる', async () => {
    global.fetch = jest.fn().mockResolvedValueOnce({
      ok: true,
      json: async () => [
        {
          userId: 1,
          id: 1,
          title: 'delectus aut autem',
          completed: false,
        },
      ],
    } as Response);
    const result = await getTodoList();
    expect(result).toEqual([
      {
        userId: 1,
        id: 1,
        title: 'delectus aut autem',
        completed: false,
      },
    ]);
  });
  test('通信が失敗した場合はエラーを投げる', async () => {
    global.fetch = jest.fn().mockResolvedValueOnce({
      ok: false,
      json: async () => [],
    } as Response);
    await expect(getTodoList()).rejects.toThrowError('Failed to fetch');
  });
});

リクエストが成功した場合はtodo一覧を取得できる

getTodoList の内部で実行している fetch をモックするため mockResolvedValueOnce にリクエストが成功した場合のデータを指定します。

ここではTodoオブジェクトを含んだ配列を返却して欲しいので、次のようにデータを指定しましょう。

src/services/todos/getTodoList.test.ts
global.fetch = jest.fn().mockResolvedValueOnce({
  ok: true,
  json: async () => [
    {
      userId: 1,
      id: 1,
      title: 'delectus aut autem',
      completed: false,
    },
  ],
} as Response);

fetch が返すデータをモックしたので、 getTodoList を実行して期待する配列が返却されているか次の部分で検証しています。

src/services/todos/getTodoList.test.ts
const result = await getTodoList();
expect(result).toEqual([
  {
    userId: 1,
    id: 1,
    title: 'delectus aut autem',
    completed: false,
  },
]);

レスポンスの取得に失敗した場合はエラーを投げる

レスポンスの取得に失敗した場合のテストを書きましょう。

失敗してほしいので、次のようなデータを指定しましょう。

src/services/todos/getTodoList.test.ts
global.fetch = jest.fn().mockResolvedValueOnce({
  ok: false,
  json: async () => [],
} as Response);

fetch が返す Response オブジェクトの okfalse に指定します。
これでレスポンスが失敗したという事をモックします。

getTodoList 内部ではレスポンスの取得に失敗した場合にErrorオブジェクトを throw するようにしています。

src/services/todos/getTodoList.ts
if (!response.ok) throw new Error('Failed to fetch');

テストコード内で try~catch 文を使って失敗したら例外を呼び出せるようにしておきましょう。
例外が発生すると catch ブロックを実行するので catch ブロック内にアサーションを書きます。

expect.assertions(1); を書いているのは、 cacth ブロックが実行されなかった場合に、テストが通ってしまうため、必ず1回はアサーションが呼ばれる事をチェックする為に記載しています。

以下で期待するErrorメッセージが throw されているかをテストできます。

src/services/todos/getTodoList.test.ts
await expect(getTodoList()).rejects.toThrow('Failed to fetch');

さっそくテストを実行しましょう。
今回も無事にすべてのテストが通るはずです。

npm run test src/services/todos/getTodoList.test.ts

Todo一覧ページのテスト

Service層の単体テストを済ませておけば、Containerコンポーネントのテストを書かなくても恐らく大きな問題ではないでしょう。[1]

したがって、ここではPresentationコンポーネントが期待する動作になっているかをテストしていきます。

Todo一覧のPresentationコンポーネントはPropsからTodoオブジェクトの配列を受け取って、配列の数が1件以上であればTodoを表示し、Todoオブジェクトが0件であればTodoが無い旨のメッセージを表示するシンプルなコンポーネントです。

src/app/todos/_components/TodoPresentation.tsx
import Link from 'next/link';
import type { Todo } from '@/types';

interface Props {
  todos: Todo[];
}

export default function TodoPresentation({ todos }: Props) {
  return (
    <div className="space-y-4">
      <h1 className="text-2xl font-bold">Todo List</h1>
      {todos.length > 0 ? (
        <ul className="list-inside list-disc space-y-2">
          {todos.map((todo) => (
            <li key={todo.id}>{todo.title}</li>
          ))}
        </ul>
      ) : (
        <p>Todoはありません</p>
      )}
      <div>
        <Link className="underline hover:no-underline" href="/">
          Go to Home
        </Link>
      </div>
    </div>
  );
}

Presentation コンポーネントのテスト

Todo一覧ページのテストは下記を満たせばOKとします。

  • Todo一覧ページが表示されること
  • Todoが1件以上ある場合はTodo一覧が表示されること
  • Todoが1件もない場合はメッセージが表示されること

それぞれテストを書いていきましょう。

Todo一覧ページが表示されること

まずはコンポーネントが表示される事をテストしたいので、getByText を使って描画したコンポーネントから「Todo List」というテキストがコンポーネントに存在するかを検証できるテストコードを書いていきます。

src/app/todos/_components/TodoPresentation.test.tsx
test('Todo一覧ページが表示されること', () => {
  const { getByText } = render(<TodoPresentation todos={[]} />);
  expect(getByText('Todo List')).toBeInTheDocument();
});

Todoが1件以上ある場合はTodo一覧が表示されること

Todoオブジェクトの配列のモックデータを用意して、 <TodoPresentation />todos に渡したらTodo一覧が表示されるかをテストします。
先ほどと同じく getByText を使って、描画したコンポーネントに渡したTodoのタイトルが存在するかどうかを検証していきましょう。
テストコードは次のようになります。

src/app/todos/_components/TodoPresentation.test.tsx
test('Todoが1件以上ある場合はTodo一覧が表示されること', () => {
  const mockTodos: Todo[] = [
    {
      userId: 1,
      id: 1,
      title: 'delectus aut autem',
      completed: false,
    },
  ];
  const { getByText } = render(<TodoPresentation todos={mockTodos} />);
  expect(getByText('delectus aut autem')).toBeInTheDocument();
});

Todoが1件もない場合はメッセージが表示されること

最後はTodoが1件も無かった場合のテストです。
テストコードは「Todo一覧ページが表示されること」とほぼ同じですが、アサーションが違います。
ここでは todos に空の配列を渡した場合、コンポーネント内に「Todoはありません」というテキストが存在するかどうかを検証します。

src/app/todos/_components/TodoPresentation.test.tsx
test('Todoが1件もない場合はメッセージが表示されること', () => {
  const { getByText } = render(<TodoPresentation todos={[]} />);
  expect(getByText('Todoはありません')).toBeInTheDocument();
});

下記コマンドを叩いて、テストが通るか確認しましょう。

npm run test src/app/todos/_components/TodoPresentation.test.tsx

おまけ:Buttonコンポーネントのテスト

おまけによく例として上がるButtonコンポーネントのテストを書いていきましょう。
button をラップしているだけのとてもシンプルなコンポーネントです。

src/components/Button/Button.tsx
import type { ComponentProps, ReactNode } from 'react';

interface Props extends ComponentProps<'button'> {
  children: ReactNode;
}

const Button = ({ children, ...props }: Props) => {
  return <button {...props}>{children}</button>;
};

export default Button;

Buttonコンポーネントのテストコードを書く

Button にテキストを渡せる

コンポーネントの children に渡したテキストが表示されるかのテストをします。

src/components/Button/Button.test.tsx
describe('Button Render', () => {
  beforeEach(cleanup);
  test('Button の children にテキストを渡せる', () => {
    const { getByRole } = render(<Button>Click me</Button>);
    const buttonElement = getByRole('button', { name: 'Click me' });
    expect(buttonElement).toBeInTheDocument();
  });
  // 省略
});

render 関数を使って描画したあと、 getByRole を使って、 button 要素から「Click me」と表示された要素を取得します。
toBeInTheDocument で、要素がドキュメント内に存在すればOKとします。

Button にクリックイベントを渡して発火できる

次はボタンにクリックイベントを渡して、それをコールできるかのテストです。

src/components/Button/Button.test.tsx
describe('Button Render', () => {
  // 省略
  test('Button にクリックイベントを渡してコールできる', () => {
    const onClick = jest.fn();
    const { getByRole } = render(<Button onClick={onClick}>Click me</Button>);
    const buttonElement = getByRole('button', { name: 'Click me' });
    buttonElement.click();
    expect(onClick).toHaveBeenCalled();
  });
});

最初にコンポーネントの props.onClick に渡すモック関数を作成します。
モック関数はテストメソッド内で何回コールされたかを計測することも可能です。

今回はコールされた回数は大きな問題ではないので、ボタンをクリックしたときに1度でもコールされたかをテストすれば良いでしょう。
モック関数がコールされたかをテストできる toHaveBeenCalled を使用したアサーションを書きます。

expect(onClick).toHaveBeenCalled();

ここまで出来たら、下記コマンドを叩いて、テストが通るか確認しましょう。
無事にテストが通るはずです。

npm run test src/components/Button/Button.test.tsx

おわりに

以上がNext.js (v14) + Jest を使って単体テストをしてみるサンプルとなります。

実際のプロジェクトでは git commit 時にテストを走らせたり、GitHub Actionsにテストを仕込むなどしてエラーを検知することになりますが、この記事では省略させていただきました。

個人的にはテストを書くことによって、以下の恩恵が受けられると考えています。

  • ファイルを修正した時のデグレを防ぐことができる
  • 関数の使い方が理解できる
  • 関数の期待する動作が理解できる
  • 途中からプロジェクトにアサインされた人間のキャッチアップの手助けになる
  • テストしやすくするためにシンプルな設計を心がけるようになる

など、他にもメリットはあるかと思います。

私自身テストを書くようになって、時間が無いからテストを書かないのではなく、時間が無いからこそテストを書いたほうが結果的に時間の節約に繋がるということを実感できるようになってきました。

この記事が、今までテストを書いてきていなかったフロントエンドエンジニアの方や、これからテストを書こうとしているフロントエンドエンジニアの方に、少しでも役立てば幸いです。

参考

https://nextjs.org/docs/app/building-your-application/testing/jest

https://qiita.com/mktu/items/d36416baba155dfecc00

https://azukiazusa.dev/blog/server-components-testing/

https://qiita.com/YSasago/items/6109c5d3fbdbffa31c9f

脚注
  1. Containerコンポーネントでfetchした値を大きく加工したり分岐する場合にはテストが必要となる場合もあるでしょう ↩︎

株式会社HAMWORKS
株式会社HAMWORKS

Discussion

rión_devopsrión_devops

細かい部分にはなるのですが、

test('通信が失敗した場合はエラーを投げる', async () => {
    global.fetch = jest.fn().mockResolvedValueOnce({
      ok: false,
      json: async () => [],
    } as Response);
    expect.assertions(1);
    try {
      await getTodoList();
    } catch (error) {
      expect(error).toEqual(new Error('Failed to fetch'));
    }
  });

この部分、catchに入らなくてもテストがパスするので、

await expect(getTodoList()).rejects.toThrowError('Failed to fetch');

とする方がいいのかな?と思いました。