React Hooks のテストを react-hooks-testing-library で書く

React Hooksのテストには react-testing-library を使っていたのですが、 react-hooks-testing-library を試したところ、なかなか良かったので簡単に紹介します。

react-hooks-testing-library とは

GitHub はこちら: https://github.com/testing-library/react-hooks-testing-library

Hooksをテストする際に、コンポーネントのコンテキスト内で実行するのでなく、 Hooksを直接呼び出してテストできるライブラリです。

もともとは react-testing-library と完全に別のパッケージだったようですが、

  • react-testing-library でも似たような機能を開発中であったこと
  • react-hooks-testing-library の作者が react-testing-library を意識して作成していたこと

から、最近 testing-library ファミリーに移動したようです。

useStateのテスト

たとえばこのような useState を使った Hooks をテストするとします。 単純に、Menuの開閉を状態に持ち、toggleする関数を作っただけです。

import { useState } from 'react';

export const useMenuOpen = () => {
  const [isOpen, setIsOpen] = useState(false);
  const toggle = () => setIsOpen(!isOpen);
  return { isOpen, setIsOpen, toggle };
};

これのテストは、下のように書けます。 ここで react-hooks-testing-library から import している act() は、'react-test-renderer'が提供しているものと同じものです。

import { act, renderHook } from '@testing-library/react-hooks';
import { useMenuOpen } from './useMenuOpen';

it('should toggle', () => {
  const { result } = renderHook(() => useMenuOpen());
  // 初期状態
  expect(result.current.isOpen).toBe(false);
 
  // 開閉する 
  act(() => {
    result.current.toggle();
  });
  expect(result.current.isOpen).toBe(true);

  // もう一度開閉する
  act(() => {
    result.current.toggle();
  });
  expect(result.current.isOpen).toBe(false);
});

react-testing-library を使うときように、何らかのコンポーネントレンダリングする必要はありません。 純粋にHooksだけのテストを書くことができます。

つかいどころ

react-hooks-testing-library を使えば、 適当なコンポーネントを記載する必要がないので、 Hooksのロジックのテストだけに集中できます。

しかし、Hooksのテストをすべて置き換えるものではなく、状況に応じて使い分けるべきです。

react-testing-library の README では、このように書いてあります。

NOTE it is not recommended to test single-use custom hooks in isolation from the components where it's being used. It's better to test the component that's using the hook rather than the hook itself. The React Hooks Testing Library is intended to be used for reusable hooks/libraries.

つまり、特定のコンポーネントに紐付いたHooksなどは、これまで同様に react-test-library を使ってコンポーネントそのものをテストと良いでしょう。クリック時のHooksの挙動などをテストする場合ですね。

一方、特定のコンポーネントに結びついていないHooks、例えば汎用的に使うHooksやパッケージ用に書いているHooksなどは、こちらの react-hooks-testing-library を使うほうがテストが見通しよく書けると思います。

以上です。

とりあえずしばらく使ってみようと思います。

Snapshotの結果がローカルとCircleCIで違っていた

Reactアプリ開発では Snapshot テストをよく使うんですが、あるとき ローカルとCircleCI上で作成したのSnapshotの結果が異なり、テストが落ちるという現象が発生しました。

これは少し調べたところ、Nodeのバージョン違いによってJest実行結果が異なっていたためでした。

当時のローカル(Mac)マシンの Node のバージョンは、

$ node -v
v12.8.0

ですが、CircleCIのNodeは、

version: 2
jobs:
  test:
    docker:
      - image: circleci/node:10.5.0

でした。

というわけで以下の手順で node をダウングレード。

$ brew unlink node
$ brew link node@10
$ brew link --force --overwrite node@10
$ node -v
v10.16.3

厳密にはまだバージョンが異なってましたが、Snapshotの結果はこれで一致。

webpackプロジェクトのCircleCI上のビルドに失敗する

webpackを使っているSPAプロジェクトを、CircleCIでビルドしようとしたら以下のエラーが出て失敗した。

npm run build

> my_project@1.0.0 build:staging /home/circleci/repo
> webpack --config ./webpack.config.js

Starting type checking service...
Using 1 worker with 2048MB memory limit
events.js:167
      throw er; // Unhandled 'error' event
      ^

Error: read ECONNRESET
    at Pipe.onread (net.js:656:25)
Emitted 'error' event at:
    at emitErrorNT (internal/streams/destroy.js:82:8)
    at emitErrorAndCloseNT (internal/streams/destroy.js:50:3)
    at process._tickCallback (internal/process/next_tick.js:63:19)
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! casting_asia_advertiser_frontend@1.0.0 build:staging: `webpack --config ./webpack.config.staging.js`
npm ERR! Exit status 1
npm ERR! 
npm ERR! Failed at the casting_asia_advertiser_frontend@1.0.0 build:staging script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     /home/circleci/.npm/_logs/2019-08-07T08_22_22_942Z-debug.log
Exited with code 1

結論から言うと、ビルドに thread-loader を使っていたことが原因だった。

この Issue によると、

Perhaps this value should get set when CI:true to something lower, or expose an environment variable that would allow overriding that value.

ということなので、CI上のときはworkerの数を減らしてみる。

CI上かどうかは、CircleCIの場合は CI=true という環境変数で判別できる。

// 省略

// CIかどうかを環境変数から判別
const CI = process.env.CI;

module.exports = {
  // 省略
  module: {
    rules: [
      {
        test: /\.(j|t)sx?$/,
        exclude: /node_modules/,
        use: [
          // 省略
          {
            loader: 'thread-loader',
            options: {
              // CI上なら workerの数を減らす。そうでないならデフォルトの値で。
              workers: CI ? 4: undefined
            },
          },
          // 省略
       }
    ]
  },
};

ビルドできました。