test-matchersで使う
ユニットテストやコンポーネントテスト向けです。実際のブラウザーやヘッドレスブラウザーを操作するのではなく、JestやVitestのテストの中で、テストがすでに持っているDOMに対してアクセシビリティをその場でアサーションします。レンダリングされたページ全体をテストする場合は、ブラウザーを操作するバインディングを参照してください。
@surea11y/test-matchersはtoHaveNoA11yViolations()と、ライブリージョン向けのtoHaveAnnounced()を提供します。2つとも、手を加えずにそのまま、どちらのフレームワークのexpect.extend()にも組み込めます。フレームワーク固有のコードは持っていません。テストがすでに動いているjsdomベースの環境をスキャンするもので、jsdom自体に依存したり、環境を用意したりはしません。surea11yのエンジンは同期的に動くので、このマッチャーも同期的です。awaitは不要で、書き忘れて解決済みのPromiseに対して黙って成功してしまうこともありません。
$ npm install --save-dev @surea11y/core @surea11y/test-matchers
@surea11y/coreはマッチャーが実行するエンジンです。上のとおり、マッチャーと一緒にインストールしてください。テストランナーの外で、ビルドしたHTMLをターミナルやCIのステップからチェックしたい場合はCLIを参照してください。
セットアップ
テスト環境はあらかじめjsdomベースになっている必要があります。このマッチャーは、その環境が提供するdocument/windowをスキャンします。テストランナーがすでに読み込んでいるセットアップファイルで、マッチャーを1度だけ登録してください。
Jest
// jest.setup.jsexpect.extend({toHaveNoA11yViolations: require('@surea11y/test-matchers').toHaveNoA11yViolations});// jest.config.jsmodule.exports = {testEnvironment: 'jsdom', // or the explicit jest-environment-jsdom package, depending on your Jest versionsetupFilesAfterEnv: ['./jest.setup.js']};
Vitest
// vitest.setup.jsimport { expect } from 'vitest';import { toHaveNoA11yViolations } from '@surea11y/test-matchers';expect.extend({ toHaveNoA11yViolations });// vitest.config.jsexport default {test: {environment: 'jsdom', // Vitest doesn't bundle jsdom -- install it as a devDependency toosetupFiles: ['./vitest.setup.js']}};
同梱の.d.tsは、Jestのグローバルなjest.Matchersインターフェースと、Vitestの@vitest/expectインターフェースの両方を拡張します。そのために@types/jestやvitestをimportする必要はありません。実際にインストールされているフレームワークに対応する拡張だけが有効になります。
使い方
通常のDOMノードに対して使えます。
test('no accessibility violations', () => {document.body.innerHTML = '<img src="logo.png">';expect(document.body).toHaveNoA11yViolations(); // fails: missing alt});
Testing Library系のcontainerにも使えます。React Testing Library、Vue Test Utilsのwrapper.element、Angular Testing Library、Svelte Testing Libraryのいずれも、レンダリングされたDOM要素を同じ形で公開しているからです。
const { render } = require('@testing-library/react');test('MyComponent has no accessibility violations', () => {const { container } = render(<MyComponent />);expect(container).toHaveNoA11yViolations();});
テストランナーの側でもフレームワークを問いません。マッチャーを登録すれば、上のコードはJestでもVitestでもまったく同じです。
.notによるアサーション
test('a page with a violation fails the assertion', () => {document.body.innerHTML = '<main><img src="logo.png"></main>';expect(document.body).not.toHaveNoA11yViolations();});
1回のスキャン結果を複数のアサーションで使い回す
複数のit()ブロックで特定のルールを毎回スキャンし直さずに確認するため、surea11yのスキャン結果を自分で計算している場合は、DOMノードの代わりにその結果オブジェクトを直接渡してください。
const { runDomRulesInPage } = require('@surea11y/core');const result = runDomRulesInPage(null, '#main', {}, null);expect(result).toHaveNoA11yViolations();
スキャンの絞り込みと設定
第2引数には@surea11y/coreが受け付けるものなら何でも渡せます。全体はエンジンオプションを参照してください。よくある例をいくつか挙げます。
// Only run rules relevant to WCAG 2.0 Aexpect(container).toHaveNoA11yViolations({ tags: { include: 'wcag2a' } });// Skip a rule you've decided not to enforce yetexpect(container).toHaveNoA11yViolations({ rules: { exclude: 'target-size-minimum' } });// Ignore a third-party widget you don't controlexpect(container).toHaveNoA11yViolations({ excludeSelectors: ['.intercom-launcher'] });// EN 301 549 V4.1.1 (WCAG 2.2 A and AA); 'en301549-v3.2.1' for V3.2.1 (WCAG 2.1 A and AA)expect(container).toHaveNoA11yViolations({ profile: 'en301549-v4.1.1' });
WCAGの目標を選ぶ(runOnly)
第3引数はスキャンのrunOnlyで、そのままエンジンに渡されます。runOnly.wcagは適合性の目標で選択します。{ wcag: { version: '2.2', level: 'AA' } }は、WCAG 2.2のレベルAとAAの達成基準のルールを、各達成基準がそのバージョンで持つレベルに従って実行します。bestPractices: trueは、regionやheading-orderなど、WCAGの達成基準を示さないルールを追加します。単独で使うとそれらのルールだけを実行し、engineOptions.profileと併用するとプロファイルのルールにそれらを追加します。
// WCAG 2.2 A and AAexpect(container).toHaveNoA11yViolations({}, { wcag: { version: '2.2', level: 'AA' } });// The same, and the best practicesexpect(container).toHaveNoA11yViolations({}, { wcag: { version: '2.2', level: 'AA' }, bestPractices: true });
runOnlyはルールのIDとタグ(includeRuleIds、tags、excludeRuleIdsなど)も受け取り、それらは目標のルールに追加されます。除外はその後に適用されます。何かを選択するrunOnlyは、engineOptions.rules/.tagsでの選択を完全に置き換えます。選択はどちらか一方の方法で指定し、両方を使わないでください。runOnlyだけが必要な場合は、engineOptionsとして{}かnullを渡してください。ルールIDは大文字小文字を区別しません('IMG-ALT-PRESENT')。
目標を指定すれば、そのレベルも決まります。代わりにルールやタグを指定するテストでは、engineOptions.wcagLevel(@surea11y/core1.12.0以降)でレベルを設定できます。実行するルールを変えずに、スキャンが報告する達成基準のレベルを決めるものです。contrast-computableは1.4.3(AA)と1.4.6(AAA)をチェックするため、下のスキャンは1.4.3だけを報告します。アサーションはfailの判定結果で決まるので、これで変わるのは結果に含まれる達成基準であり、テストを失敗させるものではありません。
expect(container).toHaveNoA11yViolations({ wcagLevel: 'AA' }, { includeRuleIds: ['contrast-computable'] });
パックを使ったスキャン
パックは、ルール、規格、チェックリストと、それらのプロファイルやメッセージを、それ自身のパッケージとして提供します。マッチャーはNodeでスキャンするため、パックはオブジェクトそのものとしてengineOptions.packsに渡し、そのプロファイルはengineOptions.profileで選びます。
const acmePolicy = require('./acme-policy-pack');const policy = { packs: [acmePolicy], profile: 'acme-web-2026' };test("a page that breaks the pack's house rule fails the assertion", () => {document.body.innerHTML = '<main><h1>Welcome</h1><video autoplay src="intro.mp4"></video></main>';expect(document).not.toHaveNoA11yViolations(policy);});// A pack's checklist profile, and the best practicesexpect(container).toHaveNoA11yViolations(policy, { bestPractices: true });
パック自身のルールはコアのルールと同じように結果に現れ、そのIDはパックの名前空間で始まります(acme-no-autoplay)。パックのオブジェクトは1か所(セットアップファイルやモジュール)にまとめてください。コアはパックのオブジェクトのセットごとにエンジンを用意し、同じセットでの次のスキャンのために保持します。コアが実行できないパック(そのcoreの範囲がインストールされているコアを含まない、妥当でない、名前空間がほかのパックと衝突する)は結果のskippedPacksに列挙され、パックのルールが一度も実行されないまま合格するのではなく、アサーションが失敗します。
API
| 引数 | 型 | 意味 |
|---|---|---|
received | Element、Document、またはスキャン結果 | チェック対象です。DOMのElement/Documentは自動的にスキャンされます。{ checksResults: [...] }の形をしたオブジェクトはスキャンせず、そのままアサーションの対象になります。それ以外を渡すとTypeErrorが投げられます。 |
engineOptions | object(省略可) | @surea11y/coreのスキャンにそのまま渡されます。エコシステムのほかの場所で使うものと同じオプションオブジェクトです(エンジンオプションを参照)。receivedがすでにスキャン結果の場合は無視されます。 |
runOnly | object、stringまたはstring[](省略可) | スキャンのrunOnlyとしてそのまま渡されます。WCAGの目標({ wcag: { version: '2.2', level: 'AA' } })、bestPractices、ルールのID、タグを指定できます。WCAGの目標を選ぶを参照してください。receivedがすでにスキャン結果の場合は無視されます。 |
options | オブジェクト(省略可) | { failOnSeverity, leanFail, wcagOnly, howToCheck }。重大な問題でだけ失敗させると不適合の可能性が高い問いを参照してください。それぞれconfigureA11yMatchers()で設定した既定値より優先されます。 |
2番目の引数には、代わりにすべてをまとめた1つのオブジェクト{ engineOptions, runOnly, failOnSeverity, leanFail, wcagOnly, howToCheck }を渡すこともできます(toHaveNoA11yViolations({ leanFail: true }))。これらのキーはどれもエンジンのオプションではなく、いずれかのキーがあればこの形として読まれます。これらとエンジンのオプションが混ざったオブジェクトはTypeErrorを投げます。
どちらのフレームワークのexpect.extend()も期待する{ pass, message }の形を返します。これを直接呼ぶことはありません。上の「セットアップ」で示したとおり、expect.extend()で1度登録するだけです。このパッケージは、スイート全体の既定値を設定するconfigureA11yMatchers(options)、Webコンポーネント向けのinstallInternalsHook(win?)、クリックハンドラーとライブリージョン向けのinstallListenerHook(win?)、installLiveRegionHook(win?)、toHaveAnnounced()マッチャーもエクスポートしています。
チェックすること、しないこと
アサーションを失敗させるのは失敗の検出結果だけです。これは、cantTellの結果は失敗ではなく参考情報とする、ほかのすべてのsurea11yバインディングの慣例に合わせています。各検出結果は、そのルールの判定結果ではなく、それ自身の判定結果で読まれます。ある要素で失敗したルールが別の要素について問いかけることがあり、その問いは失敗として数えられません。<a href="/pricing">Click here</a>のようなあいまいなリンクはfailではなくcantTellなので、leanFail(後述)で求めない限り、それだけで成功しているテストが壊れることはありません。ただし、ほかの理由でアサーションが実際に失敗した場合は、失敗のあとに問いが一覧になるので、見落とすことはありません。このエンジンがまったく自動化しないことについては既知の制限事項を参照してください。
求められたものをチェックできなかったスキャンも失敗します。何もないことで合格にはなりません。スキャンが実行されるドキュメントにない要素(切り離されたもの、または別のドキュメントのもの)、実行されなかったカスタムルール(skippedCustomRulesに列挙されます)、実行されなかったパック(skippedPacksに列挙されます)が該当します。メッセージはそれぞれを理由とともに示します。テスト自身が除外したカスタムルールは抜け漏れではないので、テストは失敗しません。
選択を指定しない呼び出しでは、ベストプラクティスのルールも実行されます。@surea11y/core1.12から、その1つであるempty-headingは、アクセシブルな名前のない見出しについて、以前のように問いかけるのではなく不合格にします。そのため、<h2></h2>を描画するコンポーネント(テストでタイトルのpropが空になっていることがよくあります)は、オプションなしの呼び出しで失敗します。スクリーンリーダーは中身のない見出しとして読み上げるので、テストに実際の内容を与えるか、コンポーネントを修正してください。ベストプラクティスを含まないWCAGの目標を指定した呼び出しでは、このルールは実行されません。
スキャン自体が例外を投げることもあり、その場合テストはエンジンのエラーで失敗します。エラーはcodeを持ちます。INVALID_RUN_ONLY(どの値もルールやタグを示さないルールやタグのリスト、または目標になっていないrunOnly.wcag)、INVALID_ENGINE_OPTIONS(strictOptions: trueのとき、エンジンが読まないオプションや使えない値)、PAGE_BUILTINS_BROKEN(テストやページ自身のコードが、エンジンが依存するJavaScriptの組み込みオブジェクトを壊した)のいずれかです。
要素に範囲を絞ると、その内側のことだけが報告されます。ページのほかの場所にある問題で、1つのcontainerに絞ったアサーションが失敗することはありません。内部的には、スキャン対象の要素に一時的に固有の属性を付け、そのセレクターでスキャンしています。テストからは見えません。
いくつかのルールはページ全体についての事実をチェックします。特定のサブツリーの中身ではありません。page-title-present、html-lang-attr-presentなどはdocument.title/document.documentElementを直接チェックします。任意のコンポーネントの断片に対して、ページにタイトルがあるかを問うのは意味がないからです。Element(Testing Libraryのcontainerやdocument.body)を渡すと、これらはfailではなくnotApplicableと報告されるので、<title>がなくてもコンポーネント単位のテストが壊れることはありません。documentそのものを渡すと、実際に評価されます。documentを直接スキャンする場合は、テストごとではなくセットアップファイルで1度だけdocument.titleとdocument.documentElementのlangを設定してください。
重大な問題でだけ失敗させる
ルールの結果にはそれぞれ重大度があります(critical、serious、moderate、minor)。ほかの問題は報告し続けながら、まず重大な問題でだけテストを失敗させるには、セットアップファイルでconfigureA11yMatchers()を使ってスイート全体のレベルを設定するか、1つのアサーションのオプションで設定します。オプションは既定値より優先されます(failOnSeverity: nullでそのアサーションだけ無効にできます)。
// jest.setup.js (vitest.setup.js: the same, with import)const { toHaveNoA11yViolations, configureA11yMatchers } = require('@surea11y/test-matchers');expect.extend({ toHaveNoA11yViolations });configureA11yMatchers({ failOnSeverity: 'serious' });// one assertionexpect(container).toHaveNoA11yViolations({ failOnSeverity: 'critical' });
レベル未満で失敗したルールはテストを失敗させませんが、テストが失敗した場合、メッセージはすべての失敗を一覧にし、レベル未満のものに印を付けます。surea11y scan --fail-on-severityと同じゲートです。何かを取りこぼしたスキャン(何にも一致しなかった範囲、実行されなかったカスタムルールやパック)は、レベルにかかわらずアサーションを失敗させます。
不適合の可能性が高い問い
cantTellは人に向けた問いです。@surea11y/core1.14から、問いは傾くことがあります。ページの証拠が一方を示している場合、どちら(failまたはpass)に傾くかとその理由を示し、coreのルールの各問いには答えるための手順(howToCheck)が含まれます。失敗メッセージは失敗のあとに問いを並べます。まず不適合の可能性が高いもの、次に適合の可能性が高いもの(それぞれ理由付き)、最後に未解決のものをルールごとに示します。{ howToCheck: true }を付けると、各問いを手順とともにすべて表示します。
既定では、問いがテストを失敗させることはありません。{ leanFail: true }を付けると不適合の可能性が高い問いでもテストが失敗し、wcagOnly: trueでベストプラクティスのルールのものを除けます。
test('the settings page has no failures or likely failures', () => {const { container } = render(<Settings />); // an <h1>, then an <h3>expect(container).toHaveNoA11yViolations(); // passes: heading-order is a questionexpect(container).toHaveNoA11yViolations({ leanFail: true }); // fails: it leans failexpect(container).toHaveNoA11yViolations({ leanFail: true, wcagOnly: true }); // passes: heading-order is a best practice});
傾きは証拠であって証明ではなく、検出結果が傾くかどうか、どちらに傾くかはcoreのマイナーリリースで変わることがあるため、オプトインになっています。configureA11yMatchers({ leanFail: true })でスイート全体に適用できます。
Webコンポーネント
多くのWebコンポーネントは、ElementInternalsをプライベートフィールドに保持し、それを通じて名前を付けたり、状態を設定したり、ロールを与えたりします。スクリプトからはそれを読み返せないため、既定ではスキャンはそうした要素を判定せずに問いかけます(cantTell)。テストは失敗しませんが、何もチェックされず、このように設定されたロールはまったく認識されません。
installInternalsHook(win?)を使うと、スキャンがそれを読めるようになります。すべてのattachInternals()の呼び出しを捉えられるよう、コンポーネントが定義される前に、セットアップファイルで1度呼び出してください。winの既定値はグローバルのwindowです。
// jest.setup.jsconst {toHaveNoA11yViolations,installInternalsHook} = require('@surea11y/test-matchers');installInternalsHook();expect.extend({ toHaveNoA11yViolations });
// vitest.setup.jsimport { expect } from 'vitest';import {toHaveNoA11yViolations,installInternalsHook} from '@surea11y/test-matchers';installInternalsHook();expect.extend({ toHaveNoA11yViolations });
これにより、ElementInternalsで名前を付けるコンポーネントは合格し、それを取り付けても名前を設定しないコンポーネントは名前のないコントロールと同じく失敗し、それを通じてだけswitchになるコンポーネントはスイッチとしてチェックされます。フックがあったかどうかは、スキャン結果のengine.environment.internalsHookでわかります。フックはテスト用ウィンドウのHTMLElement.prototype.attachInternalsをラップします。テストでは無害ですが、オプトインになっているのはこのためです。制限が1つあります。jest-environment-jsdom30に含まれるjsdom 26では、ElementInternalsにARIAプロパティを設定するコンポーネントは、フックの有無にかかわらず例外を投げます(jsdom 29を使うVitestでは問題ありません)。@surea11y/core1.14からはVitestでもフックが'early'として記録されるので、ElementInternalsを一度も取り付けていないコンポーネントも、そこで問いかけになることはなくなりました。詳しくはパッケージのREADMEを参照してください。
クリックハンドラーとライブリージョン
@surea11y/core1.14からのオプトインのフックがさらに2つあり、同じようにセットアップファイルで、自分のコードが実行される前にインストールします。installListenerHook()は、それ以降に追加されるクリック、キー、フォーカス、マウスのリスナーを記録し、キーボードのルールがそれを判定できるようにします。clickだけを待ち受ける<div role="button">は失敗し(CLICK_HANDLER_NO_KEY_LISTENER)、keydownも待ち受ければ合格します。installLiveRegionHook()は、ページのライブリージョンに何が与えられたかを記録します。スキャンはテキストが入った状態で追加された、スクリーンリーダーが読み上げないことの多いリージョンについて確認を求め、toHaveAnnounced()は操作の結果がユーザーに伝えられたかを確かめます。必要なものだけをインストールしてください。それぞれ独立しています。
// jest.setup.js (vitest.setup.js: the same, with import)const {toHaveNoA11yViolations,toHaveAnnounced,installInternalsHook,installListenerHook,installLiveRegionHook} = require('@surea11y/test-matchers');installInternalsHook();installListenerHook();installLiveRegionHook();expect.extend({toHaveNoA11yViolations,toHaveAnnounced});
test('saving says so', async () => {const user = userEvent.setup();const { container } = render(<SaveBar />);await user.click(screen.getByRole('button', { name: 'Save' }));expect(container).toHaveAnnounced('Saved');expect(window).not.toHaveAnnounced(/error/i);});
文字列はテキスト全体と一致する必要があり、正規表現は一部と一致すれば十分です。要素に対しては、その要素自身かその中にあるライブリージョンだけが対象です。windowやdocumentに対しては、フックをインストールしてからのすべての読み上げが対象です。これはページが読み上げ用に提供した内容で、スクリーンリーダーが実際に読み上げた内容ではありません。フックがなければtoHaveAnnounced()は例外を投げます。知っておくべきことが2つあります。Reactはリスナーを各要素ではなく描画先のルートに追加するので、Reactのコンポーネントでは、スキャンはルートのリスナーを見て、フックがない場合と同じくボタンをマークアップで判定します。また、ライブリージョンフックは変更をまとめて読むので、実際のユーザーと同じように、描画してから操作し、その間にawaitを挟んでください(await user.click())。詳しくはパッケージのREADMEを参照してください。
失敗メッセージ
ルールごとではなく、失敗した出現箇所をすべて一覧にします(1つのルールが複数の要素を指摘することがあります)。ルールID、重大度、読みやすい要約、失敗した要素のセレクター、あれば修正のヒントを示します。バグ報告に引用できるよう、結果を生成した@surea11y/coreのリリースを示し、そのあとに、失敗として数えない問いを一覧にします。画像の隣で見出しレベルが飛んでいる場合は次のとおりです。
expected no accessibility violations, but found 1:1) img-alt-present (serious): This <img> has no alt attribute.at body > main > imgAdd an alt attribute that says what the image shows, or alt="" if the image is purely decorative.Scanned with @surea11y/core 1.14.1.2 findings need manual review (cantTell, not counted as failures):1) heading-order (minor, likely fails): This heading jumps from level 1 to level 3, skipping a level.at body > main > h3Go down one level at a time, so an <h2> is followed by an <h3> rather than an <h4>, and the outline of the page stays easy to follow.Why it likely fails: The heading skips a level after the one before it.Open questions, which only a person can answer: manual-review.
バージョンの行の前に、スキャンがどのように実行されたかの注記が入ることがあります。internalsフックがあれば読めたカスタム要素についてスキャンが問いかけた場合や、リスナーフックやライブリージョンフックなら判断できた検出結果を判定した場合に、その件数とフックのインストールを促す行が入ります。{ leanFail: true }を付けると、不適合の可能性が高い問いは失敗の側に移り、見出しの行は両方を数えます(found 1 failure and 1 likely failure:)。
実行されなかったパックは、理由とともに示されます。
expected no accessibility violations, but the scan left out what it was asked to check:No accessibility violations found in what was scanned.Pack "@acme/a11y-policy" did not run: it supports core ^2.0.0, and this is core 1.14.0.Scanned with @surea11y/core 1.14.1.
両フレームワークの実行可能な例を並べて用意しています:Jest、Vitest、パックを使った同じ例:Jest、Vitest、リスナーフックとライブリージョンフック、toHaveAnnounced()、leanFailで小さなコンポーネントをチェックする例:Jest、Vitest。engineOptionsの全体(ロケール、コントラストのモード、Shadow DOM、カスタムルール、WCAGバージョンのタグの組み合わせ)についてはENGINE_OPTIONS.mdを参照してください。このパッケージは、そのリファレンスを重複して持つのではなく、渡されたものをそのまま転送します。