🙌

React Testing Libraryでテスト駆動アクセシビリティ改善

2021/06/04に公開

はじめに

この記事内で扱うアクセシビリティとは、HTMLのマシンリーダビリティのことを指します。
配色、UIデザイン等は対象外としております。

アクセシビリティ観点を含めた画面機能のテストコード

私のプロジェクトの要件にアクセシビリティは含まれていません。工数インパクトを出さない範囲で気を遣っていこうという方針で開発をしていました。
要件に入っていないのでアクセシビリティの検証に工数を多く注ぐことは許されていません。しかし、考慮する以上は何らかの検証をしなければ実装の誤りに気がつくことができず、ない方がマシという状態になりかねません。

各コンポーネントのアクセシビリティを改善する際テストコードの中でアクセシビリティ検証を盛り込み、テスト駆動的に開発することでローコストかつ確実に改善を進めていく方法を実践したので記事にします。

WebシステムUIのアクセシビリティを向上させるモチベーション

エンジニアだけで改善できるアクセシビリティの代表的な項目といえばUIのマシンリーダビリティです。
PCやスマートフォンでWebサイトを閲覧している場合、マシンリーダビリティが気になるようなことは日常的には多くありません。
重要性を語るために頻繁に取り上げられる例としては、音声読み上げソフトなどが挙げられます。画面を見ることができないユーザーが音声読み上げソフトを頼りにWebサイトを操作する際、マシンリーダビリティが低いとボタンやダイアログなどのUI部品に気がつくことができません。

身体的にハンディキャップを持たない人でも、マウスやトラックパットが使えない時にWebサイトを操作する場合は、Tabを使ってボタンなどのUI部品を選択することになりますが、この時にもマシンリーダビリティが重要になります。
最近ではPS5、Switchといったゲーム機でもコントローラーを利用してウェブブラウザを使用できますが、ボタンを認識してカーソルが吸い付くなどのサポートが備えられていることもあります。これまでにないハードウェアの登場によって、突如マシンリーダービリティの重要性が増すことも考えられるでしょう。

アクセシビリティが高いHTMLの例

本記事ではアクセシビリティの向上方法については多く触れないこととしますので、ほんの一例だけ紹介します。

UI部品に対して適切なHTMLタグが使用されている

最も重要なことは、**実装するUI部品に対して適切なHTMLタグを使用することです。**ボタンにはbuttonタグを、フォームにはformタグを使用しましょう。
button, linkを使ってクリッカブルな要素を実装すればマウスを使用せずにキーボードだけでTab遷移によるUI操作が可能になります。

// タブで選択できる 機械から見ても人から見てもボタンだと明らか
<button type="button" onClick={handleClick}>購入する</button>

// タブキーでフォーカスできない 機械的にボタンであると判別できない
<div onClick={handleClick}>購入する</div>

UI部品に対して、適切なaria属性が指定されている

aria-role(role属性)や、aria-checkedなどの属性をUIに正しく付与することでアクセシビリティが向上します。
ダイアログなどは専用のHTMLタグが存在しないので、role="dialog"を指定することで、リーダーに対してその要素がダイアログだと伝えることができます。
タブなどにはaria-selected属性を付与することで、どのタブが選択中あるかをリーダーに伝えることができます。

<!-- roleが指定されていなかったらこのHTMLがダイアログを表していることが機械的に判別できない -->
<div role="dialog">
  <form>
    <h2>利用者登録</h2>
   <label>名前
    <input type="text" name="name"></input>
    </label>
    <button type="submit">送信</button>
  </form>
</div>

React Testing Library

Reactのコンポーネントをテストするライブラリ。Jestと組み合わせることで、コンポーネントの挙動を実際にユーザーが使用するのと近い形でテストすることができます。
この記事では観点をアクセシビリティ改善に絞るため、このライブラリでの細かいテストの書き方については触れないことにします。
使い方を学ぶ際には公式ページのExampleが参考になります

アクセシビリティ検証の事前知識

React Testing Libraryには明示的に「アクセシビリティをテストする」という機能はありません。
公式ドキュメントが推奨する方法でコンポーネントのテストを作成し、それを通過するコンポーネントを作成することでアクセシビリティの高い状態になるというのが正しい表現です。
そのため、公式ドキュメントをよく読んで、推奨されるAPIの利用方法を理解することが肝要です。

Queryの優先順位

React Testing Libraryには、コンポーネント上のDOM要素を取得するためのクエリメソッドがいくつか用意されています。
どれを使用しても画面機能のテストを行うことはできますが、公式ドキュメントにはクエリの優先順位が定義されています。
高い優先順位のクエリはタグの種類やaria-roleを頼りにDOMを検索するため、スクリーンリーダーなどのWeb参照をサポートするツールに近い方法でコンポーネントを操作するテストが書けるようになります。
利用が推奨されているクエリについて公式ドキュメントを参考にしつつ簡潔に紹介します。
公式ドキュメント -About Queries-

1: getByRole

ariaロールに従って要素を取得するクエリです。
アクセシビリティツリー上の全ての要素に対して利用可能であり、このクエリが全てのテストにおいて最も多く使用されるべきです。
もしこのクエリで取得できない要素が多い場合、テスト対象のUIはアクセシブルではない可能性があります。

2: getByLabelText

主にフォーム内の入力要素を取得するのに使うことになります。
ラベルを見て要素を識別して入力対象のフィールドを見つけるのはユーザーのユースケースそのものであるため、フォームのテストにおいてはこのメソッドが一番利用されるべきです。

3: getByPlaceholderText

テキストボックスなどのプレースホルダーを参照するクエリです。
placeholderはラベルの代替として、他に使用できるクエリがない場合に使用するべきです。

4: getByText

テキスト(TextContent)を参照するクエリです。フォームの外側のインタラクティブではない要素の取得に使われます。(span, div, pなど)
挙動が明快でわかりやすいので慣れない内は多用しがちですが、上記の高優先度のクエリが利用できるならそちらに置き換えるべきです。
また、テスト対象のコンポーネントのアクセシビリティが低くてLabelTextなどが判別できない場合、やむなくこのクエリを多用してテストを作成することもあります。

5: getByDisplayValue

フォームの表示中の入力値で要素を探します。あらかじめ初期値が入っているフォームの要素を探す時に使えます。

サンプルを使った説明

クエリの優先順位を説明したところで、実際にサンプルコードでテストを書き、アクセシビリティと画面機能の検証をします。
下記のようなシンプルなフォームを用意します。
image.png

名前を入力し、動物の下にあるチェックボックスを任意の数チェックして送信するフォームです。
このフォームには以下の2つの仕様があり、これらと画面の基礎的な挙動をテストで検証するとします。

  • 名前か動物のどちらかが未入力であればボタンはdisabledになる。
  • submit後、SUCCESS!! という文字列が表示される。

アクセシビリティの低いコードをテスト駆動で改善する流れ

このフォームのコンポーネントのコードは以下のようになっています。divが多用され、セマンティックとは言えない状態です。

テスト駆動でアクセシビリティを改善する流れは以下です。

  1. 優先度が低いクエリを使用してもいいので、とりあえず通るテストコードを作成する。
  2. テストコード中の優先度が低いクエリを高いものに書き換える。(この状態でテストは落ちる)
  3. 書き換えたテストが通るようにコンポーネントのコードを書き換える。

記事の趣旨にはあまり関係しませんが、このサンプルコードの作成にはNext.jsを使用しており、スタイルの適用にはCSS Modulesを使用しています。
(見やすさのためにCSSもある程度適用していますが、記事の内容とは関係しないのでここには載せません。気になる場合はこのコードのリポジトリを参照してください。)

DivForm.tsx
import React, { useState } from "react";
import styles from "./divForm.module.css";

const DivForm: React.FC = () => {
  const [name, setName] = useState("");
  const [animals, setAnimals] = useState<string[]>([]);
  const [isDone, setIsDone] = useState(false);
  const handleCheck: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    if (e.target.checked) {
      setAnimals([...animals, e.target.value]);
      return;
    }
    setAnimals(animals.filter((animal) => animal !== e.target.value));
  };
  const isDisabled = !name || animals.length === 0;
  // 入力されている名前とチェックされている動物をコンソールに出す
  const handleSubmit = () => {
    if (isDisabled) return;
    setIsDone(true);
  };

  const checkList = [
    { name: "cat", label: "Cat" },
    { name: "dog", label: "Dog" },
    { name: "tiger", label: "Tiger" },
  ];
  return (
    <div>
      <div className={styles.mainHeading}>Test Form</div>
      <div className={styles.subHeading}>name</div>
      <input
        className={styles.input}
        type="text"
        name="name"
        onChange={(e) => setName(e.target.value)}
      />

      <div className={styles.subHeading}>
        <strong>choose animals</strong>
      </div>

      {/* 動物のチェックボックスリスト */}
      <div className={styles.form}>
        {checkList.map((item) => (
          <div key={item.name} className={styles.item}>
            <div>{item.label}</div>
            <input type="checkbox" name={item.name} onChange={handleCheck} />
          </div>
        ))}
      </div>

      <div
        className={`${styles.button} ${isDisabled ? styles.disabled : ""}`}
        onClick={handleSubmit}
      >
        submit
      </div>
      {isDone && <div className={styles.success}>SUCCESS!!</div>}
    </div>
  );
};

export default DivForm;

1.とりあえず通るテストを一つ書く

上記のコードにreact-testing-libraryでテストを記述していきます。
まずは一つ通るテストを記述します。

Divform.test.ts
import React from "react";
import { render, RenderResult, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import DivForm from ".";

describe("divForm", () => {
  let renderResult: RenderResult;
  beforeEach(() => {
    // 準備処理 テスト対象Componentの描画
    renderResult = render(<DivForm />);
  });
  afterEach(() => {
    // テスト終了後処理 テスト対象のアンマウント
    renderResult.unmount();
  });
  describe("初期状態", () => {
    test("フォームの見出しが表示されている", async () => {
      await waitFor(() => {
        expect(renderResult.getByText(/Test Form/));
      })
    });
  });
});

これを実行すると無事通過します。
image.png

2.テストコード中の優先度が低いクエリを高いものに書き換える

無事テストは通過しますが、先ほど紹介したクエリの優先順位を無視してgetByTextを使用しています。
試しに優先順位が最も高いgetByRoleに書き換えることにします。検証対象は見出しなので、"heading"ロールで取得できることが望ましいです。
よって下記のようにテスト部分を書き換えてみます。

test("フォームの見出しが表示されている", async () => {
  await waitFor(() => {
    expect(renderResult.getByRole("heading"));
  })
});

すると、テストは通りません。
image.png

見出しのTest Formという文字列はdivタグで書かれているため、見出し(heading)であると機械的に判別できないためです。

何が問題なのか?

スクリーンリーダー等のツールでも見出しを判別することができず、音声読み上げ等の精度に支障が出てしまいます。
これはマシンリーダビリティが低く、アクセシビリティが低い状態です。
MacのVoice Overを使用してみるとよく分かりますが「Test Form」という文字列は読み上げてくれるものの、それが見出しであることを読み上げてくれません。
image.png
上の画像の右側の箱がVoice Overの読み上げ内容です。ただのテキストとして認識されています。

3.書き換えたテストが通るようにコンポーネントのコードを書き換える

getByRole("heading")で見出しを取得できるようにするには、コンポーネントのコードを書き換える必要があります。
divで表現していた見出しをh1タグに書き換えてみます。

<!-- 修正前 -->
<div className={styles.mainHeading}>Test Form</div>
<!-- 修正後 -->
<h1 className={styles.mainHeading}>Test Form</h1>

これでもう一度getByRoleを使ったテストを実行すると、今度は通過します
image.png
見出しを表すh1タグは暗黙的に"heading"ロールが与えられているためです。
Voice Overで確認してみると「見出しレベル1」が読み上げに追加されており、アクセシビリティが向上したことが分かります。
image.png

このテスト駆動改善の流れを各UIに対して繰り返すことで、アクセシビリティを確保することができます。

サンプルコード全体の改善

主題であるテスト駆動アクセシビリティ改善の流れについては上で説明したことが全てです。
ここからは参考のために改善前、改善後のコードを記載しておきます。

1. まずは通過する画面機能のテストを作成する

先ほどのDivFormコンポーネントのテストを作成します。
前述の通り現状ではマシンリーダビリティが低いので、優先度が低いクエリを多用することになります。
妥協した箇所にはTODOコメントを記載しておきます。

DivForm.test.ts
import React from "react";
import {
  fireEvent,
  render,
  RenderResult,
  waitFor,
} from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import DivForm from ".";

describe("divForm", () => {
  let renderResult: RenderResult;
  beforeEach(() => {
    // 準備処理 テスト対象Componentの描画
    renderResult = render(<DivForm />);
  });
  afterEach(() => {
    // テスト終了後処理 テスト対象のアンマウント
    renderResult.unmount();
  });
  describe("初期状態", () => {
    test("フォームの見出しが表示されている", async () => {
      await waitFor(() => {
        // TODO: 見出しはheadingロールで取得する
        expect(renderResult.getByText("Test Form"));
      });
    });
    test("submitボタンが非活性状態になっている", async () => {
      // TODO: ロールでボタンを取得し、toBeDisabledでアサーションする
      const button = renderResult.getByText("submit");
      expect(button.className.includes("disabled")).toBeTruthy();
    });
  });
  describe("画面機能", () => {
    describe("nameフィールドに値を入力した時", () => {
      let nameTextField: HTMLElement;
      beforeEach(async () => {
        await waitFor(() => {
          // TODO: ラベルの文字を読んで対応するテキストボックスを取得する
          nameTextField = renderResult.getByRole("textbox");
        });
        fireEvent.change(nameTextField, { target: { value: "Kontam" } });
      });
      test("フィールドに値が反映される", async () => {
        await waitFor(() => {
          expect(nameTextField).toHaveValue("Kontam");
        });
      });
      describe("加えて、animalsのtigerをチェックした時", () => {
        let tigerCheckbox: HTMLElement;
        let submitButton: HTMLElement;
        beforeEach(async () => {
          await waitFor(() => {
            // TODO: ラベルの文字を読んで対応するチェックボックスをクリックする
            const checkboxes = renderResult.getAllByRole("checkbox");
            tigerCheckbox = checkboxes[2];
          });
          fireEvent.click(tigerCheckbox);
          submitButton = renderResult.getByText("submit");
        });
        test("チェックボックスにチェックが入る", async () => {
          expect(tigerCheckbox).toBeChecked();
        });

        test("submitボタンが活性化する", async () => {
          // TODO: ロールでボタンを取得し、toBeDisabledでアサーションする
          await waitFor(() =>
            expect(submitButton.className.includes("disabled")).toBeFalsy()
          );
        });

        test("submitボタンを押下するとSUCCESSが表示される", async () => {
          fireEvent.click(submitButton);
          await waitFor(() =>
            expect(renderResult.getByText(/SUCCESS/i)).toBeInTheDocument()
          );
        });
      });
    });
  });
});

このテストを実行すると下記のように成功します。
マシンリーダビリティは低いながらも、画面の仕様が満たせていることは検証できるようになりました。

image.png

ここまででコミットしておきましょう。

2.テストコード中の優先度が低いクエリを高いものに書き換える

次はテストコードの中で妥協してしまった箇所を改善していきます。
より優先度の高いクエリを使用することと、UIの操作はできる限りユーザーが実際に行う手順に近くなるように書き換えていきます。

本来であればtestを一つ一つ改善していくべきですが、記事が長くなるのでここでは一度に全て書き換えてしまいます。

DivForm.test.tsx
import React from "react";
import {
  fireEvent,
  render,
  RenderResult,
  waitFor,
} from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import DivForm from ".";

describe("divForm", () => {
  let renderResult: RenderResult;
  beforeEach(() => {
    // 準備処理 テスト対象Componentの描画
    renderResult = render(<DivForm />);
  });
  afterEach(() => {
    // テスト終了後処理 テスト対象のアンマウント
    renderResult.unmount();
  });
  describe("初期状態", () => {
    test("フォームの見出しが表示されている", async () => {
      await waitFor(() => {
        expect(renderResult.getByRole("heading", { name: /Test Form/i }));
      });
    });
    test("submitボタンが非活性状態になっている", async () => {
      const button = renderResult.getByRole("button", { name: /submit/i });
      expect(button).toBeDisabled();
    });
  });
  describe("画面機能", () => {
    describe("nameフィールドに値を入力した時", () => {
      let nameTextField: HTMLElement;
      beforeEach(async () => {
        await waitFor(() => {
          nameTextField = renderResult.getByLabelText("name", {
            selector: "input",
          });
        });
        fireEvent.change(nameTextField, { target: { value: "Kontam" } });
      });
      test("フィールドに値が反映される", async () => {
        await waitFor(() => {
          expect(nameTextField).toHaveValue("Kontam");
        });
      });
      describe("加えて、animalsのtigerをチェックした時", () => {
        let tigerCheckbox: HTMLElement;
        let submitButton: HTMLElement;
        beforeEach(async () => {
          await waitFor(() => {
            tigerCheckbox = renderResult.getByLabelText("Tiger", {
              selector: "input",
            });
          });
          fireEvent.click(tigerCheckbox);
          submitButton = renderResult.getByRole("button", { name: /submit/i });
        });
        test("チェックボックスにチェックが入る", async () => {
          await waitFor(() => {
            expect(tigerCheckbox).toBeChecked();
          });
        });

        test("submitボタンが活性化する", async () => {
          await waitFor(() => expect(submitButton).toBeEnabled());
        });

        test("submitボタンを押下するとSUCCESSが表示される", async () => {
          fireEvent.click(submitButton);
          await waitFor(() =>
            expect(renderResult.getByText(/SUCCESS/i)).toBeInTheDocument()
          );
        });
      });
    });
  });
});

このテストを実行すると全てFailします。

image.png

次は、このテストが通るように一つずつHTMLを改善していきます。
このテストが全て通った時、アクセシビリティは格段に向上しているはずです。

3.書き換えたテストが通るようにコンポーネントのコードを書き換える

テストを上から一つ一つ通るようにHTMLを改善していきましょう。
難しいことはなく、基本に忠実にラベルにはLabelタグを使い、ボタンにはbuttonタグを使うように置き換えていくだけです。
それでも通せないテストがある場合、ふさわしいaria属性がないかを調べてみましょう。

本来は一つずつやるべきですが、記事が長くなるのでここでは一度に全てのタグを書き換えてしまいます。

DivForm.tsx
import React, {useState} from 'react';
import styles from './divForm.module.css';

const DivForm: React.FC = () => {
  const [name, setName] = useState('');
  const [animals, setAnimals] = useState<string[]>([]);
  const [isDone, setIsDone] = useState(false);
  const handleCheck: React.ChangeEventHandler<HTMLInputElement> = e => {
    if (e.target.checked) {
      setAnimals([...animals, e.target.value]);
      return;
    }
    setAnimals(animals.filter(animal => animal !== e.target.value));
  };
  const isDisabled = !name || animals.length === 0;
  // 入力されている名前とチェックされている動物をコンソールに出す
  const handleSubmit = () => {
    if (isDisabled) return;
    setIsDone(true);
  };

  const checkList = [
    {name: 'cat', label: 'Cat'},
    {name: 'dog', label: 'Dog'},
    {name: 'tiger', label: 'Tiger'},
  ];
  return (
    <div>
      <h1 className={styles.mainHeading}>Test Form</h1>
      <label className={styles.singleLabel}>
        <p>name</p>
        <input
          className={styles.input}
          type="text"
          name="name"
          onChange={e => setName(e.target.value)}
        />
      </label>

      <h2 className={styles.subHeading}>choose animals</h2>

      {/* 動物のチェックボックスリスト */}
      <ul className={styles.form}>
        {checkList.map(item => (
          <li key={item.name} className={styles.item}>
            <label>
              <p>{item.label}</p>
              <input type="checkbox" name={item.name} onChange={handleCheck} />
            </label>
          </li>
        ))}
      </ul>

      <button
        onClick={handleSubmit}
        disabled={isDisabled}
        className={`${styles.button} ${isDisabled ? styles.disabled : ''}`}>
        submit
      </button>
      {isDone && <div className={styles.success}>SUCCESS!!</div>}
    </div>
  );
};

export default DivForm;

いわゆるセマンティックなHTMLに近くなったと思います。
この状態で、先ほど優先度の高いクエリに書き換えたテストを流してみます。
image.png
テストが通るようになりました。
コードはマシンリーダビリティ高く改善され、テストコードによって改悪するような変更を検知できるようになりました。
image.png
VoiceOverでの挙動の一例を紹介すると、Tigerのチェックボックスにフォーカスした時にラベル、チェック状態、候補の数が読み上げられるようになっています。

まとめ

react-testing-libraryを使ったアクセシビリティ改善の魅力はテスト駆動開発のような感覚で改善を進められることです。
機械的にUIを操作するとしたら、どのように要素を識別してUIを操作するか想像しながらテストを作成することで強固な検証を行うことができるでしょう。

とはいえ、書き換えた後のコードをみていただければ分かる通りformタグを利用したりしていなかったり、改善の余地が残されています。
formロールを持つDOMの存在を確認するテストを設けることで検証することができ、検証の厳しさを自在に変えられるのが魅力の一つではありますが、操作観点だけで無く閲覧観点でも上級なアクセシビリティを目指すのであれば、何かもう一つくらい他の検証の仕組みを用意すると良いかもしれません。

この記事で紹介したアプローチの最大の魅力は、画面機能テストのついでにアクセシビリティを検証できるという手軽さにあります。
テストコードの書き方次第で検証の厳しさを柔軟に変えられるので、既存コードの段階的な改善にも向いています。
こういったライトな方法が浸透していくことで、世の中のWebシステムのアクセシビリティが少しでも改善していけば良いなと思っています。

参考リンク

サンプルコード Kontam/test-driven-a11y
React Testing Library 公式ドキュメント

Discussion