DIGGLE開発者ブログ

予実管理クラウドDIGGLEのプロダクトチームが、技術や製品開発について発信します

React Testing Libraryのrerenderを薄くラップすることで使い勝手を向上させた話

こんにちは。DIGGLE エンジニアの伊藤です。
ソフトウェアテスト Advent Calendar 2022の 20 日目の記事です。 Advent Calendar 初参加になります。

普段はフロントエンドを中心に業務を行なっています。
今回は DIGGLE で実施されているフロントエンドの単体テスト、 特にコンポーネントのテストで使っているフレームワークの使い勝手を向上させたお話をさせていただきます。

はじめに

DIGGLE ではフロントエンドで React を使っており、 テストフレームワークとして下記を利用しています。

Jest でモデルなどの React を介さない部分のテストを行い、React Testing Library を使って React コンポーネントのスナップショットテストを実施しています。React コンポーネントのテストで React Testing Library を使うか Enzyme を使うかといった違いはあるかもしれませんが、一般的によくある構成だと思います。

上記2つのフレームワークを用いることで DIGGLE でテストしたい項目を概ね満たすことができたのですが、 state を外部からコンポーネントに渡すテストを書こうとした際に上手く書けない問題がありました。

stateを外部からコンポーネントへ渡せない問題

例えば、React Testing Library を使って下記の様な Dropdown のコンポーネントをテストした際、

import { render } from '@testing-library/react';

---

it('Dropdownをクリックした時のテスト', async () => {
  const [value, setValue] = React.useState();
  render(<TestDropdown value={value} onClick={(e, v) => setValue(v)} option={...}/>);

// onClickした時のvalueの変化をテストしたい
});

としてuserEventでクリックイベントを発火させても value に値が反映されませんでした。
React Testing Library での関数は React の関数ではないので、React のフックを使おうとすると怒られるということで、 考えてみれば当然の話でした。
(Storybook では useState を使えたため、React Testing Library でも使えるのでは?と勘違いしたことが原因ですが、 おかげで Storybook と React Testing Library のスタンスの違いを感じることができました。)

上記の問題解消のためには、Jest + React Testing Library 構成の中で state を外部から props で渡すコンポーネントに関して「useState をモックしたい」という話になるのですが、

結論から先に書いてしまうと React Testing Library の rerender を活用することで問題を解決しました。

解決にあたって

解決にあたって候補になった方法は下記の2点でした

  1. rerender*1 を使って値を更新して擬似的に useState を表現する
  2. @testing-library/react-hooks というライブラリを使う

どちらの方法も甲乙つけがたいのですが、最終的には「1. rerender を使って値を更新して擬似的に useState を表現する」を採用することにしました。

「2. @testing-library/react-hooks というライブラリを使う」を採用しなかった理由は、

  • @testing-library/react-hooks はカスタムフックのテストに対する解決方法という色が強そう
    • DIGGLE ではカスタムフックの利用が活発でなく、導入の旨味が薄い
      • 展開のコストも考えると、急いで入れる必要はなさそう
  • React v18.0 対応を見てみると、 @testing-library/react-hooks の ver がサッと上がらない様子だった
    • 急いで入れて旨味を感じられず、管理の手間が増えるということになりかねない
  • 「1. rerender を使って値を更新して擬似的に useState を表現する」を実施する場合は薄くラップする必要があるとわかっていたため、「2. @testing-library/react-hooks というライブラリを使う」は必要になったタイミングでラッパーの中で切り替えれば良い

と考えたためです。

ただ、「1. rerender を使って値を更新して擬似的に useState を表現する」の方法にも問題はあり、 なにもせずに利用しようとするとほぼ同じ記述を 2 回することになってしまいます。

「rerender を使って値を更新して擬似的に useState を表現する」の問題点

愚直にテストを記述すると下記の形になります。( it 関数の中身だけを記載しています)

let rerender = _.noop;

const handleClick = jest.fn((value) => {
  rerender(<TestDropdown value={value} onClick={(e, v) => handleClick(v)} />);
});

const { rerender as TLRerender } = render(
  <TestDropdown value={value} onClick={(e, v) => handleClick(v)} />
);

rerender = TLRerender;

// onClickした時のvalueの変化をテスト

rerender を活用することにより、onClick が発火した際にvを与えながら再描画をhandleClick経由で行える様になります。

「useState をモックしたい」は上記で満たすことができたものの、TestDropdown をほぼ同じ形で2回書かないといけないことがわかると思います。

今回の様なシンプルな props ならまだ耐えられますが、 大量に props を渡す必要のあるコンポーネントに対するテストや onClick、onBlur など複数イベントに rerender の関数をセットしたい際に記述が煩雑になりそうなことがわかります。

問題点の解消 - rerender を薄くラップする

タイトルの通り、rerender を薄くラップすることで同じ記述を 2 回しなければならない問題を解決しました。

用意したラッパーは下記になります。

import { render as TLRender } from '@testing-library/react';
import _ from 'lodash';

export default class RerenderHelper {
  #rerender = _.noop;

  #render = _.noop;

  #firstRenderValue = null;

  constructor(render = _.noop, firstRenderValue = null) {
    this.#render = render; // render関数
    this.#firstRenderValue = firstRenderValue; // 初期描画時に利用されるvalueの値
  }

  // 再描画
  rerenderComponent = jest.fn((value) => {
    const r = this.#render(value, (v = value) => this.rerenderComponent(v));
    this.#rerender(r);
  });

  // 初期描画
  firstRender() {
    const { rerender } = TLRender(
      this.#render(this.#firstRenderValue, (v = this.#firstRenderValue) =>
        this.rerenderComponent(v)
      )
    );
    this.#rerender = rerender;
  }
}

やっていることはシンプルで、 同じ記述をしている部分を #render として持たせ、初期描画と再描画処理でそれぞれ呼び出しています。

また、#render を (value, setValue) => {} という形の関数で渡せる様にすることで、 使い勝手を useState に寄せています。

使い方は下記になります。

import Rerender from 'helpers/rerender';

---

it('Dropdownをクリックした時のテスト', async () => {
  const r = new Rerender((value, setValue) => {
    return (
      <TestDropdown value={value} onChange={(v) => setValue(v)} option={...}/>
    );
  });
  r.firstRender();

  // onClickした時のvalueの変化をテスト
});

上記ラッパーでできることは限られているものの、カスタムフックを使っていない環境であれば問題ないと思います。

まとめ

今回は DIGGLE のフロントエンドテストの環境を、フレームワークの一機能をラップすることで向上させた事例について紹介しました。

フロントエンドに限らずテストではさまざまなモックがされるかと思います。
モックする箇所や基準は様々だと思いますが、必要な部分に絞って慎重にモックを適用していく一例として今回の事例が参考になれば幸いです。

モックの濫用を防ぐ意味でも、カスタムフックの活用が活発になるまでのつなぎとして役割を果たしてくれると思います。

We're hiring!

今あるものを更により良くするための方法を、我々と一緒に模索してくれる開発メンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net

*1:rerender自体の説明は公式にあるためここでは割愛させていただきます https://testing-library.com/docs/react-testing-library/api/#rerender