React Testing LibraryでのWAI-ARIAロールの活用事例

2024/08/19に公開

こんにちは!サイボウズ様で業務委託でフロントエンドエンジニアをしている Nokogiri です。
このたびはCybozu Summer Blog Fes'24にて執筆の機会をいただきましたので、僭越ながら寄稿させていただきます。

今回はReact Testing LibraryでのWAI-ARIAロールの活用事例について紹介させていただきます。

前提

Reactで書かれたUIをテストする方法の一つとしてTesting Library があります。

Testing Libraryでは要素取得にWAI-ARIA ロールを利用することを推奨しています。(ロールは視覚/マウス ユーザーだけでなく、支援技術を使用するユーザーのエクスペリエンスを反映するため)

実はWAI-ARIA ロールには66個のロールがありますが、実際によくWeb開発で使われるUIにどのようなロールが付与されているのかを知りたくなりました。

記事執筆に当たり調査したこと

一般によく使われており、かつa11y対応がされているUIライブラリであるchakra の コンポーネントがどのようなロールが付与さているかを調査しました。

chakraのコンポーネント集を見てロールをまとめるだけでもいいのですが、せっかくなので自分でchakraのコンポーネントを使ってWebアプリを作り、Testing Libraryを使って自動テストを書いてみました。

chakraのコンポーネント集からよく使われると思われるものやロールに特徴があるもの25個を抜粋して作成しています。

成果物

今回の調査に当たって作成したものは以下のレポジトリに公開しています。
自動テストを書くときの参考にしていただけると幸いです。

https://github.com/nkgrnkgr/testing-library-and-a11y/

UI毎のロール早見表

UI Role
警告ダイアログ alertdialog
警告表示 alert
パンくず navigation
チェックボックス checkbox
コード表示 code
区切り線 separator
ドロアー dialog
テキスト入力 textbox
見出し heading
リンク link
リスト list, listitem
ローディング alert
メニュー menu, menuitem
モーダル dialog
数値入力 spinbutton
ポップオーバー dialog
プログレスバー progressbar
ラジオ radiogroup, radio
セレクト combobox
スタッツ term
タブとコンテンツ tab, tabpanel
テーブル table, rowgroup, columnheader, row, cell
テキス入力(複数行) textbox
トースト -
ツールチップ tooltip

事例紹介

以下26個のコンポーネントに関してそれぞれ「UI」「ロールを使ったテスト」を記述します。

警告ダイアログ

警告ダイアログ

  • ロール:alertdialog

テストコード

const user = userEvent.setup();
const button = screen.getByRole("button", {
  name: "警告ダイアログを開く",
});
await user.click(button);

const dialog = await screen.findByRole("alertdialog", {
  name: "削除の確認",
});
expect(dialog).toBeInTheDocument();

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/AlertDialogDisplay

警告表示

警告表示

  • ロール:alert

テストコード

const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("アラート");

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/AlertDisplay

パンくず

パンくず

  • ロール:navigation
  • 内部のリンクのロールはlink

テストコード

const breadcrumb = screen.getByRole("navigation", {
  name: "breadcrumb",
});
expect(breadcrumb).toBeInTheDocument();

const items = within(breadcrumb)
  .getAllByRole("listitem")
  .map((i) => {
    const link = within(i).queryByRole("link");
    return link === null
      ? {
          text: i.textContent,
          href: "",
        }
      : {
          text: link.textContent,
          href: link.getAttribute("href"),
        };
  });
expect(items).toEqual([
  {
    text: "Home",
    href: "#",
  },
  {
    text: "一覧",
    href: "#",
  },
  {
    text: "アイテム1",
    href: "",
  },
]);

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/Breadcrumb

チェックボックス

チェックボックス

  • ロール:checkbox

テストコード

const checkbox = screen.getByRole("checkbox", {
  name: "Checkbox",
}) as HTMLInputElement;
expect(checkbox.checked).toBe(true);

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/Checkbox

コード表示

コード表示

  • ロール:code

テストコード

const code = screen.getByRole("code", {
  name: "JavaScript Code!",
});
expect(code).toBeInTheDocument();

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/CodeDisplay

区切り線

区切り線

  • ロール:separator

テストコード

const divider = screen.getByRole("separator", {
  name: "区切り",
});
expect(divider).toBeInTheDocument();

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/DividerDisplay

ドロアー

ドロアー

  • ロール:dialog

テストコード

const user = userEvent.setup();
const button = screen.getByRole("button", {
  name: "ドロアーを開く",
});
await user.click(button);
const drawer = await screen.findByRole("dialog", {
  name: "アカウントの作成",
});
expect(drawer).toBeInTheDocument();

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/DrawerDisplay

テキスト入力

テキスト入力

  • ロール:textbox

テストコード

const user = userEvent.setup();
const emailInput = screen.getByRole("textbox", {
  name: "Email",
}) as HTMLInputElement;
expect(emailInput.value).toBe("xxx@gmail.com");
await user.clear(emailInput);
expect(
  screen.getByText("Emailのフォーマットが正しくありません"),
).toBeInTheDocument();

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/FormControlDisplay

見出し

見出し

  • ロール:heading

テストコード

const heading = screen.getByRole("heading", {
  name: "見出し",
});
expect(heading).toBeInTheDocument();

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/Heading

画像

画像

  • ロール:img
const image = screen.getByRole("img", {
  name: "150 x 150 placeholder",
});
expect(image).toBeInTheDocument();

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/ImageDisplay

リンク

リンク

  • ロール:link
const link = screen.getByRole("link", {
  name: "Github.com",
});
expect(link).toBeInTheDocument();
expect(link.getAttribute("href")).toBe("https://github.com");

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/Link

リスト

リスト

  • ロール:list
  • リスト内の要素は listitem
const list = screen.getByRole("list", {
  name: "リスト",
});
expect(list).toBeInTheDocument();
const listItems = within(list).getAllByRole("listitem");
expect(listItems).toHaveLength(3);
const textContents = listItems.map((i) => i.textContent);
expect(textContents.sort()).toEqual(
  ["アイテム 1", "アイテム 2", "アイテム 3"].sort(),
);

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/ListDisplay

ローディング

ローディング

  • ロール:alert (chakraでのロール付与はないため実装者がつけているロールになります)
const loading = screen.getByRole("alert");
expect(loading).toBeInTheDocument();

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/Loading

メニュー

メニュー

  • ロール:menu
  • メニュー内の要素は menuitem
const user = userEvent.setup();
const button = screen.getByRole("button", { name: "メニューを開く" });
await user.click(button);

const menu = await screen.findByRole("menu");
const menuItems = within(menu).getAllByRole("menuitem");
expect(menuItems).toHaveLength(4);
const textContents = menuItems.map((i) => i.textContent);
expect(textContents.sort()).toEqual(
  ["個人設定", "購入履歴", "アカウントの切り替え", "ログアウト"].sort(),
);

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/MenuDisplay

モーダル

モーダル

  • ロール:dialog

テストコード

const user = userEvent.setup();
const button = screen.getByRole("button", { name: "モーダルを開く" });
await user.click(button);

const dialog = await screen.findByRole("dialog");
expect(dialog).toBeInTheDocument();

const closeButton = within(dialog).getByRole("button", {
  name: "閉じる",
});
await user.click(closeButton);
await waitFor(() => {
  expect(screen.queryByRole("dialog")).toBeNull();
});

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/ModalDisplay

数値入力

数値入力

  • ロール:spinbutton
const user = userEvent.setup();
const numberInput = screen.getByRole("spinbutton", {
  name: "数値入力",
}) as HTMLInputElement;
expect(numberInput.value).toBe("10");
await user.clear(numberInput);
await user.type(numberInput, "999");
expect(numberInput.value).toBe("999");
await user.type(numberInput, "{arrowup}");
expect(numberInput.value).toBe("1000");
await user.type(numberInput, "{arrowdown}");
expect(numberInput.value).toBe("999");

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/NumberInputDisplay

ポップオーバー

ポップオーバー

  • ロール:dialog
const user = userEvent.setup();
const button = screen.getByRole("button", { name: "ポップオーバーを開く" });
await user.click(button);

const dialog = await screen.findByRole("dialog", {
  name: "ポップオーバー",
});
expect(dialog).toBeInTheDocument();

const closeButton = within(dialog).getByRole("button", {
  name: "Close",
});
await user.click(closeButton);
await waitFor(() => {
  expect(screen.queryByRole("dialog")).toBeNull();
});

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/PopoverDisplay

プログレスバー

プログレスバー

  • ロール:progressbar
const progressbar = screen.getByRole("progressbar");
expect(progressbar).toBeInTheDocument();
expect(progressbar).toHaveTextContent("40%");
expect(progressbar).toHaveAttribute("aria-valuemin", "0");
expect(progressbar).toHaveAttribute("aria-valuemax", "100");
expect(progressbar).toHaveAttribute("aria-valuenow", "40");

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/Progressbar

ラジオ

ラジオ

  • ロール:radiogroup
  • 個別の選択肢は:radio
const user = userEvent.setup();
const radioGroup = screen.getByRole("radiogroup", {
  name: "ユーザー区分",
});
const radioIndividual = within(radioGroup).getByRole("radio", {
  name: "個人",
}) as HTMLInputElement;
const radioCorporation = within(radioGroup).getByRole("radio", {
  name: "法人",
}) as HTMLInputElement;
const radioEtc = within(radioGroup).getByRole("radio", {
  name: "その他",
}) as HTMLInputElement;
expect(radioIndividual.checked).toBe(true);
expect(radioCorporation.checked).toBe(false);
expect(radioEtc.checked).toBe(false);

await user.click(radioCorporation);
expect(radioIndividual.checked).toBe(false);
expect(radioCorporation.checked).toBe(true);
expect(radioEtc.checked).toBe(false);

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/Radio

セレクト

セレクト

  • ロール:combobox
const user = userEvent.setup();
const combobox = screen.getByRole("combobox", {
  name: "ユーザー区分",
});
await user.click(combobox);
await user.selectOptions(combobox, "corporation");
expect(combobox).toHaveValue("corporation");

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/SelectDisplay

スタッツ

スタッツ

  • ロール:term
const statTitle = screen.getByRole("term");
expect(statTitle).toHaveTextContent("日経平均株価");
const definitions = screen.getAllByRole("definition");
const textContents = definitions.map((d) => d.textContent);
expect(textContents).toEqual([
  "35,909.70",
  "decreased by−2,216.63 (5.81%)今日",
]);

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/StatDisplay

タブとコンテンツ

タブとコンテンツ

  • ロール:tab
  • タブと紐付くコンテンツについては:tabpanel
const user = userEvent.setup();
const tabPanel1 = screen.getByRole("tabpanel", { name: "One" });
expect(tabPanel1).toBeInTheDocument();
const tab2 = screen.getByRole("tab", { name: "Two" });
await user.click(tab2);
expect(screen.getByRole("tabpanel", { name: "Two" })).toBeInTheDocument();

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/TabAndContents

テーブル

テーブル

  • ロール:table, rowgroup, columnheader, row, cell
  • rowgroup は ヘッダー要素とボディー要素に分けられる
  • columnheader はヘッダー要素の中のヘッダーセル
const table = screen.getByRole("table", { name: "日本の人口推移" });
expect(table).toBeInTheDocument();
const rowgroup = within(table).getAllByRole("rowgroup");
const headers = within(rowgroup[0])
  .getAllByRole("columnheader")
  .map((header) => header.textContent);
const yearIndex = headers.indexOf("年");
const populationIndex = headers.indexOf("人口");
const increaseRateIndex = headers.indexOf("増加率(%)");
const tableBodyRows = rowgroup[1];
const rows = within(tableBodyRows).getAllByRole("row");
const year1975Row = rows.find((row) => {
  const cells = within(row).getAllByRole("cell");
  return cells[yearIndex].textContent === "1975";
});
assertExists(year1975Row);
expect(
  within(year1975Row).getAllByRole("cell")[populationIndex],
).toHaveTextContent("1億1190万人");
expect(
  within(year1975Row).getAllByRole("cell")[increaseRateIndex],
).toHaveTextContent("34.5%");

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/Table

テキスト入力(複数行)

テキスト入力(複数行)

  • ロール:textbox*
const TEXT = `
Hello
world
`;
const user = userEvent.setup();
const textarea = screen.getByRole("textbox", {
  name: "コメント",
}) as HTMLInputElement;
expect(textarea.textContent).toBe("");

await user.type(textarea, TEXT);
expect(textarea.textContent).toBe(TEXT);

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/Textarea

トースト

トースト

  • chakraだとregionは最初から表示されているので内部にテキストが出現したことでトーストが表示されたと判断している
const user = userEvent.setup();
const button = screen.getByRole("button", { name: "トーストを表示" });
await user.click(button);

const notificationRegion = screen.getByRole("region", {
  name: "Notifications-bottom",
});
const toast = await within(notificationRegion).findByText(
  "入力に誤りがあります。",
);
expect(toast).toBeInTheDocument();

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/Toast

ツールチップ

ツールチップ

  • ロール:tooltip
const user = userEvent.setup();
const text = screen.getByText("ツールチップを表示");
await user.hover(text);

const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toBeInTheDocument();

https://github.com/nkgrnkgr/testing-library-and-a11y/tree/main/src/components/Tooltip

最後に

今回紹介したUIとロールの組み合わせはあくまでchakraの実装なので、別のライブラリであれば異なるロールが付与されていることもあります。自分でUIを実装する際はいくつかのライブラリを比較して適切なロールを選択できるとよさそうです。
UIに対して適切にロールが付与されることでし視覚/マウスユーザーだけでなく支援技術を利用するユーザーにとって使いやすいUIになります。
またテストの書きやすさという観点でも積極的にロールを利用したほうがよいです。

付録A: ChromeでWAI-ARIA ロールを確認する方法

ChromeのdevtoolのsettingsからExperimentsを選択し Enable full accessibility tree view in the Styles tab にチェックを入れます。

Elementsタブにアクセシビリティマークが表示されるようになります。

checkbox

マーク

サイボウズ フロントエンド

Discussion