Reactコンポーネントの単体テスト書いてて困っちゃった

この記事は、ソフトウェアテストアドベントカレンダー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を使用する
            - 参考: https://zenn.dev/maktub_bros/articles/3366d053275d60
    - cleanupが必要ない場合もあるので、詳しくは[副作用フックの利用法](https://ja.reactjs.org/docs/hooks-effect.html)をご覧ください

## その2: [Material UIのSelect](https://mui.com/material-ui/react-select/)を使ったら、  [userEvent.selectOptions](https://testing-library.com/docs/ecosystem-user-event/#selectoptionselement-values-options) が反応しなかった
- どう困ったか
    - エラーは出ず、ただただテストに失敗しました
- どう解消したか
    - ちょっと邪道かもしれませんが、 [userEvent.click](https://testing-library.com/docs/ecosystem-user-event/#clickelement-eventinit-options) を使用すると、無事要素の選択ができます
        - step1: まず選択できる状態にします
            - 初期状態は選択肢が描画されていないので、 userEvent.click して選択肢を表示してあげます
        - step2: 選択した後の状態を把握します
            - screen.debugを使用して以下のような出力を得られれば良い
            <details><summary>出力例</summary>


            ```sh
                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>
            ```
            </details>

        - step3: 選択肢を選択します（クリックする）
            - userEvent.clickを使用して選択すると、選択肢が閉じて初期状態（選択肢が全く描画されていない状態）に戻ります
    - 以下のような確認ができればいいかなって思ってます
        <details><summary>出力例</summary>

        ```typescript
        // 初期状態
        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](https://testing-library.com/docs/react-testing-library/api/#debug) と [logRoles](https://testing-library.com/docs/dom-testing-library/api-debugging/#logroles) を使用してDOM要素の検出方法を探ります
        - DOM要素の検出方法は
            - ByRole
            - ByLabelText
            - ByPlaceholderText
            - ByText
            - ByDisplayValue
            - ByAltText
            - ByTitle
            - ByTestId
                - これは最終奥義みたいな感じです
        - 公式ドキュメントは[こちら](https://testing-library.com/docs/queries/about)
    - 例えば日付変更しているコンポーネントあたりのテストしようかなーみたいな時は
        - そのあたりのDOM要素（諸々をwrapしているDOM要素だとなお良い）に `data-testid` を付与して
        - 以下のコードをとりあえず流してみる
            <details><summary>コード</summary>
                
                ```tsx
                it('テストケース1', () => {
                	const target = screen.getByTestId('hogehoge'); // data-testidの値
                	screen.debug(target); // これでDOM要素を全て表示してくれる
                	logRoles(target); // 検出に使用できそうな情報を出力してくれる
                })
                ```
            </details>
                
            <details><summary>出力例</summary>
                
                ```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"
                    />
                ```
            </details>

## その4: コンポーネント内部で色々初期化処理している

- どう困ったか
    - 生成されたインスタンスの関数が外部へのリクエストとか飛ばしていると、モックが必要になるが、constructor関数をpublicにしていると、mockするのが厄介
- どう解消したか
    - インスタンスを生成するだけのカスタムフックを作成しました
        - 初期化処理をprivateにして、初期化処理に関しては別途用意することも考えたが、すでに多く使われているクラスだと、全て変更しなければいけないので、とても辛かったです
        - なので、インスタンス生成だけ行うカスタムフック（カスタムフックもただの関数ではあるのでインスタンス生成ロジックを関数に寄せただけですが、、）を作ることで回避しました


# ここまで書いて思ったこと
- テストを書くことでReactのことを深く知れたので、テストを書くだけでも学びはあります
    - ReactのことはもちろんHTMLのことも知れたりと、普段意識しない部分まで考えるので、必然的に知識は増えます
- 元々テストしづらいコンポーネントだったので、テストできる状態まで持っていくのがとても大変だったが、なぜ大変になっちゃったのかを考えてみました
    - けど、一つのコンポーネントでなんでもやろうとしているからな気がしました。。
    - firestoreからデータ取得して、入力値の検証️（バリデーション）もやって、表示もやって
    - ってまあ色々な役割を持ってました。。
    - そのコンポーネントの役割はなんですか？って問いに一言で答えられると良さそうかなって思いました
        - 例えば Container/Presentationalパターン なんてものもあるので、表示をするだけ、とかもできますし、カスタムフックもあるので検証ロジックはそちらに寄せる、などなど、やりようはたくさんあるかなーって思いました
    - 一応力技でテストは書きましたが、あまり力は使いたくないので、今後は綺麗なコンポーネント設計をしてなかったら、指摘しようと思います
- React に限らずUIのユニットテストは、画面の仕様がすぐ変わるので壊れやすく、工夫が必要かなーって思ったりはしました
    - ここの正解はまだ見つけられていないので、もし知見のある方がいらっしゃったら、ぜひお伺いしたいです。。

# 終わりに
ここまで読んでいただきありがとうございました！！
コンポーネント設計をしないで実装すると、割とテストで辛くなるので、意識して実装していきたいですねー。