105
80

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Reactでレンダリング回数やレンダリングにかかる時間をユニットテストでテストできるライブラリを開発しました

Last updated at Posted at 2020-08-15

先日、react-performance-testing という React でレンダリング回数やレンダリングにかかる時間をテストできるライブラリを開発しました🎉

Reactをテストする時にレンダリング回数をテストしたいと思ったことがある人は多いはずです。実際、パフォーマンスを意識して開発しているときや、動作が重くなりやすいコンポーネントを開発している時には、レンダリング回数をテストして、自動で確認できるようにした方が開発効率はよくなります。今回開発した react-performance-testing はそのような課題を解決し、React におけるランタイムのパフォーマンスをユニットテストで簡単にテストできるライブラリーです。

使い方

インストール

npm:

npm install --save-dev react-performance-testing

yarn:

yarn add --dev react-performance-testing

レンダリング数をテストする

レンダリング数をテストするための基本的な使い方は、以下のような実装になります。

import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { perf, wait } from 'react-performance-testing';

test('countボタンをクリックした時に、2回レンダリングする', async () => {
  const Button = ({ name, onClick }) => (
    <button type="button" onClick={onClick}>{name}</button>
  );
  const Counter = () => {
    const [count, setCount] = React.useState(0);
    return (
      <div>
        <span>{count}</span>
        <Button name="count" onClick={() => setCount(c => c + 1)}/>
      </div>
    );
  };

  // perf メソッドの内部で createElement にモンキーパッチを当てているので、
  // React モジュールを引数に渡す必要があります。
  const { renderCount } = perf(React);

  fireEvent.click(screen.getByRole('button', { name: /count/i }));

  // renderCount.current 以下に各コンポーネントの名前がkeyとなるオブジェクトを含んでいて、
  // さらにその中の value プロパティにレンダリング数が含まれています。
  await wait(() => {
    expect(renderCount.current.Counter.value).toBe(2);
    expect(renderCount.current.Button.value).toBe(2);
  });
});

上記のような形でレンダリング数をテストすることができます。

レンダリングにかかる時間をテストする

レンダリングにかかる時間をテストするための基本的な使い方は、以下のような実装になります。

import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { perf } from 'react-performance-testing';

test('countボタンをクリックした時に、レンダリングにかかる時間が16ミリ秒以下になる', async () => {
  const Button = ({ name, onClick }) => (
    <button type="button" onClick={onClick}>{name}</button>
  );
  const Counter = () => {
    const [count, setCount] = React.useState(0);
    return (
      <div>
        <span>{count}</span>
        <Button name="count" onClick={() => setCount(c => c + 1)}/>
      </div>
    );
  };

  const { renderTime } = perf(React);

  fireEvent.click(screen.getByRole('button', { name: /count/i }));

  await wait(() => {
    // renderCount.current 以下に各コンポーネントの名前がkeyとなるオブジェクトを含んでいて、
    // さらにその中の mount プロパティと updates プロパティにレンダリングにかかる時間が含まれています。
    // 値はミリ秒単位で渡されます。
    expect(renderTime.current.Counter.mount).toBeLessThan(16); // 初期レンダリング
    // updatesは配列
    expect(renderTime.current.Counter.updates[0]).toLessThan(16); // state更新
  
});

上記のような形でレンダリングにかかる時間をテストできます。渡される値はミリ秒単位であることに注意してください

mountプロパティには初期レンダリング(マウント時)の時間を計測した結果が含まれます。

updatesプロパティは配列で渡されます。このプロパティにはstateを更新した時などに発生する、再レンダリングにかかる時間の計測結果が格納されています。

ここでの課題としてはupdatesプロパティをテストする時の体験がいまいちなところです。これに関しては、今後Utilメソッドのようなものを開発するか、@testing-library/jest-dom のような、jestを拡張するライブラリを開発する予定です。

注意: renderTimeを使う場合、テストは一つずつ行う必要があることに注意してください。計測プロセスには、Inline CachingというV8の最適化処理が入ってしまうため、最初のテストケースの結果と2回目のテストケースの結果が大きく変わってしまう可能性があるからです。Inline Cashingについてはこの記事がわかりやすいです。

複数のコンポーネントを含む場合

複数のコンポーネントを含む場合は、renderCountrenderTimeに含まれるコンポーネントは表示される順に配列で渡されます。

const Text = ({ text }) => <p>{text}</p>;
const Component = () => (
  <div>
    <Text text="1"/>
    <Text text="2"/>
    <Text text="3"/>
  </div>
);
const { renderCount } = perf(React);

wait(() => console.log(renderCount.current)); 
/* 
* { 
*   Component: { value: 1 },
*   Text: [{ value: 1 }, { value: 1 }, { value: 1 }]
* }
*/

複数のコンポーネントを含む場合、上記のように配列で渡されます。そして、各コンポーネントのプロパティはネストしているコンポーネントを区別せずにフラットな状態で渡されます。これは、このライブラリが小さな単位(ListやModalなど)でコンポーネントをテストするために作られたものだからです。小さなコンポーネントをテストするライブラリであるため、ネストした状態で値を保持するのは、コード量が増えたり、テストしにくくなってしまうと考えたためこのような設計になっています。

例えば、

expect(renderCount.current.Component.value).toBe(1);
expect(renderCount.current.Component.Text[0].value).toBe(1);

とするよりも

expect(renderCount.current.Component.value)toBe(1);
expect(renderCount.current.Text[0].value)toBe(1);

のように書けた方が、すぐにコンポーネントにアクセスできてテストを書きやすいと考えました。

そのため、Pageコンポーネントなどの多くの重複するコンポーネントを含む大きなコンポーネントをテストするケースでは使い勝手が悪いです。

しかし、今後、大きなコンポーネントでも開発体験を向上できるように改善していく予定です。

良い方法があればissueまたはPRを送っていただければ嬉しいです:bow:

推奨テストライブラリ

react-performance-testing を使う時に他のテストライブラリと併用して使うと思います。

Jest, Mocha, Jasmine では自動でクリーンアップなどの処理を行うため、これらのライブラリを合わせて使うことをお勧めします。これ以外のライブラリを使っている場合は、teardownのタイミングで react-performance-testingcleanupメソッドを実行してください。

TypeScript

TypeScript にも対応していて、型安全なテストを実装することができます。

const Text = ({ text }) => <p>{text}</p>;
const Component = () => (
  <div>
    <Text text="1"/>
    <Text text="2"/>
    <Text text="3"/>
  </div>
);

const Component = () => <div>test</div>;
const { renderCount } = perf<{ Component: unknown, Text: unknown[] }>(React);

Generics を使ってコンポーネントの名前をkeyに持つunknown型を定義することで、そのコンポーネントが配列になるのか、単純なオブジェクトなのかを定義できます。

この方法についてはもっと良いやり方がありそうなので、issueまたはPRお待ちしています:bow:

ReactNative

ReactNativeでも使うことができます。

ReactNativeで使うには、

import { perf } from 'react-performance-testing/native';

のようにインポートすることで、ReactNativeに対応したperfメソッドを使うことができます。

ざっくりとした仕組み

perfメソッドにはReactモジュールを渡しています。これは、React.createElementにモンキーパッチを当てて、渡されたコンポーネントを独自の関数でラップして、レンダリング回数やレンダリングにかかる時間を計測するようにしているからです。このような仕組みであるため、Reactモジュールを引数に渡す必要があります。

最後に

ここまで読んでいただきありがとうございました!

是非使ってみてください。

そしてフィードバックやGitHubに:star:をつけてくれたら、モチベーションになります:muscle:

GitHub: https://github.com/keiya01/react-performance-testing

追記(2020/08/21)

v1.2.0 で Breaking Changes が入りました。今まではrenderCountなどの変数を直接使うことができていましたが、今後は wait メソッドでラップする必要があります。
変更の経緯は、これまでは値の計測および変数への代入などの処理をレンダリング処理中に行っていました。この処理はわずかな処理であるため、通常はそこまで大きな誤差にはならないのですが、大きいコンポーネントや複雑な処理を含む場合、renderTimeに大きく影響する可能性があるため、レンダリング処理が終わったタイミングで値の計測などの処理を行うように変更しました。

105
80
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
105
80

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?