🎩

AtomicDesign の仕分けをテストで判定する

2022/07/04に公開約15,600字

AtomicDesign には「Atoms か Molecules か?、Molecules か Organisms か?」の判定が悩ましい…という課題があります。これは、フロントエンド・デザイナー間ではもちろん、デザイナー同士でも解釈差異があるようです。明確な判定軸がなければ、いずれブレが生じ、規律のないデザインシステムになりかねません。

この状況に対し「アクセシビリティを軸にする」という発想に至り、どのように仕分けるかを先日の投稿で紹介しました。このアイディアはタイトルのとおり「テスト」で機械的に判定可能です。

AtomicDesign カスタムマッチャー関数

「AtomicDesign の仕分けをテストで判定する」ために、独自のカスタムマッチャー関数を設けます。普段利用している'@testing-library/jest-dom'もカスタムマッチャーのライブラリですが、このカスタムマッチャー関数は簡単に自作し、プロジェクトに組み込むことができます。次のtoBeAtomといった見慣れないマッチャー関数が、今回紹介する独自カスタムマッチャー関数です。

const { container } = render(<Component />);
expect(container).toBeAtom();
expect(container).toBeMolecule();
expect(container).toBeOrganism();
expect(container).toBeTemplate();

このカスタムマッチャー関数を使うと、以下の様なテストケースが書けます。各々ディレクトリに正しく仕分けられていることが証明できますし、コンポーネントに対し、はじめに書くテストケースとしても良さそうです。

test("Atom である", () => {
  const { container } = render(<Button />);
  expect(container).toBeAtom();
});
test("Molecule である", () => {
  const { container } = render(
    <TextboxWithTitle
      labelProps={{ children: "お名前" }}
      textboxProps={{ name: "name" }}
    />
  );
  expect(container).toBeMolecule();
});

Testing Library の getRoles 関数について

冒頭のとおり「アクセシビリティ」を判定材料とするため着眼したのが、Testing Library の API である「getRoles」です。つぎの公式ドキュメント引用コードのとおり、DOM ノードから「ロール名でインデックス付けされたオブジェクト」が取得できることがわかります。

import { getRoles } from "@testing-library/dom";

const nav = document.createElement("nav");
nav.innerHTML = `
<ul>
  <li>Item 1</li>
  <li>Item 2</li>
</ul>`;
console.log(getRoles(nav));

// Object {
//   navigation: [<nav />],
//   list: [<ul />],
//   listitem: [<li />, <li />]
// }

このオブジェクトに含まれる「role」の種類によって「Atom である、Molecule である」といった判定を行っていきます。

Jest のカスタムマッチャー関数について

予備知識として、Jest のカスタムマッチャー関数について紹介します。expect.extend(matchers)が、カスタムマッチャー関数を追加する API です。つぎの公式ドキュメント引用コードのように、toBeWithinRangeが拡張されたマッチャー関数です。

expect.extend({
  toBeWithinRange(received, floor, ceiling) {
    const pass = received >= floor && received <= ceiling;
    if (pass) {
      return {
        message: () =>
          `expected ${received} not to be within range ${floor} - ${ceiling}`,
        pass: true,
      };
    } else {
      return {
        message: () =>
          `expected ${received} to be within range ${floor} - ${ceiling}`,
        pass: false,
      };
    }
  },
});

test("numeric ranges", () => {
  expect(100).toBeWithinRange(90, 110);
  expect(101).not.toBeWithinRange(0, 100);
  expect({ apples: 6, bananas: 3 }).toEqual({
    apples: expect.toBeWithinRange(1, 10),
    bananas: expect.not.toBeWithinRange(11, 20),
  });
});

カスタムマッチャー関数はtoBeWithinRange(expectで受け取る引数, ...マッチャー関数で受け取る引数)というように定義し{ message: () => string; pass: boolean }を返す必要があります。

この定義ファイルをjest.config.jssetupFilesAfterEnvに指定すれば、明示的な import をせずに、各々テストケースでカスタムマッチャー関数を利用できるようになります(TypeScript を使用している場合、namespace に型定義を追加する必要がありますので、公式ドキュメントを参考ください)

ルールの確認

カスタムマッチャー関数を作り込む前に、どういった判定で仕分けるのかを再確認します。概ね本家のAtomicDesignに近い?分類になっているように思いますが、あくまで本家はアクセシビリティに言及しているわけではないので、これは独自ルールであることをご了承ください。

分類 要約 概要
Atoms 単一要素 role を有す要素一つ以下で構成されるコンポーネント
Molecules 複合要素 role を有す要素二つ以上で構成されるコンポーネント
Organisms 主要要素 ランドマークロール・ウィンドウロールを含むコンポーネント(main を除く)
Templates ページと対のコンポーネント main role を含むコンポーネント

あらかたこの仕分け内容になりますが、さらに細かい分岐判定については、後続のサンプルコードを参照ください。

カスタムマッチャー関数定義

いよいよカスタムマッチャー関数の定義です。はじめに Testing Library のgetRoles関数で、ロールオブジェクトを取得します。このオブジェクトは残念ながら a11y tree 構造を確認できるものではなく、role 属性でクエリ可能な要素一覧を取得できるのみです。「どんな role が含まれるか?」を取得し、不要な暗黙ロールは取り除いておきます(ignoresRoles

const ignoresRoles = ["generic", "presentation"];

function getRoleKeys(container: HTMLElement) {
  return Object.keys(getRoles(container)).filter(
    (key) => !ignoresRoles.includes(key)
  );
}

たとえば、先ほど引用したサンプルコードの場合、ランドマークであるnavigationを含んでいます。これをもってOrganismという判定をします。

import { getRoles } from "@testing-library/dom";

const nav = document.createElement("nav");
nav.innerHTML = `
<ul>
  <li>Item 1</li>
  <li>Item 2</li>
</ul>`;
console.log(getRoleKeys(nav));

// ["navigation", "list", "listitem"]

どの様な DOM でどう判定されるのかは、以下「カスタムマッチャー関数定義テスト」を開いて確認してみてください。<header><footer>は構成によって Landmark になったり・ならなかったりするので、微調整しています(マークアップ上に<header><footer>が複数存在するのは正しく、Organisms / Molecules どちらとも言えるため)

カスタムマッチャー関数定義詳細
import { getRoles } from "@testing-library/react";

interface CustomMatchers<R = unknown> {
  toBeAtom: () => R;
  toBeMolecule: () => R;
  toBeOrganism: () => R;
  toBeTemplate: () => R;
}

declare global {
  namespace jest {
    interface Expect extends CustomMatchers {}
    interface Matchers<R> extends CustomMatchers<R> {}
    interface InverseAsymmetricMatchers extends CustomMatchers {}
  }
}

const groupRoles = [
  "group",
  "article",
  "list",
  "term",
  "tablist",
  "tabpanel",
  "table",
  "rowgroup",
  "row",
  "combobox",
];

const maybeLandmarkRoles = ["banner", "contentinfo"];

const landmarkRoles = [
  "complementary",
  "form",
  // "main",
  "navigation",
  "region",
  "search",
];

const windowRoles = ["alertdialog", "dialog"];

const ignoresRoles = ["generic", "presentation"];

function includeGroupRole(keys: string[]) {
  return keys.map((key) => groupRoles.includes(key)).some(Boolean);
}

function includeMaybeLandmarkRole(keys: string[]) {
  return keys.map((key) => maybeLandmarkRoles.includes(key)).some(Boolean);
}

function includeLandmarkRole(keys: string[]) {
  return keys.map((key) => landmarkRoles.includes(key)).some(Boolean);
}

function includeWindowRole(keys: string[]) {
  return keys.map((key) => windowRoles.includes(key)).some(Boolean);
}

function includeMainRole(keys: string[]) {
  return keys.includes("main");
}

function getRoleKeys(container: HTMLElement) {
  return Object.keys(getRoles(container)).filter(
    (key) => !ignoresRoles.includes(key)
  );
}

function fail(message: string) {
  return { pass: false, message: () => message };
}

function toBeAtom(container: HTMLElement): jest.CustomMatcherResult {
  const keys = getRoleKeys(container);
  if (keys.length >= 2) {
    return fail("Atom should structed by single role.");
  }
  if (includeGroupRole(keys)) {
    return fail("Atom should not include group role.");
  }
  if (includeWindowRole(keys)) {
    return fail("Atom should not include window role.");
  }
  if (
    includeLandmarkRole(keys) ||
    includeMaybeLandmarkRole(keys) ||
    includeMainRole(keys)
  ) {
    return fail("Atom should not include landmark role.");
  }
  return { pass: true, message: () => "it Atom" };
}

function toBeMolecule(container: HTMLElement): jest.CustomMatcherResult {
  const keys = getRoleKeys(container);
  if (!(keys.length >= 2)) {
    return fail("Molecule should structed by multiple role.");
  }
  if (includeLandmarkRole(keys)) {
    return fail("Molecule should not include landmark role.");
  }
  if (includeWindowRole(keys)) {
    return fail("Molecule should not include window role.");
  }
  if (includeMainRole(keys)) {
    return fail("Molecule should not include main role.");
  }
  return { pass: true, message: () => "it Molecule" };
}

function toBeOrganism(container: HTMLElement): jest.CustomMatcherResult {
  const keys = getRoleKeys(container);
  if (!(keys.length >= 2)) {
    return fail("Organism should structed by multiple role.");
  }
  if (
    !(
      includeLandmarkRole(keys) ||
      includeMaybeLandmarkRole(keys) ||
      includeWindowRole(keys)
    )
  ) {
    return fail("Organism should structed by landmark or window role.");
  }
  if (includeMainRole(keys)) {
    return fail("Organism should not include main role.");
  }
  return { pass: true, message: () => "it Organism" };
}

function toBeTemplate(container: HTMLElement): jest.CustomMatcherResult {
  const keys = getRoleKeys(container);
  if (!includeMainRole(keys)) {
    return fail("Template should include main role.");
  }
  return { pass: true, message: () => "it Template" };
}

expect.extend({ toBeAtom, toBeMolecule, toBeOrganism, toBeTemplate });
カスタムマッチャー関数定義テスト
import "@testing-library/jest-dom";
import { render } from "@testing-library/react";

describe("Template", () => {
  test("<main>(role=main)", () => {
    const { container } = render(
      <main>
        <h2>Test</h2>
        <button>+1</button>
      </main>
    );
    expect(container).not.toBeAtom();
    expect(container).not.toBeMolecule();
    expect(container).not.toBeOrganism();
    expect(container).toBeTemplate();
  });
});

describe("Organisms", () => {
  function asserts(container: HTMLElement) {
    expect(container).not.toBeAtom();
    expect(container).not.toBeMolecule();
    expect(container).toBeOrganism();
    expect(container).not.toBeTemplate();
  }
  test("<aside>(role=complementary)", () => {
    const { container } = render(
      <aside>
        <h2>Test</h2>
        <button>+1</button>
      </aside>
    );
    asserts(container);
  });
  test("<form>(role=form)", () => {
    const { container } = render(
      <form aria-labelledby="test">
        <h2 id="test">Test</h2>
        <button>+1</button>
      </form>
    );
    asserts(container);
  });
  test("<form>(role=search)", () => {
    const { container } = render(
      <form role="search">
        <h2>Test</h2>
        <button>+1</button>
      </form>
    );
    asserts(container);
  });
  test("<nav>(role=navigation)", () => {
    const { container } = render(
      <nav>
        <h2>Test</h2>
        <button>+1</button>
      </nav>
    );
    asserts(container);
  });
  test("<section>(role=region)", () => {
    const { container } = render(
      <section aria-labelledby="test">
        <h2 id="test">Test</h2>
        <button>+1</button>
      </section>
    );
    asserts(container);
  });
  test("<div>(role=alertdialog)", () => {
    const { container } = render(
      <div role="alertdialog">
        <h2 id="test">Test</h2>
        <button>+1</button>
      </div>
    );
    asserts(container);
  });
  test("<div>(role=dialog)", () => {
    const { container } = render(
      <div role="dialog">
        <h2 id="test">Test</h2>
        <button>+1</button>
      </div>
    );
    asserts(container);
  });
});

describe("Molecules", () => {
  function asserts(container: HTMLElement) {
    expect(container).not.toBeAtom();
    expect(container).toBeMolecule();
    expect(container).not.toBeOrganism();
    expect(container).not.toBeTemplate();
  }
  test("<form>(role=none)", () => {
    const { container } = render(
      <form>
        <h2>Test</h2>
        <button>+1</button>
      </form>
    );
    asserts(container);
  });
  test("<div>(role=group)", () => {
    const { container } = render(
      <div role="group">
        <h2>Test</h2>
      </div>
    );
    asserts(container);
  });
  test("<article>(role=artilce)", () => {
    const { container } = render(
      <article>
        <h2>Test</h2>
      </article>
    );
    asserts(container);
  });
  test("<ul>(role=list)", () => {
    const { container } = render(
      <ul>
        <li></li>
      </ul>
    );
    asserts(container);
  });
  test("<ol>(role=list)", () => {
    const { container } = render(
      <ol>
        <li></li>
      </ol>
    );
    asserts(container);
  });
  test("<dl>(role=term)", () => {
    const { container } = render(
      <dl>
        <dt></dt>
        <dd></dd>
      </dl>
    );
    asserts(container);
  });
  test("<div>(role=tablist)", () => {
    const { container } = render(
      <div role="tablist">
        <p role="tab"></p>
      </div>
    );
    asserts(container);
  });
  test("<div>(role=tabpanel)", () => {
    const { container } = render(
      <div role="tabpanel">
        <h2>Test</h2>
      </div>
    );
    asserts(container);
  });
  test("<table>(role=table)", () => {
    const { container } = render(
      <table>
        <thead>
          <tr />
        </thead>
        <tbody>
          <tr />
        </tbody>
      </table>
    );
    asserts(container);
  });
  test("<select>(role=combobox)", () => {
    const { container } = render(
      <select>
        <option value={0}>A</option>
        <option value={1}>B</option>
      </select>
    );
    asserts(container);
  });
  test("<section>(multiple roles)", () => {
    const { container } = render(
      <section>
        <h2 id="test">Test</h2>
        <button>+1</button>
      </section>
    );
    asserts(container);
  });
});

describe("Organisms or Molecules", () => {
  // MEMO: header footer は構成 Node によって AsNonLandmark になりえるため
  function asserts(container: HTMLElement) {
    expect(container).not.toBeAtom();
    expect(container).toBeMolecule();
    expect(container).toBeOrganism();
    expect(container).not.toBeTemplate();
  }
  test("<header>(role=banner)", () => {
    const { container } = render(
      <header>
        <h2>Test</h2>
        <button>+1</button>
      </header>
    );
    asserts(container);
  });
  test("<footer>(role=contentinfo)", () => {
    const { container } = render(
      <footer>
        <h2>Test</h2>
        <button>+1</button>
      </footer>
    );
    asserts(container);
  });
});

describe("Atoms", () => {
  function asserts(container: HTMLElement) {
    expect(container).toBeAtom();
    expect(container).not.toBeMolecule();
    expect(container).not.toBeOrganism();
    expect(container).not.toBeTemplate();
  }
  test("<p>(role=none)", () => {
    const { container } = render(<p>test</p>);
    asserts(container);
  });
  test("<h1>(role=heading)", () => {
    const { container } = render(<h1>test</h1>);
    asserts(container);
  });
  test("<a>(role=link)", () => {
    const { container } = render(<a href="#">test</a>);
    asserts(container);
  });
  test("<button>(role=button)", () => {
    const { container } = render(<button>test</button>);
    asserts(container);
  });
  test("<input>(role=textbox)", () => {
    const { container } = render(<input type="text" />);
    asserts(container);
  });
  test("<textarea>(role=textbox)", () => {
    const { container } = render(<textarea />);
    asserts(container);
  });
  test("<div>(single role)", () => {
    const { container } = render(
      <div>
        <img alt="picture" />
        <p>test</p>
      </div>
    );
    asserts(container);
  });
  test("<label>(role=none)", () => {
    const { container } = render(
      <label>
        <input type="checkbox" id="check" />
        Test
      </label>
    );
    const { container: htmlFor } = render(
      <>
        <label htmlFor="check">Test</label>
        <input type="checkbox" id="check" />
      </>
    );
    asserts(container);
    asserts(htmlFor);
  });
  test("text(role=none)", () => {
    const { container } = render(
      <div>
        <p>test</p>
      </div>
    );
    asserts(container);
  });
});

まとめ

紹介した判定軸は、あくまで筆者の判定軸です。linter の設定と同じように、コーディングガイドライン策定は開発者間で決定することです。本稿で最も述べたかったのは 「策定したガイドラインは何かしら機械的な方法で縛れるべき」 ということです。

「UI とアクセシビリティツリーを紐づけるのは無理がある」という意見もあるかもしれませんが、こういった仕組みから着手することでも、アクセシビリティに向き合う機会になるのではないでしょうか。

Sample

https://github.com/takefumi-yoshii/nextjs-testing-strategy-2022

Discussion

ログインするとコメントできます