🎃

Next.js にテストを導入し hooks のテストを書く

2021/10/27に公開

はじめに

前回コンポーネントと状態管理のファイル分割と設計について記事を書き、設計のメリットとして「テストのしやすさ」を上げました。

今回は前回のブログで作った hooks を例にしてテストについて書いていこうと思います。

https://zenn.dev/tiwu_dev/articles/2504a20732d305

導入

Next.js 公式ドキュメントを参考に導入していきます。

https://nextjs.org/docs/testing

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 の設定ファイルを作ります。

jest.config.js
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

カバレッジの設定

https://jestjs.io/ja/docs/configuration#collectcoveragefrom-array

moduleNameMapper

CSS モジュールや画像などをモックする

https://jestjs.io/ja/docs/configuration#modulenamemapper-objectstring-string--arraystring

https://jestjs.io/ja/docs/webpack#cssモジュールのモック

https://jestjs.io/ja/docs/webpack#静的アセットの管理

testPathIgnorePatterns

テストを無視するパス

https://jestjs.io/ja/docs/configuration#testpathignorepatterns-arraystring

transform

babel を使う設定

https://jestjs.io/ja/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object

transformIgnorePatterns

transform しないパス

https://jestjs.io/ja/docs/configuration#transformignorepatterns-arraystring

CSS,画像のモック

CSS や画像のインポートでエラーになる可能性があるのでモックを作ります。

__mocks__/fileMock.js
(module.exports = "test-file-stub")
__mocks__/styleMock.js
module.exports = {};

もし "Failed to parse src "test-file-stub" on 'next/image'" といったエラーが出る際は先頭に / をつければ良いとのこと。

__mocks__/fileMock.js
(module.exports = "/test-file-stub")

テストの作成

動作確認のため簡単なテストを書いていきます。

テスト対象。

lib/util/zeroFill.ts
export const zeroFill = (val: number): string => {
  return (`0${val}`).slice(-2);
};

テスト。

lib/util/__tests__/zeroFill.spec.ts
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 を導入します。

https://www.npmjs.com/package/eslint-plugin-jest

npm install --save-dev eslint-plugin-jest

ESLint に設定を追加すればエラーは消えます。

.eslintrc.js
{
  "env": {
    "jest/globals": true
  },
  "plugins": ["jest"]
}

package.json にスクリプトを追加し

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 を導入します。

https://github.com/testing-library/react-hooks-testing-library

npm install --save-dev @testing-library/react-hooks

テスト対象

前回のブログで作った hooks です。

具体的には自分が開発しているサイトのツイッターの呟き一覧をキャラとLPで検索できるページです。

https://enjoy-sfv-more.com/lounge/tweet

一部省略していますが

  • useEffect でツイートを取得
  • ユーザーが選択した LP を保持
  • 検索処理で選択した LP を設定、ツイートを絞り込む

といった機能を持っています。

hooks/lounge/useLoungeTweet.ts
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,
  };
};

テストの作成

hooks/lounge/__tests__/useLoungeTweet.test.ts
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 で絶対パスを解決するために moduleNameMapperlib を追加しています。

jest.config.js
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();
});

useEffectstate を書き換えてなければ以下のように 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 の値が反映されてないため意図しない挙動を起こします。

カバレッジ

カバレッジ表示のためスクリプトを追加します。

package.json
"scripts": {
  "test:coverage": "jest --coverage"
}

実行すると Next.js PJ の .next, coverage, public 配下もカバレッジの対象にしてしまうので無視する設定を jest に追加します。

jest.config.js
collectCoverageFrom: [
    '!**/.next/**',
    '!**/coverage/**',
    '!**/public/**',
  ],

GitHub Actions

GitHub Actions で push の度にテストを実行させます。

https://docs.github.com/ja/actions/quickstart

を参考に GitHub Actions を作成。

.github/workflows/ci.yml
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

最終的な設定ファイルたち

※関係ありそうなところだけ書いています

.eslintrc.js
module.exports = {
  env: {
    'jest/globals': true,
  },
  plugins: [
    'jest',
  ],
};
jest.config.js
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)$',
  ],
};
package.json
{
  "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 のままになっていて諦めました・・・

hooks/lounge/useLoungeTweet.ts
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