🧪

RadixUIのTooltipをテストする際に詰まったところ

2023/09/14に公開

初めに

RadixUIのTooltipをテストしようとした際に予期せぬところでハマったので、
誰かの役に立てばと思い、重い腰を上げて本記事を書きました。

記事の対象者

  • コンポーネントテスト初心者・中級者
  • jest・react-testing-libraryの利用者
  • RadixUIのテストを考えている方
  • 「これは記事化すべき」と言ってくれたKさん

本記事のゴール

1.今回遭遇したエラーとその対処法の一例を理解すること
2.以下の項目がパスするテストが書けること

  • Tooltipトリガーをホバーして400ms以内はTooltipコンテンツが表示されていないこと
  • Tooltipトリガーをホバーして500ms後にはTooltipコンテンツが表示されること
  • 表示されるTooltipコンテンツが指定した物と同じであること

TL;DR

以下の3点に気を付けましょう。

  • ResizeObserverのMockを行う
  • テスト対象はqueryBytestidで取得する
  • timeout処理はwaitForとPromiseのsleep関数で行う

今回遭遇したエラー

ResizeObserverをモックしていない場合のエラー

取得した要素がtoBeVisibleか評価している行でエラーが起こっている為、
筆者は要素取得周りに原因が有ると踏んでしまい中々ResizeObserverエラーに気付かなかったです。(反省)

ReferenceError: ResizeObserver is not defined
...
components/Tooltip › ホバーして500ms後に表示される

expect(received).toBeVisible()

received value must be an HTMLElement or an SVGElement.
Received has value: null

40 |
      41 |     // 500ms経過したらTooltipが表示されている事を確認
    > 42 |     expect(queryByTestId("radix-tooltip-content")).toBeVisible();
         |                                                    ^
      43 |     expect(queryByTestId("radix-tooltip-content")).toContainHTML("test-content");
      44 |   });
      45 | });

テスト対象をtextで取得した場合のエラー

getByTextでは要素が見つけられないと怒られてしまうので、queryByTestIdやfindByTestIdを使いましょう。
※ 筆者は試していませんが、getByRoleでも問題無いようです。

TestingLibraryElementError: Unable to find an element with the text: text-content. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

環境

デバイス環境

  • MacBook Pro 16インチ 2023 (M2 Pro)

Node系環境

  • volta: 1.1.1 (node系パッケージマネージャー)
  • node: 18.17.1
  • pnpm: 8.7.1

テスト系ライブラリ

  • ts-jest: ^29.1.1
  • @testing-library/jest-dom: ^6.1.3
  • @testing-library/react: ^14.0.0
  • @testing-library/user-event: ^14.4.3,

実装の前に

指定の秒数待機するsleep関数をPromiseとsetTimeoutを組み合わせて作っておきます。
※ 既に作成済みの場合や、ライブラリを用いる場合はスキップして構いません。

// sleep.ts
export async function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

実装方法

1.ResizeObserverのMock

ResizeObserverのMockが無いと怒られるので、以下のようなクラスを作成して対処しましょう。

// tooltip.test.ts
...
class ResizeObserver {
  observe() {}
  unobserve() {}
  disconnect() {}
}
...

2. 対象コンポーネントのレンダー

テスト対象のコンポーネント(Tooltip)をimportし、
@testing-library/reactのrender関数でレンダーします。

// tooltip.test.ts
...
// 🌟 render,waitfor関数をimport
import { render, waitFor } from "@testing-library/react";

import { Tooltip } from "./Tooltip"
...
describe("ui-admin/Tooltip", () => {
  // 🌟 ResizeObserverのMockを追加
  window.ResizeObserver = ResizeObserver;

  it("ホバーして500ms後に表示される", async () => {
    const { queryByTestId } = await waitFor(() =>
      // 🌟 対象コンポーネントをレンダー
      render(
        // 🌟 popoverとして表示されるテキストにtest-contentを指定
        <Tooltip content="test-content">
	  {/* 🌟 ホバー対象のトリガーには以下のspanを指定 */}
          <span data-testid="test-child">test-children</span>
        </Tooltip>,
      )
    )
  });
});

3.テスト対象の取得

テスト対象を取得する際は、testIdを使用します。
textで取得しようとした場合、RadixTooltipの仕様によって同一テキストが画面上に複数描画されてしまい単一取得に失敗する事を防ぐ為。
(確認していませんが、恐らく仕様です。恐らく)

// tooltip.test.ts
...
describe("ui-admin/Tooltip", () => {
  window.ResizeObserver = ResizeObserver;

  it("ホバーして500ms後に表示される", async () => {
    const { queryByTestId } = await waitFor(() =>
      render(
        <Tooltip content="test-content">
          <span data-testid="test-child">test-children</span>
        </Tooltip>,
      )
    )

    /* 🌟 ホバー時に表示されるpopoverをtestIdで取得
    * getでは取得失敗時にエラーになる為、queryもしくはfindによる取得の必要あり
    */
    const popover = queryByTestId("radix-tooltip-content");

    // 🌟 triggerをtestIdで取得
    const trigger = queryByTestId("test-child");
  });
});

4. ホバーイベントを発火させる

userEventをimportし、使用前にsetupメソッドを呼び出してセットアップをしておきます。
各イベントを発火させる際は、waitForのコールバックに渡します。

...
// 🌟 hoverやclickイベント等を実行できるライブラリのimport
import userEvent from "@testing-library/user-event";
...
// tooltip.test.ts
...
describe("ui-admin/Tooltip", () => {
  window.ResizeObserver = ResizeObserver;

  it("ホバーして500ms後に表示される", async () => {
    const { queryByTestId } = await waitFor(() =>
      render(
        <Tooltip content="test-content">
          <span data-testid="test-child">test-children</span>
        </Tooltip>,
      )
    )

    // 🌟 hoverやclick等のイベントutilsのセットアップ
    const user = userEvent.setup();

    const popover = queryByTestId("radix-tooltip-content");

    const trigger = queryByTestId("test-child");
    
    // 🌟 ホバーを実現
    await waitFor(() => user.hover(trigger));
  });
});

5. 非表示状態のテスト

// tooltip.test.ts
// 🌟 toBeInTheDocumentメソッドを利用可能にする
import "@testing-library/jest-dom";
// 🌟 冒頭で作成したsleep関数をimportしておきます
import { sleep } from "./sleep"
...
describe("ui-admin/Tooltip", () => {
  window.ResizeObserver = ResizeObserver;

  it("ホバーして500ms後に表示される", async () => {
    const { queryByTestId } = await waitFor(() =>
      render(
        <Tooltip content="test-content">
          <span data-testid="test-child">test-children</span>
        </Tooltip>,
      )
    )
    
    const user = userEvent.setup();

    const popover = queryByTestId("radix-tooltip-content");
    // 🌟 初期状態ではpopoverが表示されていない事をテスト
    expect(popover).not.toBeInTheDocument();

    const trigger = queryByTestId("test-child");
    await waitFor(() => user.hover(trigger));
    
    // 🌟 目標の500msに満たない400msの待機 (sleep)
    await waitFor(() => sleep(400));

    // 🌟 400ms経過時点ではまだpopoverが表示されていない事を確認
    expect(queryByTestId("radix-tooltip-content")).not.toBeInTheDocument();
  });
});

6. 表示のテスト

// tooltip.test.ts
...
describe("ui-admin/Tooltip", () => {
  window.ResizeObserver = ResizeObserver;

  it("ホバーして500ms後に表示される", async () => {
    const { queryByTestId } = await waitFor(() =>
      render(
        <Tooltip content="test-content">
          <span data-testid="test-child">test-children</span>
        </Tooltip>,
      )
    )
    
    const user = userEvent.setup();

    const popover = queryByTestId("radix-tooltip-content");
    expect(popover).not.toBeInTheDocument();

    const trigger = queryByTestId("test-child");
    await waitFor(() => user.hover(trigger));
    
    await waitFor(() => sleep(400));

    expect(queryByTestId("radix-tooltip-content")).not.toBeInTheDocument();
    
    // 🌟 さらに100ms待機し、前の400msと合わせて500msの待機を行う
    await waitFor(() => sleep(100));
    
    // 🌟 500ms経過したらTooltipが表示されている事を確認
    expect(queryByTestId("radix-tooltip-content")).toBeVisible();
    expect(queryByTestId("radix-tooltip-content")).toContainHTML("test-content");
  });
});

最終的なコード

最終的に以下のようなトコードが出来上がっているかと思います。
実際に手元でテストを実行してパスするか試してみましょう。
※ もしパスしない場合やエラーが出る場合はコメントお待ちしております。

// sleep.ts
export async function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
// Tooltip.tsx
import * as RadixTooltip from "@radix-ui/react-tooltip";

import { TooltipProps } from "./type";

// RadixTooltipをラップしたコンポーネント
export const Tooltip = ({ content, children }: TooltipProps) => (
  <RadixTooltip.Provider>
    <RadixTooltip.Root delayDuration={500}>
      <RadixTooltip.Trigger asChild className="cursor-pointer">
        {children}
      </RadixTooltip.Trigger>
      <RadixTooltip.Portal>
        <RadixTooltip.Content className="bg-grayScale-0 shadow-low " data-testid="radix-tooltip-content">
          <span className="text-ui14 rounded px-[8px] py-[2px]">{content}</span>
          <RadixTooltip.Arrow className="fill-base-0" />
        </RadixTooltip.Content>
      </RadixTooltip.Portal>
    </RadixTooltip.Root>
  </RadixTooltip.Provider>
);
// tooltip.test.tsx

// toBeInTheDocument等のメソッドを利用可能にする
import "@testing-library/jest-dom";

import { render, waitFor } from "@testing-library/react";

// hoverやclick等のイベントを再現できるutilsをimport
import userEvent from "@testing-library/user-event";

// テスト対象コンポーネントをimport
import { Tooltip } from "./Tooltip";

// スリープ関数
import { sleep } from "./sleep"

// ResizeObserverのMock
class ResizeObserver {
  observe() {}
  unobserve() {}
  disconnect() {}
}

describe("components/Tooltip", () => {
  // Mock済みのResizeObserverで上書き
  window.ResizeObserver = ResizeObserver;

  it("ホバーして500ms後に表示される", async () => {
    const { queryByTestId } = await waitFor(() =>
      // 対象コンポーネントをレンダー
      render(
        <Tooltip content="test-content">
	  {/* hover対象のtrigger部分 */}
          <span data-testid="test-child">test-children</span>
        </Tooltip>,
      )
    );
    
    // hoverやclick等のイベントutilsのセットアップ
    const user = userEvent.setup();

    /* ホバー時に表示されるpopoverをtestIdで取得
    * getでは取得失敗時にエラーになる為、queryもしくはfindによる取得の必要あり
    */
    const popover = queryByTestId("radix-tooltip-content");
    
    // 初期状態ではpopoverは非表示である事をテスト
    expect(popover).not.toBeInTheDocument();

    // triggerをtestIdで取得
    const trigger = queryByTestId("test-child");
    
    // ホバーを実現
    await waitFor(() => user.hover(trigger));

    // 400ms待機
    await waitFor(() => sleep(400));

    // 400ms経過時点ではまだTooltipが表示されていない事を確認
    expect(queryByTestId("radix-tooltip-content")).not.toBeInTheDocument();

    // 100ms待機
    await waitFor(() => sleep(100));

    // 400ms + 100msの計500ms経過したらTooltipが表示されている事を確認
    expect(queryByTestId("radix-tooltip-content")).toBeVisible();
    
    // 併せて表示されている文字列がcontent propで渡したものと同じか確認
    expect(queryByTestId("radix-tooltip-content")).toContainHTML("test-content");
  });
});

最後に

予期しない所でハマって、大分時間を使ってしまいました。

筆者のテスティング力が足りなかったなと反省し、
もっとJestやReact Testing Libraryを理解する為にしっかりドキュメントを読み込んでみようと思いました。

また、本記事へのご指摘・ご意見は大歓迎です。
いつでもお待ちしております。

Discussion