かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React Hooks Jest + enzyme + act で useEffect を含むコンポーネントのテストする

react-create-app で作成した React Hooks を使ったアプリケーションのテストのメモ

TL;DR

下書き途中にしたまま数ヶ月が経過してしまったので、少し情報が古くなってしまってるかもですが書きかけていた Jest + enzyme + act で React Hooks のテストをしてみたエントリーを揚げます🍤

Jest

FacebookJavaScriptユニットテストツール群。 Snapshot Test などさまざまな単体テストを書くためのfunction群と、テストを実行するテストランナーが含まれている。
create-react-app で作成したアプリにはデフォルトで入っている? (最新では ReactTestUtils になってるかも )

Jest の特徴

多くのライブラリは テストランナー・アサーション・テストモック など機能別にライブラリが分かれていて必要に応じて組み合わせて使う必要があったが、Jest はこれひとつで基本的なテストができるの機能が含まれるので、ライブラリの依存などの問題がなく、テストをするためのライブラリ選定や環境作成の手間から開放される。
cf. Jestで始める! ユニットテスト - 環境の準備とテストの実行 | CodeGrid

it(testName, fn) or test(testName, fn) でテストスコープを作成して、expect() で評価する

it('foo is 1', () => {
  expect(foo).toBe(1);
});
it('bar is not 1', () => {
  expect(bar).not.toBe(1);
});

cf. Using Matchers · Jest

Enzyme

Airbnb 製 React のテストユーティリティ。 React の公式ドキュメントでも仕様が推奨されていいて、React のテストは Jest + Enzyme で行うのが鉄板になっているっぽい。

Enzyme の特徴

テストするコンポーネントに紐付いた子コンポーネントなしにレンダリングする shallow や、子コンポーネントを含んでレンダリングする mount などの機能がある。
jQuery の影響を受けているようで jQuery のような書き方でセレクタ指定で JSX のDOMにアクセスできる API も用意されていて、直感的にテスト対象を指定できるようになる。 (querySelector の指定方法も jQuery の影響受けてる気がする)

intsall

$ yarn add --dev enzyme
React v.16 系で使用する場合

Enzyme v2.x では、React v.16 のアプリで使うには設定する必要がある。 Enzyme v3 からは React v.16 がサポートされるらしい。

$ yarn add --dev enzyme-adapter-react-16

Settings

設定ファイルは別に用意しておき import するのが楽だと思います。

import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

configure({ adapter: new Adapter() });

Debug

shallowmountレンダリングしているコンポーネントがどうなっているのか、debug() で確認することができる。

const app = shallow(<App />);
console.log( app.debug() );

cf. debug() · Enzyme

act() from 'react-dom/test-utils'

act() 内で実行することで useEffect Hook を含む結果をテストすることができる。
jest, enzyme だけだと effect 系の Hooks を含むテストが上手くできない。

act を使ったテスト

act 無しで effect のテストが落ちるパターン

App.js

// App.js
import React, { useState, useEffect } from 'react';

function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times.`;
  });
  const countup = () => setCount(count + 1);
  return (
    <div className="app">
      <p>You clicked 
            <span className="count">${count}</span>
             times.</p>
      <button onCLick={countup}>COUNT UP</button>
    </div>
  );
}
export default App;

__test__/App.test.js

import React from 'react';
import './enzyme.setup.js'; // adapter for react 16
import { shallow, mount } from 'enzyme';
import App from '../App';

let container;
beforeEach(() => {
  container = global.document.createElement('dev');
  global.document.body.appendChaild(container);
  global.document.title = "React App";
});
afterEach(() => {
  global.document.body.removeChaild(container);
  container = null;
});

describe('Button on click', () => {
  const app = mount(<App />, { attachTo: container });
  app.find('button').simulate('click');
    it('count is 1', () => {
    expect(app.find('.count').text()).toEqual("1");
  });
    it('Title is "You clicked 1 times."', () => {
    expect(document.title).toEqual('You clicked 1 times.'); // => Failed 
  });
});

effect (副作用) である useEffect の処理の実行が取れないので、document.title を変更している Title is "You clicked 1 times. のテストは落ちる
👇

$ yarn run test
Button on click › Title is "You clicked 1 times."
expect(received).toEqual(expected) // deep equality

Expected: "You clicked 1 times."
Received: "React App"

act (react-dom/test-utils) で使って effect のテストを通るようにする

act() 内でコンポーネントレンダリングを行うことで、コンポーネントの effect (副作用) を含んでテストを行うことが出来る。
先の例は act() を使って 次のように書き換えます。

import React from 'react';
import { act } from 'react-dom/test-utils';
import './enzyme.setup.js';
import { shallow, mount } from 'enzyme';
import App from '../App';

// 中略
describe('Button on click', () => {
  // act で実行される effect の状態は act() のある同スコープ内でのみ引き継がれる
  it('Count is 1 & Title become "You clicked 1 times."', () => {
    // act() 内でレンダリングするので let にする
    let app;
    // First Render with Effect
    act(() => {
      app = mount(<App />, { attachTo: container });
    });
    
    console.log( app.find('.count').text() );
    // => 0
    console.log( document.title );
    // => You clicked 0 times. ... useEffect が実行されている
    
    // Second Render with Effect
    act(() => {
      app.find('button').simulate('click');
    });
    
    expect(app.find('.count').text()).toEqual("1");
    expect(document.title).toEqual('You clicked 1 times.');
  });
});

act() に中で APP に対するアクションを実行することで useEffect 内の処理が取れるようになるので、document.title を更新するテストも通るようになります。

act を使う時の注意点

act を使ったテストは act() を実行しているスコープ内でのみ引き継がれるので、act() を実行している it, describe直下に expect() を置くある必要あがある。
つまり複数の it を使いたい場合は it 内で都度レンダリングから行う必要がある。

次のような書き方はeffect の実行後の状態が引き継がれないので、Title is "You clicked 1 times.のテストが落ちる

describe('on clicked', () => {
  let app;
  // First Render with Effect
  act(() => {
    app = mount(<App />, { attachTo: container });
  });
  console.log( app.find('.count').text() );
  // => 0
  console.log( document.title );
  // => You clicked 0 times.
  
  // Second Render with Effect
  act(() => {
    app.find('button').simulate('click');
  });
  console.log( app.find('.count').text() );
  // => 1
  console.log( document.title );
  // => You clicked 1 times.
  // ※ act() のあるスコープでは effect の結果が引き継がれている

  it('count is 1', () => {
    expect(app.find('.count').text()).toEqual("1");
  });
  
  it('Title is "You clicked 1 times.', () => {
    expect(document.title).toEqual('You clicked 1 times.');
    // => "React App"
    // ※ 別スコープになると、effect の結果は引き継がれない
  });
});

👇 Run test

$ yarn run test
 on clicked
    ✓ count is 1 (3ms)
    ✕ Title is "You clicked 1 times. (2ms)

  ● on clicked › Title is "You clicked 1 times.

    expect(received).toEqual(expected) // deep equality

    Expected: "You clicked 1 times."
    Received: "React App"

先の effect のあるテストの describe 内で複数の it を使いたい場合は次のようにする必要がある

describe('on clicked', () => {
  let app;
  it('count is 1', () => {
    app = shallow(<App />);
    app.find('button').simulate('click');
    expect(app.find('.count').text()).toEqual("1");
  });
  
  it('Title is "You clicked 1 times.', () => {
    // First Render with Effect
    act(() => {
      app = mount(<App />, {attachTo: container});
      console.log( document.title );
      // => You clicked 0 times.
      // Second Render with Effect
      act(() => {
        app.find('button').simulate('click');
      });
      console.log( document.title );
      // => You clicked 1 times.
      expect(document.title).toEqual('You clicked 1 times.');
      app.unmount();
    });
  });
});

 

所感

テストをするには他にも Mock Functions とか色々と必要なこともありますが、とりあえす effect は jest だけではテストできなかったのでちょいハマりどころかもと思いました。
そして、いつの間にか React の公式ドキュメントの日本語ページが充実しててマジヤヴァイ。感謝の念しか無い (こんなエントリー読まなくても公式読めば解決すると思う。)


[参考]

Atomic Design ~堅牢で使いやすいUIを効率良く設計する

Atomic Design ~堅牢で使いやすいUIを効率良く設計する

👆 かわいい