UIコンポーネントテスト:ボタンクリックで要素の表示・非表示を切り替えたいとき

ボタンクリックで表示・非表示を切り替えたい

UIを開発しているとボタンをクリックすると指定要素の表示・非表示を切り替えたい場面が出てくる。これをUIコンポーネントテストに落とし込む場合、少し注意が必要となる。 理由は「見た目上は消えているがレンダリングされている」場合と「レンダリング結果からも消える」場合は全く異なるからである。

見た目上は消えているがレンダリングされている場合は、HTML属性やCSSを用いて表示を切り替えているパターンが該当する。 レンダリング結果からも消える場合はJavaScript(React.jsやVue.js)で要素の表示を制御している場合である。

今回はJavaScript(React.jsやVue.js)で要素の表示を制御している場合の例で「ボタン要素のクリックに応じた特定要素の表示切り替え」を行うUIコンポーネントのテストを書いてみる。

お題

エラーや注意喚起文を表示するためモーダルウィンドウを出す場面があったとする。 モーダルウィンドウを実装するときは<dialog>1 などを使うことが多い。 <button>をクリックして<dialog>を非表示にする…といった場面がある。

TODOを整理すると次のようになる。

  1. ボタンクリックでモーダルウィンドウを表示する
  2. モーダルウィンドウ内部に警告文が表示されている
  3. モーダルウィンドウ内のボタンをクリックする
  4. モーダルウィンドウ要素が非表示になる

UIコンポーネントは次のように実装されている。モーダルウィンドウコンポーネントごとDOMレンダリングを切り替える形となっている。

// ボタンとモーダルウィンドウを組み合わせて表示制御を行うコンポーネント
import React, { useState } from "react";
import AlertModal from "../../component/AlertModal";

export default function AlertPage() {
  const [showModal, setShowModal] = useState(false);

  const handleClick = () => {
    setShowModal(!showModal);
  };

  return (
    <>
      <h3>ボタンクリックでモーダルダイアログ表示を切り替える</h3>
      <button onClick={handleClick}>モーダル表示</button>
      {showModal ? (
        <AlertModal open={true} onButtonClick={handleClick} />
      ) : (
        <></>
      )}
    </>
  );
}

<dialog>要素を持つコンポーネントは次のように実装されている。

// モーダルウィンドウコンポーネント
import React from "react";

type AlertModalProps = {
  /**
   * dialogを切り替える
   */
  open?: boolean;
  /**
   * ボタンコンポーネントのイベント発火を検証する
   */
  onButtonClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
};

export default function AlertModal({ open = true, ...props }: AlertModalProps) {
  return (
    <dialog open={open}>
      <p>アラートテキストを表示</p>
      <button type="button" onClick={props.onButtonClick}>
        OK
      </button>
    </dialog>
  );

ボタンクリック前の状態をテストする

「ボタンクリックでモーダルウィンドウを表示できる」ことをテストするためには、ボタンクリック前はモーダルウィンドウが存在しないことを検証しなければいけない。始めからモーダルウィンドウが表示されていたのであれば、「ボタンクリックで表示が切り替わる」部分は正しくないからである。

筆者はdescribeを使ってテストのまとまりを分割することが多い。一つのテストに全てまとめてしまうと仕様変更でテストが全部壊れてしまうからである。「初期状態のときはこういう見た目だよ」というまとまりにしておけば、仕様変更による影響を小さくできる。

describe("初期状態のテスト", () => {
  it("モーダルコンポーネントは非表示", () => {
    const content = render(<AlertPage />);
    expect(content.queryByText("アラートテキストを表示")).not.toBeTruthy();
  });
  // 他にも最初から記載されているものがあれば記載する
});

ボタンクリック後の状態をテストする

ボタンをクリックしたときの動作はclick2イベントで擬似的に再現できる。Testing LibraryではuserEvent(v14以降)APIを用いてイベント発火することを推奨している。userEvent APIはユーザーが実際に操作している状態を再現することをコンセプトとして設計されている。これはTesting Libraryの思想と一致する。fireEvent API3はDOMイベントを再現することに特化している。ユーザーが再現できない状態で無理やりイベント発火させることができてしまうため、実際の動作とかけ離れてしまう可能性が出てくる。

よって、可能な限りuserEventを使うほうが良い。ボタン操作やマウスオーバーのような基本操作はuserEventで再現できる。

使い方は単純である。テスト開始前にsetup()4を用いてuserEventをセットアップしておき、必要な場面で呼び出せば良い。 レンダリング結果に対する操作を行うため、非同期処理となる。async-awaitでテストケースを組み立てておく必要がある。

describe("モーダルダイアログの表示・非表示を切り替えられる", () => {
  // userEventを用いるためasyncで待ち受ける
  it("モーダル表示ボタンをクリックするとダイアログウィンドウが表示される", async () => {
    // イベント発火のセットアップ
    const event = userEvent.setup();
    const content = render(<AlertPage />);
    // clickイベントを呼び出す。引数に対象要素を割り当てる。非同期処理
    await event.click(content.getByRole("button", { name: "モーダル表示" }));
    expect(content.queryByText("アラートテキストを表示")).toBeTruthy();
  });

  // ここは分けておいた方がいい。ごっちゃになると見落とす
  it("モーダルを一度表示した後OKボタンをクリックすると非表示になる", async () => {
    const event = userEvent.setup();
    const content = render(<AlertPage />);
    // モーダルウィンドウを表示
    await event.click(content.getByRole("button", { name: "モーダル表示" }));
    // OKボタンをクリック
    await event.click(content.getByRole("button", { name: "OK" }));
    // モーダルウィンドウが非表示になっているか
    expect(content.queryByText("アラートテキストを表示")).not.toBeTruthy();
  });
});