Next.js の Jest / React Testing Library でモック (mock) を含めた render で開発効率を高めよう

Next.js の Jest / React Testing Library でモック (mock) を含めた render で開発効率を高めよう

はじめに

Next.js でテスト駆動開発を進める時に必ず関門となる各種機能のモックに関するベストプラクティスを紹介します。
今回は Router などの機能をモックした render を作成して、共通のコンポーネントとして利用することを目標としましょう。

バージョン情報

  • Node.js:15.11.0
  • React:17.0.2
  • Next.js:10.2.2
  • Jest:26.6.3
  • React Testing Library
    • @testing-library/react:11.2.5

Router を使用した際の問題点

next/router

例えば、SNS でのシェア機能などを実装する時に、現在のページに関する path を取得したい場合があります。
ここで、便利な機能が next/router です(公式ページはこちら)。
// src/pages/sample.tsx import { useRouter } from 'next/router'; export default function Sample(): JSX.Element { const router = useRouter(); const path = router.asPath; return <h1>このページの path は {path} です。</h1>; }
上記のコードを実行すると、以下のような実行結果が得られ、SNS のシェア機能などに便利であることがわかります。
01

テスト時にエラーへ遭遇

では、上記のコードに対して、テストを書いてみましょう。
例えば、最も単純なスナップショットテストで今回は紹介します。
テストは Next.js を利用する時に定番の JestReact Testing Library を使用します。
以下のように簡単なスナップショットテストを書きましょう。
pages/... となっているのは、以前の記事(【Next.js】特定のディレクトリを基準にし、絶対パスでモジュールをインポートする方法)で紹介した設定を行っているためです(推奨)
// __test__/sample.test.tsx import { render } from '@testing-library/react'; import Sample from 'pages/sample'; describe('初期表示の確認', () => { test('サンプルページ', () => { const { asFragment } = render(<Sample />); expect(asFragment()).toMatchSnapshot(); }); });
こちらでテストを実行してみましょう。
# テストの実行 yarn test __test__/sample.test.tsx
一見正しいように見えますが、以下のようなエラーに遭遇します。
# 表示されるエラーを抜粋 初期表示の確認 ✕ サンプルページ (29 ms) ● 初期表示の確認 › サンプルページ TypeError: Cannot read property 'asPath' of null 3 | export default function Sample(): JSX.Element { 4 | const router = useRouter(); > 5 | const path = router.asPath; | ^ 6 | 7 | return <h1>このページの path は {path} です。</h1>; 8 | } ... ```
router.asPath のところでエラーが発生していることがわかります。
React の ReactRouter や、その機能をベースとした next/router を使用する際には必ず遭遇するエラーと言っても過言ではありません。
useRouter などの Hooks はクライアントサイド側で実行されるため、テスト実行時などサーバーサイド側でのみ実行する場合にエラーが起きてしまうのです。
したがって、useRouter 意外にも use*** 系の Hooks を使用する際には常に一工夫が必要です。

Router 使用時の解決策

Router 機能をモック

テストを書いていると モック という用語に高頻度で遭遇します。
モックとは該当する機能などを別の機能に置き換えることを言います。
この別の機能への置き換えのことはマッピングということも多いのですが、特にモックでは意味を持たない機能にマッピングしていることを指している気がします。
一応、Wikipedia も引用しておきます。
モックオブジェクト(Mock Object)とは、ソフトウェアテスト時、特にテスト駆動開発、ビヘイビア駆動開発における代用の下位モジュールスタブの一種。スタブと比較して、検査対象のモジュールがその下位モジュールを正しく利用しているかどうかを検証するのに使われる。引用先
厳密に表現しようとすると難しくなるので、ざっくりの理解で良ければ、テストに差し支えがないように意味をなさない機能に置き換え(マッピング)することという感じでしょうか。
では、実際に Router の機能をモックして、テストが正しく動作するか確認していきましょう。
// __test__/sample.test.tsx import { render } from '@testing-library/react'; import Sample from 'pages/sample'; // モック用に追記 jest.mock('next/router', () => ({ useRouter() { return { asPath: '/', }; }, })); describe('初期表示の確認', () => { test('サンプルページ', () => { const { asFragment } = render(<Sample />); expect(asFragment()).toMatchSnapshot(); }); });
こちらでテストを実行してみましょう。
# テストの実行 yarn test __test__/sample.test.tsx PASS __test__/sample.test.tsx 初期表示の確認 ✓ サンプルページ (11 ms) › 1 snapshot written. ...
これで正しくテストが通るようになりました。
見ていただければお分かりかと思いますが、jest.mock() の中にモックしたい機能を選択して、その置き換える機能と一緒に定義します。
今回であれば、next/router の中の useRouter の機能の中にある asPath'/' に置き換えるという処理を書いていました。

Router の他の機能もモック

今回は userRouterasPath だけで良かったのですが、useRouter を使っていると他にも必要な機能をモックしていく必要があります。
毎回調べるのは大変ですので、ひとまずこれで設定しておけば OK!という共通のモックを作っておくことをお勧めします。
useRouter の引数として取ることができる変数を next/router の型として定義されている NextRouter を参考に、以下のように設定していきました。
// __test__/sample.test.tsx import { render } from '@testing-library/react'; import Sample from 'pages/sample'; // 変更 jest.mock('next/router', () => ({ useRouter() { return { route: '/', pathname: '/', query: {}, asPath: '/', basePath: '/', isLocaleDomain: true, isReady: true, push: jest.fn(), prefetch: jest.fn(), replace: jest.fn(), reload: jest.fn(), back: jest.fn(), beforePopState: jest.fn(), events: { on: jest.fn(), off: jest.fn(), emit: jest.fn(), }, isFallback: false, isPreview: false, }; }, })); describe('初期表示の確認', () => { test('サンプルページ', () => { const { asFragment } = render(<Sample />); expect(asFragment()).toMatchSnapshot(); }); });
この変更後にエラーなく実行できるかも確認しておきましょう。
# テストの実行 yarn test __test__/sample.test.tsx PASS __test__/sample.test.tsx 初期表示の確認 ✓ サンプルページ (12 ms) › 1 snapshot written. ...

モック機能を持った render を作成

このモック機能で正しく動作することは確認できましたが、毎回モックを書くのが面倒だと感じないでしょうか。
共通化をして適宜 import してくるのでも良いのですが、もっと良い方法があります。
それは React Testing Libary で使用している render コンポーネントをカスタムして、モック機能を持った render コンポーネントを作成してしまうことです。
これを一度作成しておくと、React Testing Library の要領で render を呼び出してもモック機能が付随しているため、モックがされていないといった小さなストレスのかかるエラーを回避できます。
ちなみに、この考え方は独自で思いついたわけではなく、Next.js ベースとしてをカスタマイズしている Blitz.js で採用されていた方法で、最初に見た時は目から鱗が落ちる思いでした。
Next.js のコミュニティでは浸透している方法ではあまり見かけないのですが、個人的にはとても良い方法だと思っています。
Blitz.js では独自の型や関数を用意して書かれているので、そのまま Next.js のプロジェクトに適用することができない点が頭を悩ませましたが、この点も fwywd チームですでに解決済みですのでご安心ください。
結論から先にお伝えすると、以下のようにカスタム render を作成するだけです。
// __test__/utils.ts import Queries from '@testing-library/dom/types/queries'; import { render as defaultRender, RenderResult } from '@testing-library/react'; import { RouterContext } from 'next/dist/next-server/lib/router-context'; import { NextRouter } from 'next/router'; import React from 'react'; export function render(children: React.ReactElement): RenderResult<typeof Queries, HTMLElement> { const mockRouter: NextRouter = { route: '/', pathname: '/', query: {}, asPath: '/', basePath: '/', isLocaleDomain: true, isReady: true, push: jest.fn(), prefetch: jest.fn(), replace: jest.fn(), reload: jest.fn(), back: jest.fn(), beforePopState: jest.fn(), events: { on: jest.fn(), off: jest.fn(), emit: jest.fn(), }, isFallback: false, isPreview: false, }; return defaultRender( <RouterContext.Provider value={mockRouter}>{children}</RouterContext.Provider>, ); }
React Testing Library の renderdefaultRender として使用し、render を再度定義しています。
RenderResultNextRouter などは TypeScript に対応させるために型の情報を見ながら調整していきました。
処理としては RouterContext というコンポーネントにモックに必要な情報を渡しているだけです。
こちらのカスタマイズした render を利用すると、以下のようにテストが非常にスッキリとします。
// __test__/sample.test.tsx // React Testing Library の render はコメントアウト // import { render } from '@testing-library/react'; import Sample from 'pages/sample'; // 新たに定義したカスタマイズ render を使用 import { render } from './lib/utils'; describe('初期表示の確認', () => { test('サンプルページ', () => { const { asFragment } = render(<Sample />); expect(asFragment()).toMatchSnapshot(); }); });
この変更後にエラーなく実行できるかも確認しておきましょう。
# テストの実行 yarn test __test__/sample.test.tsx PASS __test__/sample.test.tsx 初期表示の確認 ✓ サンプルページ (12 ms) › 1 snapshot written. ...
この lib/utils.ts でテストを効率よくかけるための処理をまとめていくと良いでしょう。

おわりに

今回はテストを書く上で避けては通れないモック機能について紹介しました。
モックの書き方自体はどこにでも情報が置いてあるのですが、カスタム render を作るという話をほとんど見かけないため、今回は Blitz.js の知見をお借りしながら Next.js 用にまとめました。
Blitz.js では renderHooks も準備していたため、また Hooks の処理が増えてきた頃にこの辺りも整理していきたいと思います。
この記事の情報で読者のみなさんの開発効率が向上することを願っています。
株式会社キカガク 代表取締役会長
吉崎 亮介
twitter: @yoshizaki_91