5
0

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 1 year has passed since last update.

ソフトウェアテストAdvent Calendar 2022

Day 8

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

Last updated at Posted at 2022-12-07

この記事は、ソフトウェアテストアドベントカレンダー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のお話です。確かにそうだな、と思い、コンポーネントのテストを実施してて良かったです
  • どう解消したか

その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.debuglogRoles を使用してDOM要素の検出方法を探ります
      • DOM要素の検出方法は
        • ByRole
        • ByLabelText
        • ByPlaceholderText
        • ByText
        • ByDisplayValue
        • ByAltText
        • ByTitle
        • ByTestId
          • これは最終奥義みたいな感じです
      • 公式ドキュメントはこちら
    • 例えば日付変更しているコンポーネントあたりのテストしようかなーみたいな時は
      • そのあたりの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"
              />
          ```
        

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

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

ここまで書いて思ったこと

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

終わりに

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

5
0
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
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?