この記事は、ソフトウェアテストアドベントカレンダー2022の8日目の記事です。
Reactのテストについて書きたいと思います。
この記事を書くきっかけ
- タイトル通り、 Reactのテストを書いてて困っちゃいました
目的
- 今後同じように自分が困らないために
- 同じように困っている人の少しでも役に立てたら
前提条件
- React Testing LibraryとJestを使ってテストを書きます
- 僕が出会った困ったことをつらつらと書いていくので、どこから読まなきゃいけないとかないです
困ったこと
その1: unmountされたコンポーネントに対してReactの状態更新を実行できない
- どう困ったか
-
Can’t perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
というエラーがテスト実施時に出力されました - useEffect内部で外部へリクエストを飛ばしてsetStateしているときに起こるエラーです。レスポンスが帰ってくる前にunmountされると、上記のようなエラー文をReactは表示してくれます
- 一言で言うと、 unmountしているのにsetStateなんてやるなよ って感じです
- なのでどっちかというとReact Testing LibraryよりReactのお話です。確かにそうだな、と思い、コンポーネントのテストを実施してて良かったです
-
- どう解消したか
- useEffect内部でcleanupしてあげる、です
- 方法1: isMountedフラグを使用する
- 方法2: AbortControllerを使用する
- cleanupが必要ない場合もあるので、詳しくは副作用フックの利用法をご覧ください
- useEffect内部でcleanupしてあげる、です
その2: Material UIのSelectを使ったら、 userEvent.selectOptions が反応しなかった
- どう困ったか
- エラーは出ず、ただただテストに失敗しました
- どう解消したか
-
ちょっと邪道かもしれませんが、 userEvent.click を使用すると、無事要素の選択ができます
-
step1: まず選択できる状態にします
- 初期状態は選択肢が描画されていないので、 userEvent.click して選択肢を表示してあげます
-
step2: 選択した後の状態を把握します
- screen.debugを使用して以下のような出力を得られれば良い
出力例
console.log <ul class="MuiList-root MuiMenu-list MuiList-padding" role="listbox" tabindex="-1" > <li aria-disabled="false" class="MuiButtonBase-root MuiListItem-root MuiMenuItem-root makeStyles-MenuItem-20 makeStyles-MenuItem-33 MuiMenuItem-gutters MuiListItem-gutters MuiListItem-button" data-value="" role="option" tabindex="-1" > 選択なし <span class="MuiTouchRipple-root" /> </li> <li aria-disabled="false" aria-selected="true" class="MuiButtonBase-root MuiListItem-root MuiMenuItem-root makeStyles-MenuItem-20 makeStyles-MenuItem-33 Mui-selected MuiMenuItem-gutters MuiListItem-gutters MuiListItem-button Mui-selected" data-value="6049d210-71ea-11ed-bb12-3d770627c243" role="option" tabindex="0" > デモ選択肢1 <span class="MuiTouchRipple-root" /> </li> <li aria-disabled="false" class="MuiButtonBase-root MuiListItem-root MuiMenuItem-root makeStyles-MenuItem-20 makeStyles-MenuItem-33 MuiMenuItem-gutters MuiListItem-gutters MuiListItem-button" data-value="6049d213-71ea-11ed-bb12-3d770627c243" role="option" tabindex="-1" > デモ選択肢2 <span class="MuiTouchRipple-root" /> </li> </ul>
-
step3: 選択肢を選択します(クリックする)
- userEvent.clickを使用して選択すると、選択肢が閉じて初期状態(選択肢が全く描画されていない状態)に戻ります
-
-
以下のような確認ができればいいかなって思ってます
出力例
// 初期状態 expect(screen.getByRole('button', {name: 'デモ選択肢1'})).toBeInTheDocument(); expect(screen.queryByRole('button', {name: 'デモ選択肢2'})).not.toBeInTheDocument(); // material uiのselectは、role = button userEvent.click(screen.getByRole('button', {name: 'デモ選択肢1'})); // buttonを押すと、aria-haspopup属性を持つので、子要素が展開 userEvent.click(screen.getByRole('option', {name: 'デモ選択肢2'})); // selectOptionsだと反応しない。。。 // 変更後 expect(screen.queryByRole('button', {name: 'デモ選択肢1'})).not.toBeInTheDocument(); expect(screen.getByRole('button', {name: 'デモ選択肢2'})).toBeInTheDocument();
-
その3: DOM要素の特定ができない
- どう困ったか
- なし
- どう解消したか
- Material UIなど、UIライブラリを使っている場合や、色々と自前で実装している場合は独特なことも多いので、地道に screen.debug と logRoles を使用してDOM要素の検出方法を探ります
- DOM要素の検出方法は
- ByRole
- ByLabelText
- ByPlaceholderText
- ByText
- ByDisplayValue
- ByAltText
- ByTitle
- ByTestId
- これは最終奥義みたいな感じです
- 公式ドキュメントはこちら
- DOM要素の検出方法は
- 例えば日付変更しているコンポーネントあたりのテストしようかなーみたいな時は
-
そのあたりのDOM要素(諸々をwrapしているDOM要素だとなお良い)に
data-testid
を付与して -
以下のコードをとりあえず流してみる
コード
```tsx it('テストケース1', () => { const target = screen.getByTestId('hogehoge'); // data-testidの値 screen.debug(target); // これでDOM要素を全て表示してくれる logRoles(target); // 検出に使用できそうな情報を出力してくれる }) ```
出力例
```sh // screen.debug() の出力 console.log <div class="sc-fzoXWK iukjXR" data-testid="testWrapper" > <div class="sc-fzozJi dcvWQK" data-testid="rootWLabelContent" > <div class="sc-fzpans krqqYA" > <div class="sc-fzoLsD hbFvtU" > 属性1 </div> <div class="sc-fznZeY fLrtke" > <div class="sc-Axmtr cHbKVu" > 任意 </div> </div> <div class="sc-fznZeY fLrtke" /> </div> <div class="sc-fznKkj hQRnpP" > <div class="MuiFormControl-root makeStyles-formControl-18 makeStyles-formControl-40" > <div class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-formControl" > <div aria-haspopup="listbox" class="MuiSelect-root makeStyles-select-22 makeStyles-select-43 MuiSelect-select MuiSelect-selectMenu MuiSelect-outlined MuiInputBase-input MuiOutlinedInput-input" role="button" tabindex="0" > 属性2 </div> <input aria-hidden="true" class="MuiSelect-nativeInput" tabindex="-1" value="9490b61c-7208-11ed-a837-cd8a0630db1c" /> <span class="makeStyles-iconDiv-23" > <svg fill="none" height="8" viewBox="0 0 8 5" width="8" xmlns="http://www.w3.org/2000/svg" > <path clip-rule="evenodd" d="M0.292007 0.292941C0.1052 0.481833 0.000427246 0.736777 0.000427246 1.00244C0.000427246 1.2681 0.1052 1.52305 0.292007 1.71194L3.23101 4.67694C3.44901 4.89194 3.73101 4.99894 4.01001 4.99894C4.28901 4.99894 4.56601 4.89194 4.77901 4.67694L7.70901 1.72194C7.89557 1.53292 8.00018 1.27803 8.00018 1.01244C8.00018 0.746856 7.89557 0.49196 7.70901 0.302941C7.61718 0.209757 7.50773 0.135759 7.38705 0.0852509C7.26636 0.0347429 7.13684 0.0087328 7.00601 0.0087328C6.87518 0.0087328 6.74565 0.0347429 6.62497 0.0852509C6.50428 0.135759 6.39484 0.209757 6.30301 0.302941L4.00501 2.61994L1.69801 0.292941C1.60597 0.20012 1.49646 0.126444 1.3758 0.0761652C1.25514 0.0258864 1.12572 0 0.995008 0C0.864292 0 0.73487 0.0258864 0.614211 0.0761652C0.493552 0.126444 0.384044 0.20012 0.292007 0.292941Z" fill="#1c2a34" fill-rule="evenodd" /> </svg> </span> <fieldset aria-hidden="true" class="PrivateNotchedOutline-root-28 MuiOutlinedInput-notchedOutline" style="padding-left: 8px;" > <legend class="PrivateNotchedOutline-legend-29" style="width: 0.01px;" > <span> </span> </legend> </fieldset> </div> </div> </div> </div> <div class="sc-fzozJi bgcjU" > <div class="sc-fzpans krqqYA" > <div class="sc-fzoLsD hbFvtU" > 属性3 </div> <div class="sc-fznZeY fLrtke" > <div class="sc-Axmtr cHbKVu" > 任意 </div> </div> <div class="sc-fznZeY fLrtke" /> </div> <div class="sc-fznKkj hQRnpP" > <div class="MuiFormControl-root makeStyles-formControl-18 makeStyles-formControl-44" > <div class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-formControl" > <div aria-haspopup="listbox" class="MuiSelect-root makeStyles-select-22 makeStyles-select-47 MuiSelect-select MuiSelect-selectMenu MuiSelect-outlined MuiInputBase-input MuiOutlinedInput-input" role="button" tabindex="0" > 属性4 </div> <input aria-hidden="true" class="MuiSelect-nativeInput" tabindex="-1" value="9490b616-7208-11ed-a837-cd8a0630db1c" /> <span class="makeStyles-iconDiv-23" > <svg fill="none" height="8" viewBox="0 0 8 5" width="8" xmlns="http://www.w3.org/2000/svg" > <path clip-rule="evenodd" d="M0.292007 0.292941C0.1052 0.481833 0.000427246 0.736777 0.000427246 1.00244C0.000427246 1.2681 0.1052 1.52305 0.292007 1.71194L3.23101 4.67694C3.44901 4.89194 3.73101 4.99894 4.01001 4.99894C4.28901 4.99894 4.56601 4.89194 4.77901 4.67694L7.70901 1.72194C7.89557 1.53292 8.00018 1.27803 8.00018 1.01244C8.00018 0.746856 7.89557 0.49196 7.70901 0.302941C7.61718 0.209757 7.50773 0.135759 7.38705 0.0852509C7.26636 0.0347429 7.13684 0.0087328 7.00601 0.0087328C6.87518 0.0087328 6.74565 0.0347429 6.62497 0.0852509C6.50428 0.135759 6.39484 0.209757 6.30301 0.302941L4.00501 2.61994L1.69801 0.292941C1.60597 0.20012 1.49646 0.126444 1.3758 0.0761652C1.25514 0.0258864 1.12572 0 0.995008 0C0.864292 0 0.73487 0.0258864 0.614211 0.0761652C0.493552 0.126444 0.384044 0.20012 0.292007 0.292941Z" fill="#1c2a34" fill-rule="evenodd" /> </svg> </span> <fieldset aria-hidden="true" class="PrivateNotchedOutline-root-28 MuiOutlinedInput-notchedOutline" style="padding-left: 8px;" > <legend class="PrivateNotchedOutline-legend-29" style="width: 0.01px;" > <span> </span> </legend> </fieldset> </div> </div> </div> </div> </div> // logRoles() の出力 console.log button: Name "属性2": <div aria-haspopup="listbox" class="MuiSelect-root makeStyles-select-22 makeStyles-select-43 MuiSelect-select MuiSelect-selectMenu MuiSelect-outlined MuiInputBase-input MuiOutlinedInput-input" role="button" tabindex="0" /> Name "属性4": <div aria-haspopup="listbox" class="MuiSelect-root makeStyles-select-22 makeStyles-select-47 MuiSelect-select MuiSelect-selectMenu MuiSelect-outlined MuiInputBase-input MuiOutlinedInput-input" role="button" tabindex="0" /> ```
-
- Material UIなど、UIライブラリを使っている場合や、色々と自前で実装している場合は独特なことも多いので、地道に screen.debug と logRoles を使用してDOM要素の検出方法を探ります
その4: コンポーネント内部で色々初期化処理している
- どう困ったか
- 生成されたインスタンスの関数が外部へのリクエストとか飛ばしていると、モックが必要になるが、constructor関数をpublicにしていると、mockするのが厄介
- どう解消したか
- インスタンスを生成するだけのカスタムフックを作成しました
- 初期化処理をprivateにして、初期化処理に関しては別途用意することも考えたが、すでに多く使われているクラスだと、全て変更しなければいけないので、とても辛かったです
- なので、インスタンス生成だけ行うカスタムフック(カスタムフックもただの関数ではあるのでインスタンス生成ロジックを関数に寄せただけですが、、)を作ることで回避しました
- インスタンスを生成するだけのカスタムフックを作成しました
ここまで書いて思ったこと
- テストを書くことでReactのことを深く知れたので、テストを書くだけでも学びはあります
- ReactのことはもちろんHTMLのことも知れたりと、普段意識しない部分まで考えるので、必然的に知識は増えます
- 元々テストしづらいコンポーネントだったので、テストできる状態まで持っていくのがとても大変だったが、なぜ大変になっちゃったのかを考えてみました
- けど、一つのコンポーネントでなんでもやろうとしているからな気がしました。。
- firestoreからデータ取得して、入力値の検証️(バリデーション)もやって、表示もやって
- ってまあ色々な役割を持ってました。。
- そのコンポーネントの役割はなんですか?って問いに一言で答えられると良さそうかなって思いました
- 例えば Container/Presentationalパターン なんてものもあるので、表示をするだけ、とかもできますし、カスタムフックもあるので検証ロジックはそちらに寄せる、などなど、やりようはたくさんあるかなーって思いました
- 一応力技でテストは書きましたが、あまり力は使いたくないので、今後は綺麗なコンポーネント設計をしてなかったら、指摘しようと思います
- React に限らずUIのユニットテストは、画面の仕様がすぐ変わるので壊れやすく、工夫が必要かなーって思ったりはしました
- ここの正解はまだ見つけられていないので、もし知見のある方がいらっしゃったら、ぜひお伺いしたいです。。
終わりに
ここまで読んでいただきありがとうございました!!
コンポーネント設計をしないで実装すると、割とテストで辛くなるので、意識して実装していきたいですねー。