こんにちは、フロントエンドエンジニアのやなぎ(@apple_yagi)です。
PR TIMESのフロントエンドではこれまで、MarkuplintやStorybook Testを用いたアクセシビリティ(a11y)テストを実施してきました。以下の記事はMarkuplintを導入した際の記事になります。


こんにちは。PR TIMESでフロントエンドエンジニアをしている夛田(@unachang113)です。 今回はMarkuplintを導入した話をしようと思います。 【Markuplintとは】 Markupli…
しかし、PR TIMESではReactを利用して開発を行っているため、これまではコンポーネント単位での品質担保が中心となっていました。そこで、@axe-core/playwrightを導入し、HTML全体のa11y品質もチェックできる体制を整えました。
目次
MarkuplintとStorybook Testでカバーしきれなかった領域
これまでPR TIMESでは、MarkuplintやStorybook Testによるa11yテストを行ってきましたが、見出しの順序やページにh1タグが存在するかといったルールは無効化していました。Reactなどのコンポーネント指向でHTMLを書く場合、テストは各コンポーネント単位となり、HTML全体を通してa11yチェックを実施するのが難しかったためです。
// h1タグがない例
function Page() {
return (
{/* Hogeがh2タグを持ち、Fugaでh4タグを持つため、見出し階層が飛ばされる */}
);
}
function Hoge() {
return テスト
;
}
function Fuga() {
return Fuga
;
}
実際に、PR TIMESのトップページでも過去に見出し順序の不整合が発生しており、これは手動で見出しレベルを確認して対応していました。しかし、毎回目視でチェックするのは難しく、今後同じ問題が再発する可能性も十分にあります。そこで、@axe-core/playwrightを用いて自動で検知・防止できる仕組みの導入を検討しました。


こんにちは PR TIMES開発本部のインターンの Chanoknan です。 PR TIMES のトップページのアクセシビリティ改善に取り組みました。具体的にどのような改善を行ったのか…
@axe-core/playwright とは
@axe-core/playwrightは、アクセシビリティ検証ライブラリであるaxe-coreと、E2EテストフレームワークのPlaywrightを組み合わせて利用できるNode.js向けのa11yテスティングツールです。
Playwrightのテストコード内で各ページにaxe-coreを注入し、その場でa11yチェックを実行できます。これにより、画面全体のHTML構造や、複数コンポーネントが合成された「最終的なUI状態」に対して自動的にa11yエラーを検出できるのが大きな特徴です。
PR TIMESでは2023年からPlaywrightを用いたIntegration Testを導入していたため、既存のテスト基盤にスムーズに組み込むことができました。


こんにちは、フロントエンドエンジニアのやなぎ( @apple_yagi )です。 先日、フロントエンドのIntegration Testで使用されていたCypressをPlaywrightに移行したので、…
a11yテスト用のカスタムマッチャーを定義する
@axe-core/playwrightを用いることで、以下のようにa11yテストを記述できます。
// src/tests/a11y-check.spec.ts
import {test, expect} from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('a11yテストにパスすること', async ({ page }) => {
await page.goto('/');
const results = new AxeBuilder({ page })
// 適用するルールのタグを指定
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
// 特定のルールを無効化
.disableRules(['color-contrast'])
.analyze();
expect(results.length).toBe(0);
});
しかし、このようにテストごとにAxeBuilderの設定を書いてしまうと、ページごとにa11y基準がバラバラになりやすいという課題がありました。そこで、自作のカスタムマッチャーを定義して、ルールや除外設定の一元管理を行うようにしました。これにより、プロジェクト全体で品質基準を統一でき、冗長なコードの重複も防くことができます。
// src/common/fixtures/index.ts
import {
test,
expect as baseExpect,
type Page,
} from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
export {test};
export const expect = baseExpect.extend({
async toPassA11y(page: Page) {
// Axe-core を使ってアクセシビリティテストを実行
const results = new AxeBuilder({page})
// 影響範囲が広いため、導入時点では無効にしておく
.disableRules(['color-contrast'])
.analyze();
// アクセシビリティテストの結果を出力
for (const violation of results.violations) {
console.log(violation);
}
const {violations} = results;
const pass = violations.length === 0;
if (pass) {
return {
message: () =>
'Expected page to have accessibility violations, but it passed',
pass: true,
};
}
const violationMessages = violations
.map((violation) => {
const nodeMessages = violation.nodes
.map((node) => ` - ${node.html}`)
.join('\n');
return `${violation.id}: ${violation.description}\n Impact: ${violation.impact}\n${nodeMessages}`;
})
.join('\n\n');
return {
message: () =>
`Expected page to pass accessibility checks, but found violations:\n\n${violationMessages}`,
pass: false,
};
},
});
// src/tests/a11y-check.spec.ts
import {test, expect} from '../../common/fixtures/index.ts';
import AxeBuilder from '@axe-core/playwright';
test('a11yテストにパスすること', async ({page}) => {
await page.goto('/');
await expect(page).toPassA11y();
});
h1タグが存在しない、または見出し階層がスキップされているページでは、テスト実行時に次のようなエラーが出力されます。


実際のページで toPassA11y を実行した結果
PR TIMESの企業ページに対して toPassA11y を実行したところ、実際に以下のようなa11yの問題が見つかりました。
問題1: インタラクティブ要素のネスト
企業ページのタブ実装で、以下のようなエラーが検出されました。
nested-interactive: Ensure interactive controls are not nested as they are not always
announced by screen readers or can cause focus problems for assistive technologies
Impact: serious
-
原因を調査したところ、Radix UIのTabs.Trigger(button要素)の中に、独自のタブコンポーネント(li要素とa要素を含む)をネストしていたことが問題でした。
// 修正前(Bad)
// TabTitlePcの実装
function TabTitlePc({ title, count }) {
return (
{title}
);
}
この構造では、button要素の中にa要素が含まれてしまい、インタラクティブ要素がネストされた状態になっていました。これは、スクリーンリーダーで正しく読み上げられなかったり、キーボード操作で混乱を招く可能性があります。
当初は、Radix UIのTabs.Trigger内に直接コンテンツを配置することを検討しました。
// アクセシビリティは解決するが、SEO的に問題がある
プレスリリース
しかし、企業ページの各タブ(プレスリリース、ストーリー、プレスキット)はそれぞれ独立したURLを持っており、これらが検索エンジンの検索結果に表示されることは重要です。上記の実装では各タブへのリンク(タグ)が失われてしまい、検索エンジンのクローラーが各タブのURLを発見できなくなるため、プロダクトの要件を満たすことができませんでした。
そこで最終的に、Radix UIの asChild パターンを使用し、タグをタブトリガーとして機能させる方法を採用しました。
このように、asChildを利用することで、タグにrole=”tab”などのWAI-ARIA属性を付与できるようになりました。また、href属性を残すことで、検索エンジンのクローラーが各タブのURLを発見できるようにも対応しています。実際のページ遷移を防ぐため、タグのonClickでpreventDefault()を実行しています。
preventDefault()を適用することで、一般的なリンクのように「新しいタブで開く」といったユースケースには対応できなくなります。ただし、従来の実装でも同じ挙動だったことや、タブというUIの特性上、ユーザーが新しいタブで開くケースはほとんど想定されないことから、このような方針を採用しました。
問題2: ランドマークのネスト
同じく企業ページで、ランドマーク要素のネストに関するエラーも検出されました。
landmark-main-is-top-level: Ensure the main landmark is at top level
Impact: moderate
-
landmark-complementary-is-top-level: Ensure the complementary landmark or aside is at top level
Impact: moderate
-
これは、
HTML5のセマンティック要素である
修正後では、外側の
実際に企業ページに導入してみて
今回、企業ページに@axe-core/playwrightを導入したことで、上記以外にもいくつかのアクセシビリティ問題が検出されました。企業ページではこれまでStorybookがほとんど作成されておらず、コンポーネント単位でのa11yテストが十分に行われていなかったことが一因として挙げられます。
@axe-core/playwrightを導入することで、Playwrightのテスト基盤は必要になるものの、すべてのコンポーネントに対してStorybookを作成する手間を省くことができました。また、複数のコンポーネントを組み合わせた際のアクセシビリティ検証も可能になり、HTML全体を通したa11y品質の担保を効率的に実現できるようになりました。
まとめ
今回は、コンポーネント指向開発におけるa11yテストの課題と、@axe-core/playwrightを導入してHTML全体のアクセシビリティ品質を担保する方法についてご紹介しました。
PR TIMESでは、markuplint、storybook test、@axe-core/playwrightの3つのツールを使い分けることで、a11yチェックのカバレッジを段階的に強化しています。
それぞれのツールが担保できる範囲の違いを整理すると、以下のようになります。
| ツール名 | 主な担保領域 | テスト粒度 |
|---|---|---|
| markuplint | 静的なマークアップの構文チェック・属性バリデーション | 単一ファイル(静的HTML/JSX、単体) |
| storybook test (a11y) | UIコンポーネント単位のa11y自動検証 | Story単位(単一コンポーネント) |
| @axe-core/playwright | 実ページでのa11y自動検証 | ページ全体 |
これにより、実装の初期段階から最終的な画面表示まで、一貫してa11y品質を担保する体制を整えることができました。ただし、これらのツールだけですべてのa11y品質を100%保証できるわけではありません。たとえば、タブ操作のしやすさやスクリーンリーダーでの読み上げなどは、引き続き手動での確認が必要です。今後も自動化できる部分は積極的にツールで担保しつつ、人の目による確認が必要な領域にはより注力して取り組んでいきたいと思います。
We are hiring!
一緒にPR TIMESの開発を担ってくれるエンジニアはもちろん、各種ポジションで採用を行っています!

コメント