Next.js (v14) + Jest を使った単体テスト入門
はじめに
最近仕事で単体テストを書くようになったので、備忘録がてら Next.js (v14) + Jest を使って単体テストの復習をしていきます。
後述するリポジトリに置いてあるコードと照らし合わせながら一緒にテストを書いていきましょう。
対象読者
この記事は以下の方を読者として想定しています。
- Next.js v14 (App Router)を利用している方
- 最近テストコードを書き始めた方
- そもそもテストコードの書き方をよくわかっていない方
- これからフロントエンドでテストを書こうと思っている方
実行環境
- node: v20.11.0
- npm: v9.5.1
リポジトリ
記事で利用しているファイルは下記リポジトリを参照してください。
下準備
テストに必要なパッケージを導入してきます。
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 公式サイトを参考に testEnvironment
、 setupFilesAfterEnv
、 moduleNameMapper
の値を追加、変更します。
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
に追加しておきましょう。
import '@testing-library/jest-dom';
テストするためにコンポーネントを作る
Container / Presentation パターンを用いるとテストがしやすいです。
APIを叩くService層と、コンポーネントを描画するView層で責務を分けられるメリットです。
Container / Presentation パターンについては以下の記事がとてもわかりやすく説明されています。
Container コンポーネント
まずはシンプルにPresentationコンポーネントを描画するだけのContainerコンポーネントを作成しましょう。
import HomePresentation from './_components/HomePresentation';
export default async function Home() {
return <HomePresentation message="Hello Jest!!" />;
}
Presentation コンポーネント
Propsで受け取った message
と、後述する todo一覧ページへのリンクが存在するシンプルなPresentationコンポーネントを作成します。
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
を表示できるかテストしましょう。
HomePresentation
の message
に適当な文字列を渡して、描画出来ているかテストします。
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
の詳しい使い方は下記を参照してください。
ここまで書いたら次のコマンドを叩いて、テストを実行しましょう。
無事にテストが通るはずです。
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です。
export const apiEndpoint = 'https://jsonplaceholder.typicode.com';
export interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
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一覧を取得できる
- リクエストが失敗した場合はエラーを投げる
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オブジェクトを含んだ配列を返却して欲しいので、次のようにデータを指定しましょう。
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => [
{
userId: 1,
id: 1,
title: 'delectus aut autem',
completed: false,
},
],
} as Response);
fetch
が返すデータをモックしたので、 getTodoList
を実行して期待する配列が返却されているか次の部分で検証しています。
const result = await getTodoList();
expect(result).toEqual([
{
userId: 1,
id: 1,
title: 'delectus aut autem',
completed: false,
},
]);
レスポンスの取得に失敗した場合はエラーを投げる
レスポンスの取得に失敗した場合のテストを書きましょう。
失敗してほしいので、次のようなデータを指定しましょう。
global.fetch = jest.fn().mockResolvedValueOnce({
ok: false,
json: async () => [],
} as Response);
fetch
が返す Response
オブジェクトの ok
を false
に指定します。
これでレスポンスが失敗したという事をモックします。
getTodoList
内部ではレスポンスの取得に失敗した場合にErrorオブジェクトを throw
するようにしています。
if (!response.ok) throw new Error('Failed to fetch');
テストコード内で try~catch
文を使って失敗したら例外を呼び出せるようにしておきましょう。
例外が発生すると catch
ブロックを実行するので catch
ブロック内にアサーションを書きます。
expect.assertions(1);
を書いているのは、 cacth
ブロックが実行されなかった場合に、テストが通ってしまうため、必ず1回はアサーションが呼ばれる事をチェックする為に記載しています。
以下で期待するErrorメッセージが throw
されているかをテストできます。
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が無い旨のメッセージを表示するシンプルなコンポーネントです。
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」というテキストがコンポーネントに存在するかを検証できるテストコードを書いていきます。
test('Todo一覧ページが表示されること', () => {
const { getByText } = render(<TodoPresentation todos={[]} />);
expect(getByText('Todo List')).toBeInTheDocument();
});
Todoが1件以上ある場合はTodo一覧が表示されること
Todoオブジェクトの配列のモックデータを用意して、 <TodoPresentation />
の todos
に渡したらTodo一覧が表示されるかをテストします。
先ほどと同じく getByText
を使って、描画したコンポーネントに渡したTodoのタイトルが存在するかどうかを検証していきましょう。
テストコードは次のようになります。
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はありません」というテキストが存在するかどうかを検証します。
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
をラップしているだけのとてもシンプルなコンポーネントです。
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
に渡したテキストが表示されるかのテストをします。
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 にクリックイベントを渡して発火できる
次はボタンにクリックイベントを渡して、それをコールできるかのテストです。
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にテストを仕込むなどしてエラーを検知することになりますが、この記事では省略させていただきました。
個人的にはテストを書くことによって、以下の恩恵が受けられると考えています。
- ファイルを修正した時のデグレを防ぐことができる
- 関数の使い方が理解できる
- 関数の期待する動作が理解できる
- 途中からプロジェクトにアサインされた人間のキャッチアップの手助けになる
- テストしやすくするためにシンプルな設計を心がけるようになる
など、他にもメリットはあるかと思います。
私自身テストを書くようになって、時間が無いからテストを書かないのではなく、時間が無いからこそテストを書いたほうが結果的に時間の節約に繋がるということを実感できるようになってきました。
この記事が、今までテストを書いてきていなかったフロントエンドエンジニアの方や、これからテストを書こうとしているフロントエンドエンジニアの方に、少しでも役立てば幸いです。
参考
-
Containerコンポーネントでfetchした値を大きく加工したり分岐する場合にはテストが必要となる場合もあるでしょう ↩︎
Discussion
細かい部分にはなるのですが、
この部分、catchに入らなくてもテストがパスするので、
とする方がいいのかな?と思いました。
@ojisan
ご指摘ありがとうございます!
仰るとおりだったので、記事内のコードとリポジトリのコードを修正いたしました!
ただ、今回のケースでは厳密にやらず期待するメッセージがthrowされるかだけテストできれば良さそうでしたので、
toThrow
を利用させていただきました!