はじめに
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 のシェア機能などに便利であることがわかります。
テスト時にエラーへ遭遇
// __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 の他の機能もモック
今回は userRouter
の asPath
だけで良かったのですが、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 { RouterContext } from 'next/dist/shared/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 の render
を defaultRender
として使用し、render
を再度定義しています。
RenderResult
や NextRouter
などは 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 の処理が増えてきた頃にこの辺りも整理していきたいと思います。
この記事の情報で読者のみなさんの開発効率が向上することを願っています。