Next.js にテストを導入し hooks のテストを書く
はじめに
前回コンポーネントと状態管理のファイル分割と設計について記事を書き、設計のメリットとして「テストのしやすさ」を上げました。
今回は前回のブログで作った hooks を例にしてテストについて書いていこうと思います。
導入
Next.js 公式ドキュメントを参考に導入していきます。
Cypress
, Playwright
, Jest and React Testing Library
の3つ紹介されていますが、今回は Jest and React Testing Library
を導入します。
インストール
既に Next.js の PJ を作っていくるので Manual setup を参考にインストールします。
npm install --save-dev jest babel-jest @testing-library/react @testing-library/jest-dom identity-obj-proxy react-test-renderer
設定
インストール後は jest の設定ファイルを作ります。
module.exports = {
collectCoverageFrom: [
'**/*.{js,jsx,ts,tsx}',
'!**/*.d.ts',
'!**/node_modules/**',
],
moduleNameMapper: {
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
'^.+\\.(css|sass|scss)$': '<rootDir>/__mocks__/styleMock.js',
'^.+\\.(jpg|jpeg|png|gif|webp|svg)$': `<rootDir>/__mocks__/fileMock.js`,
},
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/.next/'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }],
},
transformIgnorePatterns: [
'/node_modules/',
'^.+\\.module\\.(css|sass|scss)$',
],
}
簡単に各項目を紹介します。
collectCoverageFrom
カバレッジの設定
moduleNameMapper
CSS モジュールや画像などをモックする
testPathIgnorePatterns
テストを無視するパス
transform
babel を使う設定
transformIgnorePatterns
transform しないパス
CSS,画像のモック
CSS や画像のインポートでエラーになる可能性があるのでモックを作ります。
(module.exports = "test-file-stub")
module.exports = {};
もし "Failed to parse src "test-file-stub" on 'next/image'"
といったエラーが出る際は先頭に /
をつければ良いとのこと。
(module.exports = "/test-file-stub")
テストの作成
動作確認のため簡単なテストを書いていきます。
テスト対象。
export const zeroFill = (val: number): string => {
return (`0${val}`).slice(-2);
};
テスト。
import { zeroFill } from '../zeroFill';
describe('zeroFill', () => {
it('1桁', () => {
expect(zeroFill(1)).toEqual('01');
});
it('2桁', () => {
expect(zeroFill(11)).toEqual('11');
});
});
テストコードが ESLint に 'describe' is not defined.
, 'it' is not defined.
, 'expect' is not defined.
と起こられるので eslint-plugin-jest
を導入します。
npm install --save-dev eslint-plugin-jest
ESLint に設定を追加すればエラーは消えます。
{
"env": {
"jest/globals": true
},
"plugins": ["jest"]
}
package.json にスクリプトを追加し
"scripts": {
"test": "jest --watch"
}
npm run test
で無事成功です!
PASS lib/util/__tests__/zeroFill.spec.ts
zeroFill
✓ 1桁 (2 ms)
✓ 2桁
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.224 s, estimated 2 s
Ran all test suites related to changed files.
Watch Usage: Press w to show more.
hooks のテスト
導入
hooks のテストのため react-hooks-testing-library を導入します。
npm install --save-dev @testing-library/react-hooks
テスト対象
前回のブログで作った hooks です。
具体的には自分が開発しているサイトのツイッターの呟き一覧をキャラとLPで検索できるページです。
一部省略していますが
-
useEffect
でツイートを取得 - ユーザーが選択した LP を保持
- 検索処理で選択した LP を設定、ツイートを絞り込む
といった機能を持っています。
import { getTweet } from 'lib/tweet';
export const useLoungeTweet = () => {
const [tweetList, setTweetList] = useState([]);
const [allTweetList, setAllTweetList] = useState([]);
const [selectLpList, setSelectLpList] = useState([]);
const [selectLpText, setSelectLpText] = useState('指定なし');
useEffect(() => {
(async () => {
const tmpTweetList = await getTweet();
setTweetList(tmpTweetList);
setAllTweetList(tmpTweetList);
})();
}, []);
const search = () => {
if (selectLpList.length === 0) {
setSelectLpText('指定なし');
} else {
setSelectLpText(selectLpList.map((_lp) => {
return _lp.name;
}).join('\n'));
}
const searchTweetList = allTweetList.filter((tweet) => {
// 検索処理
});
setTweetList(searchTweetList);
};
const setSelectLp: SET_SELECT_LP = (lpId: string) => {
const lp = lps.getLpById(lpId);
if (selectLpList.includes(lp)) {
setSelectLpList(selectLpList.filter((l) => l.id !== lp.id));
} else {
setSelectLpList([...selectLpList, lp]);
}
};
const checkedLp = (lp) => {
return selectLpList.includes(lp);
};
return {
tweetList,
selectLpText,
setSelectLp,
checkedLp,
search,
};
};
テストの作成
import { act, renderHook, RenderResult } from '@testing-library/react-hooks';
import * as getTweetModlue from 'lib/tweet';
jest.spyOn(getTweetModlue, 'getTweet').mockResolvedValue([{
text: 'リュウ ルーキー',
}, {
text: 'リュウ',
}, {
text: 'a',
}]);
test('useLoungeTweet', async () => {
let data: RenderResult<ReturnType<typeof useLoungeTweet>>;
await act(async () => {
const { waitForNextUpdate, result } = renderHook(() => useLoungeTweet(characters));
data = result;
await waitForNextUpdate();
});
const lp1 = lps.getLpById('1');
const lp2 = lps.getLpById('2');
// 初期値
expect(data.current.tweetList.length).toStrictEqual(3);
expect(data.current.selectLpText).toStrictEqual('指定なし');
expect(data.current.checkedLp(lp1)).toStrictEqual(false);
expect(data.current.checkedLp(lp2)).toStrictEqual(false);
act(() => {
data.current.setSelectLp(lp1.id);
});
// 値のセット
expect(data.current.tweetList.length).toStrictEqual(3);
expect(data.current.selectLpText).toStrictEqual('指定なし');
expect(data.current.checkedLp(lp1)).toStrictEqual(true);
expect(data.current.checkedLp(lp2)).toStrictEqual(false);
act(() => {
data.current.search();
});
// 検索
expect(data.current.tweetList.length).toStrictEqual(1);
expect(data.current.selectLpText).toStrictEqual(lp1.name);
expect(data.current.checkedLp(lp1)).toStrictEqual(true);
expect(data.current.checkedLp(lp2)).toStrictEqual(false);
});
useLoungeTweet
では import { getTweet } from 'lib/tweet';
のように絶対パスで import
しています。
Jest で絶対パスを解決するために moduleNameMapper
に lib
を追加しています。
module.exports = {
moduleNameMapper: {
'^lib/(.*)$': '<rootDir>/lib/$1',
},
};
解説
まず、 useEffect
で外部 API を呼びツイートを取得している関数 getTweet
のモックを作ります。
これは hooks は関係なく、Jest の機能です。
jest.spyOn(getTweetModlue, 'getTweet').mockResolvedValue([{
text: 'リュウ ルーキー',
}, {
text: 'リュウ',
}, {
text: 'a',
}]);
useLoungeTweet
では useEffect
内で state
を書き換えているため、 act
内で renderHook
を実行し結果を保持します。
また、useEffect
内の処理を待つため、await waitForNextUpdate();
を実行します。
result
を別の変数に代入するため、型定義を RenderResult
を利用して書いています。
let data: RenderResult<ReturnType<typeof useLoungeTweet>>;
await act(async () => {
const { waitForNextUpdate, result } = renderHook(() => useLoungeTweet(characters));
data = result;
await waitForNextUpdate();
});
useEffect
で state
を書き換えてなければ以下のように act
を利用せずテストを書くことができます。
test('useHoge', () => {
const { result } = renderHook(() => useHoge());
expect(result.current.fuga).toStrictEqual(1);
});
result
で hooks の変数や関数を利用することができるため、 act
内で状態変更する関数を実行し、都度変数の値をテストしています。
const lp1 = lps.getLpById('1');
const lp2 = lps.getLpById('2');
// 初期値
expect(data.current.tweetList.length).toStrictEqual(3);
expect(data.current.selectLpText).toStrictEqual('指定なし');
expect(data.current.checkedLp(lp1)).toStrictEqual(false);
expect(data.current.checkedLp(lp2)).toStrictEqual(false);
act(() => {
data.current.setSelectLp(lp1.id);
});
// 値のセット
expect(data.current.tweetList.length).toStrictEqual(3);
expect(data.current.selectLpText).toStrictEqual('指定なし');
expect(data.current.checkedLp(lp1)).toStrictEqual(true);
expect(data.current.checkedLp(lp2)).toStrictEqual(false);
act(() => {
data.current.search();
});
// 検索
expect(data.current.tweetList.length).toStrictEqual(1);
expect(data.current.selectLpText).toStrictEqual(lp1.name);
expect(data.current.checkedLp(lp1)).toStrictEqual(true);
expect(data.current.checkedLp(lp2)).toStrictEqual(false);
注意点としては act
を抜けるまでは状態変更が反映されません。
act(() => {
data.current.setSelectLp(lp1.id);
data.current.search();
});
このように状態変更処理の後に状態を利用した処理を実行すると、search
内では lp1
の値が反映されてないため意図しない挙動を起こします。
カバレッジ
カバレッジ表示のためスクリプトを追加します。
"scripts": {
"test:coverage": "jest --coverage"
}
実行すると Next.js PJ の .next
, coverage
, public
配下もカバレッジの対象にしてしまうので無視する設定を jest に追加します。
collectCoverageFrom: [
'!**/.next/**',
'!**/coverage/**',
'!**/public/**',
],
GitHub Actions
GitHub Actions で push の度にテストを実行させます。
を参考に GitHub Actions を作成。
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v2
- name: NPM Install
run: npm install
- name: Run Test
run: npm run test
最終的な設定ファイルたち
※関係ありそうなところだけ書いています
module.exports = {
env: {
'jest/globals': true,
},
plugins: [
'jest',
],
};
module.exports = {
collectCoverageFrom: [
'**/*.{js,jsx,ts,tsx}',
'!**/*.d.ts',
'!**/node_modules/**',
'!**/.next/**',
'!**/coverage/**',
'!**/public/**',
],
moduleNameMapper: {
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
'^.+\\.(css|sass|scss)$': '<rootDir>/__mocks__/styleMock.js',
'^.+\\.(jpg|jpeg|png|gif|webp|svg)$': '<rootDir>/__mocks__/fileMock.js',
'^class/(.*)$': '<rootDir>/class/$1',
'^lib/(.*)$': '<rootDir>/lib/$1',
'^data/(.*)$': '<rootDir>/data/$1',
},
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/.next/'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }],
},
transformIgnorePatterns: [
'/node_modules/',
'^.+\\.module\\.(css|sass|scss)$',
],
};
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"dependencies": {
"react": "17.0.1",
"react-dom": "17.0.1",
},
"devDependencies": {
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.2",
"@testing-library/react-hooks": "^7.0.2",
"@types/node": "^14.17.11",
"@types/react": "^17.0.27",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"babel-jest": "^27.1.0",
"eslint": "^7.32.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-plugin-import": "^2.24.2",
"eslint-plugin-jest": "^24.5.2",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.1.0",
"react-test-renderer": "^17.0.2",
"typescript": "^4.3.5"
}
}
終わりに
useEffect
内の非同期処理 + 状態変更のテストに少し詰まりましたがそれ以外はスムーズに書けました!
テストが書きづらい時に「書きづらいということはいろいろな処理をしすぎの肥大化したファイルになっている?🤔」と自問自答することもあったり・・
今回だと「状態管理の hooks 内に検索のロジックが混ざっているので、ロジックは別ファイルにしたほうがいいな」など・・・!
何かの参考になればと思います!
余談
実は useRef
を hooks 内で使っているのですが、どうしてもモックができず初期値 null のままになっていて諦めました・・・
export const useLoungeTweet = () => {
const lpDetailsElm = useRef(null);
const search = () => {
const searchTweetList = allTweetList.filter((tweet) => {
// 検索処理
});
lpDetailsElm.current.removeAttribute('open');
};
return {
search,
};
};
他と同じようにモックを書いたのですがテストがエラーで落ちてしまいます。
jest.spyOn(React, 'useRef').mockReturnValueOnce({ current: { removeAttribute: jest.fn() } });
良い方法を知っている方がいたら是非🙏
Discussion