フロントエンドの自動テスト(E2Eテストやコンポーネントテスト)を運用していて、こんな悩みを抱えたことはありませんか?
「CSSのクラス名を少し変更しただけでテストが真っ赤になって落ちた」「リファクタリングでDOMの階層構造を変えたらセレクタが見つからなくなった……」。
デザイン変更やリファクタリングのたびにテストが壊れる現象(いわゆるテストの脆さ / Flakyテスト)の多くは、「実装の詳細(クラス名やDOM構造)に依存したセレクタ指定」が原因です。
この問題を根本から解決するアプローチが、「セマンティックなHTML設計」と「アクセシビリティツリー(Role / Accessible Name)を活用したテストコード」の組み合わせです。
本記事では、なぜセマンティクスを意識するとテストが壊れにくくなるのかという理論から、Testing LibraryやPlaywrightを用いた具体的なサンプルコード、リファクタリングの手順まで、3,000字オーバーで徹底解説します!
1. なぜテストが頻繁に壊れるのか?「脆いテスト」の正体
テストが壊れやすい最大の要因は、テストコードが「ユーザー視点の振る舞い」ではなく「実装の詳細」を検証してしまっている点にあります。
アンチパターン①:CSSクラス名への過度な依存
見た目を整えるためのクラス名(例:.btn-primary、.user-list__item--active、Tailwind CSSのユーティリティクラスなど)は、デザインリニューアルやリファクタリングで日常的に変更されます。これらをテストのクエリとして指定していると、機能自体は壊れていないのにテストだけが失敗し、保守コストが跳ね上がります。
アンチパターン②:DOM階層(XPathや親子結合子)の指定
div > div:nth-child(2) > span や //*[@id="root"]/div[1]/form/button のようなセレクタは最悪のアンチパターンです。アクセシビリティ改善やレイアウト調整のために <div> を1枚追加しただけで、セレクタのパスが完全に狂ってテストが全滅します。
2. 解決策:セマンティックHTML + アクセシビリティクエリ
「壊れにくいテスト」を実現するための王道は、W3C標準のセマンティックHTMLを適切にマークアップし、テストランナーから「ユーザーが画面を認識するのと同じ方法(ロールやテキスト)」で要素を取得することです。
| 要素の指定方法 | 変更への耐性 | 得られる副次効果 |
|---|---|---|
CSSクラス名(.btn-submit) |
低い(デザイン変更で即死) | なし |
テスト専用属性(data-testid="submit") |
普通(壊れにくいがHTMLが汚れる) | アクセシビリティ向上には寄与しない |
| セマンティック(Role+Name) | 極めて高い(意図が変わらない限り壊れない) | アクセシビリティ・SEOが同時に向上する |
セマンティックなマークアップを行うと、ブラウザ内部で「アクセシビリティツリー(Accessibility Tree)」が正確に構築されます。Testing Libraryの getByRole や Playwrightの getByRole はこのツリーを参照するため、見た目のCSSやDOM構造が変わっても、機能的な意味が変わらない限りテストが落ちません。
3. 【実践比較】壊れやすいコード vs 壊れにくいコード
具体的なログインフォームのマークアップとテストコードを例に、ビフォー・アフターを比較してみましょう。
【Before】セマンティクスを無視した壊れやすい実装
すべてが <div> や <span> で組まれており、クラス名頼りになっている悪い例です。
<!-- 悪いHTML例:意味を持たないタグ構成 -->
<div class="login-container">
<div class="form-title">ログイン</div>
<div class="input-group">
<span class="label">メールアドレス</span>
<input type="text" class="email-input" value="" />
</div>
<div class="input-group">
<span class="label">パスワード</span>
<input type="password" class="password-input" value="" />
</div>
<div class="btn-submit" onclick="handleSubmit()">送信する</div>
</div>
このHTMLに対するテストコードは、どうしてもクラス名や構造に依存せざるを得ません。
// 悪いテスト例(Playwright):クラス名に依存
test('ログイン処理が実行できること(脆いテスト)', async ({ page }) => {
await page.goto('/login');
// クラス名が変わったり、divからbuttonに変わると壊れる
await page.locator('.email-input').fill('user@example.com');
await page.locator('.password-input').fill('password123');
await page.locator('.btn-submit').click();
});
【After】セマンティックHTMLを活用した壊れにくい実装
適切なHTMLタグ(<form>, <h1>, <label>, <button>)を用い、ラベルと入力を htmlFor(for)で正しく結びつけた良い例です。
<!-- 良いHTML例:セマンティックかつアクセシブルな構成 -->
<main>
<h1>ログイン</h1>
<form aria-label="ログインフォーム">
<div>
<label for="user-email">メールアドレス</label>
<input id="user-email" type="email" required />
</div>
<div>
<label for="user-password">パスワード</label>
<input id="user-password" type="password" required />
</div>
<button type="submit">送信する</button>
</form>
</main>
このHTMLに対しては、Testing LibraryやPlaywrightのアクセシビリティクエリをフル活用できます。
// 良いテスト例(Playwright):ユーザー視点のRoleとAccessible Nameで取得
import { test, expect } from '@playwright/test';
test('ログイン処理が実行できること(壊れにくいテスト)', async ({ page }) => {
await page.goto('/login');
// ラベルテキストと連動したinputを取得
await page.getByLabel('メールアドレス').fill('user@example.com');
await page.getByLabel('パスワード').fill('password123');
// 意味論的な「ボタン(Role: button)」かつ「送信する」というテキストを持つ要素を取得
await page.getByRole('button', { name: '送信する' }).click();
// 見出しが存在することを確認
await expect(page.getByRole('heading', { name: 'マイページ', level: 1 })).toBeVisible();
});
// 良いテスト例(React Testing Library)
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
test('入力と送信が正しく動作すること', async () => {
const user = userEvent.setup();
render(<LoginForm />);
// 実装の詳細(クラス名・タグの種類)を意識せず、アクセシブルな名前で取得
await user.type(screen.getByLabelText('メールアドレス'), 'user@example.com');
await user.type(screen.getByLabelText('パスワード'), 'password123');
await user.click(screen.getByRole('button', { name: '送信する' }));
});
4. セマンティクスを活かしたテスト設計の4大鉄則
テストの保守性を飛躍的に高めるために押さえるべきポイントを4つにまとめました。
① getByRole を最優先で使う
Testing Library公式の優先順位ガイドラインでも明記されている通り、第一選択肢は常に getByRole です。button, heading, link, checkbox, dialog などのRoleを指定することで、画面の視覚的表現に惑わされない強固なセレクタになります。
② ラベルのないアイコンボタンには aria-label を付与する
閉じるボタン(×アイコン)や検索虫眼鏡アイコンなど、テキストが存在しないボタンには aria-label="閉じる" や aria-label="検索" を付与します。これにより、スクリーンリーダー利用者にも親切になり、テストコードでも page.getByRole('button', { name: '閉じる' }) で確実に捕捉できるようになります。
③ 見出しレベル(h1〜h6)を論理的に構造化する
見出しを視覚的な文字サイズだけで決めず、文書構造として正しく配置します。テストでは page.getByRole('heading', { level: 2, name: '商品一覧' }) のように、階層レベルを指定して厳密な検証が可能です。
④ data-testid は最後の手段(最終防衛ライン)にする
動的なキャンバス描画や、どうしてもセマンティックなRoleを割り当てられない複雑なSVGグラフなどを除き、安易に data-testid を乱用しないようにします。data-testid に頼りすぎると、HTMLのセマンティクス崩れ(アクセシビリティの低下)に気づけなくなるためです。
5. メリット:テストの堅牢化がアクセシビリティ(a11y)を自動改善する
セマンティックHTMLを意識したテスト駆動開発には、**「テストが壊れにくくなる」以上の巨大な相乗効果**があります。
- テストが書けない=マークアップが不適切なサイン:「
getByRoleでボタンが取れない」と気づいた時点で、<div onClick>のような不適切なマークアップを撲滅できる。 - アクセシビリティ対応が自然と完了する:キーボード操作やスクリーンリーダー対応に必要な属性(
aria-*やlabel紐付け)がテストコードを書く過程で網羅される。 - デザイン変更に強いリファクタリング耐性:Tailwind CSSへの移行やデザインシステムの刷新を行っても、UIの機能が変わらなければテストは1行も修正せずに通過する。
6. まとめ
テストコードの壊れやすさに悩まされたら、セレクタを工夫する前に**「HTMLのセマンティクスが正しく設計されているか」**を見直すのが根本解決への最短ルートです。
- 見た目(CSS)ではなく「機能と意味(Role / Accessible Name)」をテストする。
- 適切なHTML要素(
<button>、<form>、<label>、<main>など)を正しく使う。 - PlaywrightやTesting Libraryの
getByRole/getByLabelを第一選択にする。
正しいセマンティックHTMLは、障害のない強固なテスト基盤を作り、同時にすべてのユーザーに優しいWebアクセシビリティを実現します。日々のコーディングとテスト設計にぜひ取り入れてみてください!
OFFICETAKECでは、堅牢なフロントエンド設計からWebサイトの保守・移行・テスト自動化の導入支援まで幅広く対応しております。Web開発や品質改善でお困りの際はお気軽にお問い合わせください。
👉 Webサイト制作・開発のご相談はこちら(お問い合わせフォームへ)


