開発者がブラウザ画面上の「LAYOUT OVERFLOW」警告を指し、overflow-x: clipが原因でscrollWidth比較テストが横スクロールを検知できない問題を図解しています。E2Eテストでのレイアウト崩れ検知の難しさを示唆しています。

はじめに

「横スクロールが出ていないこと」を確認するE2Eテストを書いたことがある方は多いと思います。多くの実装は document.documentElement.scrollWidthbody.scrollWidthclientWidth と比較するだけのシンプルなチェックです。

ところが、ルート要素に overflow-x: clip(または hidden)が指定されていると、このテストは常にパスし続けてしまいます。原因は単純で、overflow-x: clip/hidden が指定された要素は、中身がどれだけあふれていても scrollWidth 自体が clientWidth 相当までしか報告されないためです。「はみ出しを視覚的に隠すためのCSS」と「はみ出しの有無を測るテストの計測対象」が同じ要素になっていると、テストはサイレントに無力化されます。この記事では、この落とし穴の仕組みと、判定ロジックを共通ヘルパーとして整理し直す方法を紹介します。

こんな人におすすめ

  • Playwrightなどでレイアウト崩れ・横スクロール検知のE2Eテストを書いている方
  • scrollWidth / clientWidth を使った判定ロジックに心当たりがある方
  • 同種の検証ヘルパーが複数のspecファイルに重複コピーされてしまっている方
  • 「テストはグリーンなのに本番でレイアウトが崩れる」という経験がある方

overflow-x: clip がscrollWidthを丸めてしまう仕組み

ブラウザの挙動として、overflow-xcliphidden の要素は、内部コンテンツがどれだけあふれていても scrollWidthclientWidth 相当までしか報告しません。つまり document.documentElementbody にこの指定があるページでは、中の要素がどれだけはみ出していても、ドキュメントレベルの scrollWidth は変化しないということです。

判定対象を「document/body」から、実際にレイアウトが組まれる「クリップされていないコンテンツルート」(アプリのメインシェル要素など)に変更するだけで、クリップの内側で起きているはみ出しを検出できるようになります。

判定ロジックを純粋関数として切り出す

まず、ブラウザ内で実行する「収集」と、収集結果から「はみ出しかどうか」を判定する処理を分離します。分離しておくと、後者だけをブラウザなしで軽量にユニットテストできます。

export interface ContentOverflowSample {
  readonly scrollWidth: number
  readonly clientWidth: number
}

export function collectContentOverflowInPage(rootSelector: string): ContentOverflowSample {
  const root: Element = document.querySelector(rootSelector) ?? document.body
  return { scrollWidth: root.scrollWidth, clientWidth: root.clientWidth }
}

export function hasContentOverflow(overflow: ContentOverflowSample): boolean {
  return overflow.scrollWidth > overflow.clientWidth + OVERFLOW_TOLERANCE_PX
}

hasContentOverflow を純粋関数にしておくと、許容誤差1pxのようなエッジケースも含めて単体テストで細かく検証できます。

共通ヘルパーに統合し、ルート要素を可変にする

同じ横スクロール検出ロジックが複数のspecファイルにコピペされていると、検出漏れのようなバグに気づいたあとも、全箇所を横断的に直す必要が出てきます。検証ロジックを1つの共通ヘルパーモジュールに集約し、既定のセレクタを持ちつつ呼び出し側で上書きできるようにしておくと、この手の修正が1箇所で完結します。

export async function expectNoHorizontalOverflow(
  page: Page,
  rootSelector: string = LAYOUT_REGRESSION_ROOT_SELECTOR, // 既定: アプリのメインシェル要素
): Promise<void> {
  const overflow = await page.evaluate(collectContentOverflowInPage, rootSelector)
  expect(
    hasContentOverflow(overflow),
    `${rootSelector} overflows horizontally at ${page.url()}: scrollWidth=${overflow.scrollWidth} > clientWidth=${overflow.clientWidth}`,
  ).toBe(false)
}

画面によってコンテンツルートが異なる場合は、セレクタを差し替えるだけで対応できます。認証必須の主要画面と、規約ページのような別レイアウトの静的画面とでは、コンテンツのルート要素が違うことがあるためです。

const ALT_LAYOUT_ROOT_SELECTOR = '.alt-shell'
// ...
await expectNoHorizontalOverflow(page, ALT_LAYOUT_ROOT_SELECTOR)

検出できることを証明する回帰テストを書く

「はみ出しがない」ことを確認するテストだけでは、検出ロジック自体が劣化して再びクリップ系の要素を見てしまっても気づけません。意図的に横幅の大きい要素をDOMへ注入し、ヘルパーがそれを検出して失敗することまで確認する回帰テストを合わせて用意しておくと安心です。

test('横はみ出しガードは意図的なはみ出しを実際に検出する', async ({ page }) => {
  await expectNoHorizontalOverflow(page) // まず正常系

  await page.evaluate(() => {
    const probe = document.createElement('div')
    probe.style.width = '600px'
    probe.style.flexShrink = '0'
    document.querySelector('.app-shell')?.appendChild(probe)
  })

  await expect(expectNoHorizontalOverflow(page)).rejects.toThrow() // 検出できることを確認

  await page.evaluate(() => document.getElementById('e2e-overflow-probe')?.remove())
  await expectNoHorizontalOverflow(page) // 後片付け後は正常に戻る
})

つまづきやすいポイント

  • ルート要素を厳しく取りすぎると、通知パネルやチャットランチャーのような装飾目的で意図的に自身の箱の外へブリードして描画されるfixed要素まで拾ってしまい、誤検知(false positive)になります。該当UIのルート要素だけをピンポイントで見る設計にするのが安全です。
  • scrollWidthclientWidth の差分をそのまま0で比較すると、サブピクセルの丸め誤差で不安定になりがちです。許容誤差(数px程度)を持たせておくと運用が楽になります。
  • 判定対象のセレクタを既定値だけに固定してしまうと、レイアウトが異なる画面が増えたときに検知漏れが再発します。呼び出し側で上書きできる設計を最初から用意しておくのがおすすめです。

まとめ

横スクロール検知のE2Eテストは、document.documentElement.scrollWidth を見ているだけでは overflow-x: clip/hidden の内側のはみ出しを取りこぼします。判定対象を実際のコンテンツルートに変更し、「収集」と「しきい値判定」を分離した純粋関数として整理すると、単体テストも書きやすくなります。同種のロジックが複数ファイルに散らばっている場合は、共通ヘルパーへの統合を検討する価値があります。何より、「本当に検出できるか」を実証する回帰テストを合わせて用意しておくことが、この種の検出漏れの再発を防ぐ一番の近道だと感じています。