はじめに
私は個人開発サービス「アルゴ算額」のフロントエンドを Next.js で開発しており、E2E テストは Playwright で書いています。Playwright の初期設定のまま Chromium・Firefox・WebKit の 3 ブラウザで CI を回していたところ、特定のブラウザや CI でだけ落ちるテストが目立つようになり、そのたびに「WebKit では fill() で onChange が呼ばれない」「Firefox では再マウント後に label と入力欄の関連付けが再確立されない」といった理解で対処して、テストを通るようにしていました。
ただ、本当にそれが原因だったのかは確かめていなかったので、記事にするにあたって、7 つの節で扱う 8 つの事例それぞれの再現コードを書き、条件ごとに繰り返し実行して原因を確かめ直しました。調査は Claude と一緒に進めました。原因は次のように分かれました。
- 第 1 部(節 1・2): テスト側のタイミングの問題で、特定のブラウザで表に出ただけのもの
- 第 2 部(節 3): Playwright のマウス操作の再現方法と、WebKit の描画タイミングの組み合わせで起きたもの
- 第 3 部(節 4): ストリーミング SSR の一時的な要素を、ロケーターが拾ってしまったもの
- 第 4 部(節 5): React の不具合がテストに現れたもの(アプリの利用者にも影響していた)
- 第 5 部(節 6・7): フレークではなく、実行環境などの条件で結果がほぼ決まっていたもの
この記事では、症状からではなく原因の種類ごとに部を分けています。自分の症状から探したい場合は、後の早見表から該当する節へ飛んでください。
検証環境
- Next.js 15.5(App Router、同梱の React は 19.2.0-canary)/ MUI 6 / @dnd-kit(core 6・sortable 10)/ Monaco Editor / NextAuth v5 beta
-
@playwright/test1.60 - CI: GitHub Actions(ubuntu-latest)で
chromium・firefox・webkitの 3 プロジェクトを matrix 実行 - 再現の検証: 手元(macOS)と CI(GitHub Actions)で、原則として各条件 20 回ずつ(
--repeat-each=20。一部の条件は 10 回や 5 回) - Linux のブラウザでの検証: Playwright 公式の Docker イメージ(
mcr.microsoft.com/playwright:v1.60.0-noble)でplaywright run-serverを動かし、macOS のテストランナーから接続
以下のコードは、アプリ固有の部分を省いた最小の形にしています。
早見表
| 症状 | 出た環境 | 原因 | 対処 | 節 |
|---|---|---|---|---|
fill() したのに onChange が呼ばれない、または入力が消える |
CI の WebKit | ハイドレーション前に入力していた | ハイドレーション完了を待ってから操作する | 1 |
| ログイン直後のページで未ログイン扱いになる | WebKit | ログインのリダイレクト完了を待っていなかった | ログイン処理の最後に waitForURL で遷移を待つ |
2 |
dragTo() で並べ替えると、並べ替わらない、または 1 つ手前に落ちる |
WebKit | マウス移動の分割数が少ない |
dragTo() に steps: 20 を指定する |
3 |
| ときどき同じ要素が 2 つ見つかり strict mode 違反になる | 主に Chromium | ストリーミング SSR の一時的な hidden 要素 |
:visible で絞る(getByRole も非表示の要素を既定で除外する。この事例では未検証) |
4 |
編集 → プレビュー → 編集と切り替えた後、getByLabel で入力欄が見つからない |
主に Firefox | React の不具合で useId の値がサーバーとクライアントでずれる |
React 19.3 以降に上げる(Next.js 16.2 以降は修正を含む canary を同梱。動作は未検証)、または固定の id を使う | 5 |
role="alert" のメッセージを探すと別の要素に当たる |
ブラウザに依存しない | Next.js と Monaco も role="alert" を持ち、Monaco の要素は遷移後も残る |
data-testid で探す |
6 |
| Monaco Editor で全選択されない(テストは通ってしまう) | OS と userAgent の組み合わせ次第(Linux の WebKit、macOS の Firefox など) | Monaco は userAgent から、ControlOrMeta は実際の OS からキーを決めるので、両者が食い違う |
エディタの API で値を入れ、完全一致で確かめる | 7 |
| 日付の表示が 1 日ずれる(年の境目では年も変わる) | CI | サーバーのタイムゾーンが UTC | アプリで timeZone を指定する、またはテストデータを年の境目から離れた日時にする(テストだけを通す場合) |
7 |
第 1 部: テスト側のタイミングの問題が特定のブラウザで表に出たもの
1. fill() したのに onChange が呼ばれない
症状
検索欄に fill("検索文字") しても、URL の検索条件(クエリ)が更新されないことがありました。CI の WebKit でだけ、ときどき起きました。「WebKit では fill() で onChange が呼ばれない」ように見えますが、ブラウザ固有の問題ではありません。
原因
ハイドレーションは、React がクライアント側で、サーバーが描画した HTML にイベント処理と state を結び付ける処理です。サーバーで描画された入力欄に、ハイドレーションが終わる前に fill() していたことが原因でした。その時点ではまだ React のイベント処理が結び付いていないので、入力しても onChange は呼ばれません。
SSR を挟まないコンポーネントテストでは、2 つの入力欄 × 3 ブラウザ × 20 回のすべてで onChange が呼ばれました。WebKit の fill() 自体に問題はありません。
JS のチャンクの読み込みを page.route で 3 秒遅らせ、「サーバー描画は済んでいるが、ハイドレーションはまだ」という時間を作ると、その間の fill() では、3 ブラウザで 5 回ずつ試したすべての回で onChange が呼ばれませんでした。
// DOM 要素に React のプロパティが付いていればハイドレーション済みとみなす
const isHydrated = (locator: Locator) =>
locator.evaluate((el) =>
Object.keys(el).some((key) => key.startsWith("__reactProps$")),
);
// JS チャンクを遅らせて「サーバー描画済み・未ハイドレーション」の時間を作る
await page.route("**/_next/static/chunks/**", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 3_000));
await route.continue();
});
await page.goto("/search", { waitUntil: "commit" });
const input = page.getByPlaceholder("キーワード");
await input.waitFor({ state: "visible" });
console.log(await isHydrated(input)); // false
await input.fill("検索文字");
await expect.poll(() => isHydrated(input)).toBe(true);
console.log(await input.inputValue()); // "検索文字"(DOM には残っている)
await page.waitForTimeout(2_000); // 検索の debounce より長く待つ
console.log(page.url().includes("query=")); // false(`onChange` が呼ばれず、検索条件が URL に入らない)
DOM に残る値は、入力欄が非制御(uncontrolled)か制御(controlled)かで分かれました。非制御の入力欄は検索欄、制御の入力欄はフォームのページの問題文の欄で試しています。
fill() のタイミング |
入力欄 | ハイドレーション後の DOM の値 | onChange |
|---|---|---|---|
| ハイドレーション後 | 非制御 | 入力した文字 | 呼ばれる |
| ハイドレーション後 | 制御(value + state) |
入力した文字 | 呼ばれる |
| ハイドレーション前 | 非制御 | 入力した文字 | 呼ばれない |
| ハイドレーション前 | 制御(value + state) |
空(React が state の値で上書き) | 呼ばれない |
非制御の入力欄では「入力されているのに反応しない」、制御の入力欄では「入力が消える」という症状になります。
なお、React には、ハイドレーション前に変わった入力値を、ハイドレーション後に change イベントとして再生する機能(PR #33129)も入っています。ただしこの機能は experimental チャンネルのビルドでだけ有効になるフラグ(enableHydrationChangeEvent)で制御されており、React 19.2・19.3 の安定版では無効です。今回の環境(Next.js に同梱された、experimental ではない canary 版)では、上のとおり onChange は呼ばれませんでした。
なぜ CI の WebKit でだけ起きたのか
CI で、goto() が返った直後にハイドレーションが終わっているかを、各ページ 20 回ずつ測りました。表の値は、goto() 直後に未ハイドレーションだった回数です。
| ページ | Chromium | Firefox | WebKit |
|---|---|---|---|
| 検索ページ | 0/20 | 0/20 | 3/20 |
| フォームのページ | 0/20 | 0/20 | 5/20 |
未ハイドレーションだったのは CI の WebKit だけで、手元の macOS では WebKit でも 0 回でした。未ハイドレーションだった回も、ハイドレーションは goto() から 30〜200 ms ほどで完了していたので、この短い隙間に fill() が入ったときだけ失敗すると考えられます。
非制御の入力欄では、fill() を pressSequentially()(1 文字ずつのキー入力)に変えると、症状は出にくくなると考えられます(この変更での発生率は測っていません)。1 文字ずつ入力している間にハイドレーションが終わり、その後のキー入力で呼ばれた onChange が、それまでの文字を含む DOM の値を受け取るためだと推測しています。制御の入力欄では、ハイドレーション前に入力した文字は React に上書きされて消えます。どちらの場合も競合を避けているだけで、原因は残ります。
対処
ハイドレーションの完了を待ってから操作します。完了の判定方法は 2 通りあります。
- DOM 要素の
__reactProps$キーの有無を見る: アプリの変更が不要ですが、React の内部の実装に依存します。 - アプリ側でマウント後に印を付ける(例:
useEffectでdocument.body.dataset.hydrated = "true"): 内部の実装に依存しませんが、テストのためのコードがアプリに入ります。
await page.goto("/search");
await expect.poll(() => isHydrated(page.getByPlaceholder("キーワード"))).toBe(true);
await page.getByPlaceholder("キーワード").fill("検索文字");
2. ログイン直後のページで未ログイン扱いになる
症状
ログイン後のページを開くと、ときどき未ログイン扱いでログインページに飛ばされました。WebKit で目立ちました。
原因
テスト用のログイン処理が、ログインボタンをクリックしたところで終わっていました。NextAuth のリダイレクト(その応答でセッションの Cookie が設定される)が終わる前に、次の goto() を実行していたことになります。
export const signIn = async (page: Page) => {
await page.goto("/");
await page.getByRole("button", { name: "サインイン" }).click();
await page.getByLabel("Email").fill("user@example.com");
await page.getByLabel("Password").fill("password");
await page.getByRole("button", { name: "Sign in with Credentials" }).click();
// リダイレクトの完了を待たずに返る
};
CI で、この処理でログインした直後にログイン必須のページを開く操作を 20 回ずつ試しました。WebKit では 20 回中 1 回ログインページに飛ばされ、Chromium と Firefox は 0 回でした。
1 回あたりの発生率は低いものの、すべてのテストがこの処理を通るので、1 回の CI の実行では複数のテストが落ちることがあります。ブラウザの不具合ではなく、テスト側の競合です。WebKit で起きやすい理由までは確かめていません。
対処
ログイン処理の最後で、リダイレクト先への遷移を待ちます。
await page.getByRole("button", { name: "Sign in with Credentials" }).click();
await page.waitForURL("/");
第 2 部: マウス操作の再現方法と WebKit の描画タイミングが重なったもの
3. dragTo() で @dnd-kit の並べ替えが別の位置に落ちる
症状
@dnd-kit で作った並べ替えのリストを locator.dragTo(target) で操作すると、WebKit でだけ並べ替わりませんでした(steps を少し増やすと、今度は目的の 1 つ手前に落ちることがあります)。Chromium と Firefox では起きません。ドラッグ前にドラッグ元とドロップ先の座標を取得しておき、page.mouse で 20 段階に分けて動かすと WebKit でも通るので、ドラッグ中のアニメーションで座標がずれるのが原因に見えますが、原因は別にあります。
原因
Playwright 1.60 の dragTo() は、ドラッグ元でマウスを押した後に target の座標を計算し、steps を指定しなければ 1 回のマウス移動でドロップします。
失敗するかどうかを分けていたのは、座標の計算のタイミングではなく マウス移動の分割数(steps) でした。次の表は、末尾の要素を先頭へ動かす操作で試した結果で、値は並べ替えに成功した回数です。
| 操作 | Chromium | Firefox | WebKit |
|---|---|---|---|
dragTo(target)(steps 未指定 = 1) |
20/20 | 20/20 | 0/20(並べ替わらない) |
dragTo(target, { steps: 2 }) |
10/10 | 10/10 | 3/10(残りは 1 つ手前) |
dragTo(target, { steps: 3 }) |
10/10 | 10/10 | 6/10(残りは 1 つ手前) |
dragTo(target, { steps: 5 }) |
10/10 | 10/10 | 10/10 |
dragTo(target, { steps: 20 }) |
20/20 | 20/20 | 20/20 |
座標を固定して page.mouse(steps: 20) |
20/20 | 20/20 | 20/20 |
表の最後の行が、座標を事前に取得して page.mouse で動かす方法です。steps: 20 では、座標を事前に固定してもしなくても結果は変わりません。steps を指定しない dragTo() は WebKit で毎回失敗するので、フレークではありません。
WebKit でだけ失敗する仕組み
アプリと同じ設定の最小構成の並べ替えリストで、@dnd-kit のイベントと描画フレーム(requestAnimationFrame、以下 raf)を記録しました。
- Chromium と Firefox では、
pointermoveのたびに描画フレームが進みます。Playwright で操作した WebKit では、マウスを動かすたびには進みません。steps: 20でも、マウスを押してから離すまでのフレーム数は WebKit で 3〜7 回、他の 2 ブラウザでは 22〜27 回でした。 - 最後の移動での
onDragOverは、どのブラウザでも目的の要素をover(@dnd-kit がその時点で判定したドロップ先)として受け取っています。ところが WebKit で移動の合間に描画フレームが進まなかった場合、onDragEndは 1 つ前の移動の時点のoverを受け取ります。
steps: 2 で失敗した回の WebKit のイベント列は次のとおりです。
pointerdown → dragStart(C) → raf ×3
→ pointermove → dragOver(over=B)
→ pointermove → dragOver(over=A)
→ pointerup → dragEnd(over=B) ← A ではなく、1 つ前の B
Chromium では各 pointermove の後に描画フレームが入り、dragEnd(over=A) になります。
失敗した回の落ち方は、これで説明できます。
-
steps: 1: 1 つ前のoverはドラッグ元自身なので、並べ替わらない -
steps: 2〜3: 1 つ前のoverは 1 つ手前の要素なので、1 つ手前に落ちる -
steps: 5以上: 最後の数回の移動はすべて目的の要素の上なので、1 つ前のoverでも正しく落ちる
steps: 2〜3 で成功した回があるのは、移動の合間にたまたま描画フレームが入った回だと推測しています。@dnd-kit の内部で over が描画を経て更新される箇所と、Playwright が WebKit で描画フレームを待たずにマウスイベントを送る理由は、確認していません。
対処
steps を指定するだけで済みます。
await source.dragTo(target, { steps: 20 });
第 3 部: ストリーミング SSR の一時的な要素をロケーターが拾ったもの
4. ときどき同じ要素が 2 つ見つかり strict mode 違反になる
症状
一覧ページのメニューボタンを getByTestId("MoreVertIcon")(MUI のアイコンに自動で付く data-testid)で探すと、ときどき 2 要素に当たって strict mode 違反で落ちました。ログイン直後のクライアント遷移で前のページの DOM が残っているように見えますが、そうではありません。
原因
同じページのストリーミング SSR の一時的な要素 が原因でした。
ストリーミング SSR では、サーバーは HTML を一度に返さず、準備できた部分から順に送ります。React は、<Suspense> の中身を後から <div hidden id="S:1"> に入れてストリーミングで送り、インラインスクリプトで本来の位置に差し替えます。2 要素に当たった瞬間の DOM を記録すると、1 つ目は表示中のツリー、2 つ目は <div hidden id="S:1"> の中でした。
DOM の変化(MutationObserver)と、React のストリーミング用スクリプト($RC と $RV)の呼び出しを、ページの読み込み開始から記録すると、次の流れが分かりました。
-
$RC("B:1", "S:1")(B:1は<Suspense>の fallback が置かれている位置の印)が呼ばれても、React はその場では差し替えません。差し替えとS:*の削除は、後から$RVでまとめて行われ、$RCからの待ち時間は 0〜約 300 ms でした。 - 重複した回はすべて、この待ち時間の間にハイドレーションが進み、クライアントが一覧を表示中のツリーに描画していました。
- その後
$RVがS:1を取り除き、要素は 1 つに戻ります。
Chromium での一例です。
61ms S:1 を追加、$RC(B:1, S:1)
248ms 表示中のツリーに一覧が描画され、ハイドレーション(ここでメニューボタンが 2 個)
353ms $RV で S:1 などを削除(1 個に戻る)
手元で 20 回ずつ試すと、重複が起きたのは Chromium 13 回、Firefox 1 回、WebKit 0 回でした。重複は長くても 100 ms 程度ですが、その間にロケーターが評価されると strict mode 違反になります。ログインの待ち方とは関係がなく、ログイン完了を待った場合でも起きました。
$RV がどういう条件で差し替えを待たせるのかまでは確認していません。
対処
:visible で絞ると、非表示の S:1 の中の要素を除外できます。当たる要素は 1 つになるので、.first() は要りません(.first() だけでは strict mode 違反が出なくなるだけで、どちらの要素を取るかは DOM の順序で決まります)。
getByRole も既定で非表示の要素(hidden 属性の付いた要素の中を含む)を除外します。一方、getByTestId や CSS セレクターは非表示の要素も拾います。メニューボタンにアクセシブルな名前を付け、role と名前で探せば、この重複には当たらないと考えられます(getByRole での再現は試していません)。
// 非表示の S:1 の中の要素も拾う
page.getByTestId("MoreVertIcon");
// 表示中の要素だけに絞る(:visible と同じ)
page.getByTestId("MoreVertIcon").filter({ visible: true });
// 非表示の要素は除外される(ボタンに aria-label="算額のメニューを開く" を付けた場合。未検証)
page.getByRole("button", { name: "算額のメニューを開く" });
第 4 部: React の不具合がテストに現れたもの
5. 切り替え後に getByLabel が入力欄を見つけられない
症状
Markdown エディタを「編集 → プレビュー → 編集」と切り替えた後、getByLabel("問題文") で入力欄が見つからなくなりました。主に Firefox で起きました。エディタは切り替えのたびに textarea を作り直します(再マウント)。Firefox の再マウントの問題に見えますが、原因は React の不具合です。getByPlaceholder で探せばテストは通りますが、不具合が見えなくなるだけです。
エディタは、useId で作った id を label の htmlFor と textarea の id に渡す、よくある作りです。
const inputId = useId();
return (
<>
<label htmlFor={inputId}>{label}</label>
{mode === "edit" ? <textarea id={inputId} /> : <Preview />}
</>
);
何が起きていたのか
ハイドレーション時に、useId の値がサーバーとクライアントでずれていました。本番ビルドの React は、ハイドレーション時に属性の値がずれていても DOM を直しません。そのため label にはサーバーの id が残ります。最初の textarea もサーバーの id を持っているので、切り替える前は関連付けが保たれています。切り替えで textarea が作り直されると、クライアントの id が付きます。ここで label との関連付けが切れます。
- SSR を挟まないコンポーネントテストでは、3 ブラウザ × 20 回で 1 度も再現しませんでした。
- 実際のページでは、計測処理を入れずに 20 回ずつ複数セット試し、Firefox で各セット 6〜11 回、Chromium でも 20 回中 1 回起きました(WebKit は 0 回)。Firefox 固有ではなく、Firefox で起きやすい現象です。
- 失敗した回はすべて、切り替える前の時点で、label の DOM 属性の
for(例:_R_6kqklubsnql9frb_)と、React が持つ props のhtmlFor(例:_R_1l6l5fiv5ul9frb_)が食い違っていました。 - コンソールにはハイドレーションエラー(React #418)も含めて何も出ません。
これはテストだけの問題ではありません。編集とプレビューを切り替えた後は、実際の利用者にとっても label と入力欄の関連付けが切れています。スクリーンリーダーで label が読み上げられず、label をクリックしても入力欄にフォーカスが移りません。getByLabel の失敗は、アプリのアクセシビリティの不具合を示していました。
なぜ useId の値がずれるのか
useId の id は、ルートからその要素までの各階層のうち、兄弟が複数ある階層で「何番目の子か」を積み重ねた数値を、32 進数で表した文字列です。上の例の 2 つの id(for がサーバー、htmlFor がクライアントの値)を 2 進数に直して比べると、クライアントの id はサーバーの id から 1 階層分(2 ビット)が抜けた値でした(別の回には、もっとルート側から違うパターンもありました)。React がハイドレーション中に、どこかの階層を 1 つ数え損ねています。
どの階層が抜けているのかを確かめるため、Next.js に同梱されている本番用の react-dom に一時的に記録処理を仕込み、useId を呼んだ時点で id に寄与した階層の一覧と、コンポーネントの中断とやり直しのイベントを記録しました。
その結果、抜けていたのはレイアウト内の「2 番目の子」の階層で、位置からすると <Box> の 2 番目の子 <Grid> でした。
// app/(main)/layout.tsx(Server Component)
<Box>
<Nav />
<Grid>{/* ← 数えられていなかったのは、この位置の階層 */}
...
</Grid>
</Box>
仕組みは次のとおりです。
- Server Component から渡された Client Component は、そのチャンクがまだ読み込まれていなければ、遅延読み込みの要素(lazy)として扱われます。
- ハイドレーション中に
<Grid>の lazy がチャンクの読み込み待ちで中断(サスペンド)し、その直後に読み込みが終わると、React はその場で処理をやり直します。 - やり直しの前に、React はその Fiber(React が内部で持つコンポーネントの作業単位)のフラグをリセットします。このとき「兄弟が複数ある子」を表す
Forkedフラグまで消えます。 - やり直しの処理は、
Forkedフラグを見て id の階層を積むかどうかを決めるので、フラグが消えたこの階層は積まれません。その結果、配下のuseIdがすべて 1 階層分ずれます。
記録処理を仕込んで Firefox で実行した 20 回のうち、ずれた 10 回すべてで、この lazy のやり直しが起きていました。やり直しが起きなかった回では、ずれは 0 回でした。一方、やり直しが起きたのにずれなかった回も 2 回あり、その理由までは追っていません。
やり直しになるかどうかはチャンクの読み込みが終わるタイミングで決まるので、ブラウザによって頻度に差が出たと考えられます。Firefox で特に多い理由までは確かめていません。
確認のため、やり直しの際に Forked フラグを残すように react-dom を書き換えて 20 回試しました。やり直しは 13 回起きましたが、ずれは 0 回 になりました。
この不具合は React 本体で PR #35518「[Fiber] fix useId tracking on replay」(2026-01-16 マージ)として修正済みです。やり直しの際のリセットでも残すフラグの集合(StaticMask)に、Forked が加えられています。
- 修正が入ったのは React 19.3.0 からです。19.2.x(19.2.8 まで)と、マージより前の 19.3.0-canary には入っていません。
- Next.js 15.5 系は、2026 年 10 月時点で最新の 15.5.27 でも同梱の React が 19.2.0-canary のままで、未修正です。
- Next.js 16.1.0 は同梱の React が 2025 年 12 月の 19.3.0-canary で、未修正です。
- Next.js 16.2.0 は同梱の React が 2026 年 3 月の 19.3.0-canary で、修正済みです(16.4.0 も修正済み)。
Next.js の各バージョンは、同梱の react-dom のフラグの値で確認したもので、実際に動かしてはいません。16.1.x のパッチ版は確認していません。
対処
- React 19.3 以降に上げる。Next.js の App Router では、Next.js を 16.2 以降(同梱の React が修正を含む)に上げる(動作は未検証)
- 上げられない場合は、次のどれかで label と入力欄の関連付けを保つ
- この要素では
useIdを使わず、呼び出し側から固定の id を渡す -
<label>で textarea を包み、htmlForを使わずに関連付ける - 切り替えで textarea を作り直さない(非表示にするだけにする)
- この要素では
第 5 部: フレークではなく、条件で結果が決まっていたもの
この部の 2 つの節(節 6・7)で扱う問題は、ランダムに落ちるフレークではありません。実行環境や、テストの中で通ったページといった条件で、結果がほぼ決まります(20 回中 0 回か 20 回のどちらかになる組み合わせがほとんどで、例外は節 7 で触れます)。条件が CI と手元で違ったり、テストごとに違ったりするので、フレークに見えます。ただし節 7 の Monaco の事例は、テストが通ってしまうので落ちもしません。
6. role="alert" で探すとフレームワークの要素に当たる
症状
操作の結果を画面に一時的に表示するフラッシュメッセージを role="alert" で探すと、フラッシュメッセージ以外の要素にも当たりました。
原因
当たっていたのは、Next.js の route announcer(クライアント遷移をスクリーンリーダーに知らせる要素)と、Monaco Editor の要素です。
ページごとに getByRole("alert") に当たる要素を記録すると、次のようになりました。3 ブラウザで 20 回ずつ実行し、60 回すべてで同じ結果でした。
| ページ | 当たった要素 | 数 |
|---|---|---|
| トップ | route announcer(aria-live="assertive") |
1 |
| Monaco のあるページ | 上記 + Monaco の .monaco-alert(aria-atomic="true")2 つ |
3 |
| Monaco のあるページから遷移した先 | 上記 + フラッシュメッセージ | 4 |
route announcer は <next-route-announcer> の shadow DOM の中にありますが、Playwright のロケーターは open モードの shadow DOM の中も探すので当たります。Monaco の 2 つの要素は、Monaco のないページへクライアント遷移した後も残っていました。そのため、同じページでも、そのテストの中で先に Monaco のあるページを通ったかどうかで、当たる要素の数が変わります。
当たる要素が複数あると、toBeVisible() などの 1 要素を前提とする検証は strict mode 違反になります。
対処
フラッシュメッセージに data-testid="flash-message" を付けて探します。
属性で除外する方法もありますが、おすすめしません。
-
:not([aria-live]): route announcer は除外できますが、Monaco の要素は残ります(上の表の遷移先では 3 要素に当たる)。 -
:not([aria-live]):not([aria-atomic]): 上の表の条件ではフラッシュメッセージだけになります。ただし Next.js や Monaco が付ける属性に依存するので、ライブラリの更新で属性が変わると機能しなくなります。
Playwright は role で探すことを推奨していますが、この事例では role 自体が重複しているので、getByRole では区別できません。
7. 実行環境で Monaco の全選択と日付の表示が変わる
Monaco Editor で全選択されない
症状
Monaco Editor の中身を書き換えるテストで、keyboard.press("ControlOrMeta+a") で全選択してから入力すると、環境によっては全選択されず、元のコードが残りました。書き換え後の内容を toContainText の部分一致で確かめていると、入力した文字列は含まれているので、それでもテストは通ってしまいます。テストが通るのでフレークにも見えず、気づきにくい問題です。
原因
Monaco は、ブラウザが動いている OS ではなく、userAgent の文字列から OS を判定して キーの割り当てを決めていました(Macintosh を含めば Mac、Windows を含めば Windows。monaco-editor 0.52.2 の esm/vs/base/common/platform.js)。Playwright のデバイス設定の userAgent は次のとおりです。
| デバイス設定 | userAgent が名乗る OS | Monaco の全選択のキー |
|---|---|---|
Desktop Chrome |
Windows | Ctrl+A |
Desktop Firefox |
Windows | Ctrl+A |
Desktop Safari |
Mac | Cmd+A |
一方、ControlOrMeta は、ブラウザが動いている OS で読み替えられます(Linux では Control、macOS では Meta)。この 2 つが食い違う組み合わせで、全選択されません。エディタの中身を書き換える操作を、各条件 20 回ずつ試しました。表の値は、元のコードが残った(全選択されなかった)回数です。Linux の行は、Docker のブラウザで測った CI 相当の結果です。
| 環境・キー | Chromium | Firefox | WebKit |
|---|---|---|---|
Linux(CI 相当)・Meta+KeyA
|
20/20 | 20/20 | 0/20 |
Linux(CI 相当)・ControlOrMeta+a
|
0/20 | 0/20 | 20/20 |
macOS・Meta+KeyA
|
6/20 | 20/20 | 0/20 |
macOS・ControlOrMeta+a
|
2/20 | 20/20 | 0/20 |
macOS では ControlOrMeta が Meta になるので、macOS の 2 行は同じキー操作です。太字は、ControlOrMeta+a で全選択されない組み合わせです。
Linux の WebKit では、Monaco が Mac の割り当てを使うので、Ctrl+A(Playwright では Control+a)でカーソルが行頭へ移動するだけでした。macOS の Firefox では、userAgent が Windows を名乗るので Monaco は Ctrl+A を待ちますが、ControlOrMeta は macOS では Meta になるので、全選択になりませんでした。
macOS の Chromium は、userAgent では Windows を名乗るので食い違う組み合わせのはずですが、多くの回で全選択され、全選択されない回数も 6 回と 2 回で揺れました。全選択の計測で結果が揺れたのはこの組み合わせだけで、理由は確認していません。
読み込み前にキーを押しているのが原因ではないかを確かめるため、Monaco の読み込み完了(エディタの .view-lines の表示)を待つ場合と待たない場合を比べましたが、全選択されない回数は変わりませんでした(macOS の 3 ブラウザ・ControlOrMeta+a で、待たない場合も待つ場合も 60 回中 22 回)。失敗した回はすべて、入力した文字は残っており、全選択だけが行われていませんでした。
対処
キー操作で全選択するのをやめ、エディタの API で値を入れます。@monaco-editor/react の既定のように CDN のローダーで Monaco を読み込んでいれば、window.monaco から API を使えます。
type MonacoWindow = {
monaco?: { editor: { getEditors(): { setValue(v: string): void }[] } };
};
// Monaco の読み込みが終わり、エディタが作られて描画されるまで待つ
await page.waitForFunction(
() => ((window as unknown as MonacoWindow).monaco?.editor.getEditors().length ?? 0) > 0,
);
await page.locator(".monaco-editor .view-lines").first().waitFor();
await page.evaluate((value) => {
const { monaco } = window as unknown as Required<MonacoWindow>;
monaco.editor.getEditors()[0].setValue(value);
}, "puts 42");
検証では、.view-lines の表示を待ち、ほかの入力欄を埋めた後に setValue しました(waitForFunction の待ちは入れていません)。macOS と Linux の 3 ブラウザで 20 回ずつ実行し、120 回すべてで、エディタの表示とフォームの確認画面の表示が入力した値と一致しました。確認画面に同じ値が出ているので、フォームの state にも反映されています。
@monaco-editor/react は、エディタを作った後で onChange を結び付けるので、エディタが getEditors() に載った直後に setValue すると、フォームに反映されない可能性があります(この競合は確認していません)。@monaco-editor/react はエディタの準備ができるまでコンテナを非表示にしているので、.view-lines の表示を待てば、onChange の結び付けもほぼ済んでいると考えられます。ただし厳密な保証ではないので、反映されたかどうかは確認画面などの値で確かめてください。また、エディタが複数あるページでは、getEditors()[0] が目的のエディタとは限りません。
この方法では、キーボード操作そのものは確かめなくなります。利用者と同じ操作で確かめたい場合は、userAgent に合わせてキーを選ぶ方法もあります。こちらも同じ条件で、確認画面の値は 120 回すべて一致しました(Linux の WebKit で 1 回だけ、入力直後のエディタの表示が入力途中の状態でした)。
const isMacUa = await page.evaluate(() => navigator.userAgent.includes("Macintosh"));
await page.keyboard.press(isMacUa ? "Meta+a" : "Control+a");
ただし、この方法は Monaco が userAgent で OS を判定するという内部の挙動に依存するので、Monaco の判定方法が変わると動かなくなります。
どちらの方法でも、書き換え後の内容は toHaveText などで完全に一致するか確かめます。toContainText のままでは、全選択されなくなっても気づけません。
日付の表示が 1 日ずれる
症状
プロフィールページの登録日に「2026」が含まれることを確かめるテストが、CI でだけ落ちました。
原因
登録日は Server Component で toLocaleDateString("ja-JP") を使って表示しており、日付はサーバー(Node.js)のタイムゾーンで決まります。CI の実行環境のタイムゾーンは UTC です。テストデータの 2026-01-01T00:00+09:00 は UTC では 2025-12-31T15:00 なので、CI では 2025 年と表示されて落ちました。
サーバーを TZ=UTC で起動すると、3 ブラウザの 60 回すべてで「2025/12/31」と表示されて落ちました。TZ=Asia/Tokyo では、60 回すべてで「2026/1/1」でした。
対処
表示を利用者の日付に合わせるなら、アプリ側で toLocaleDateString("ja-JP", { timeZone: "Asia/Tokyo" }) のようにタイムゾーンを指定します。サーバーのタイムゾーンに依存しない表示になるので、テストも環境に左右されなくなります。
テスト側だけで済ませるなら、テストデータを 2026-06-01T00:00Z のように年の境目から離れた日時にすると、どのタイムゾーンでも年が変わりません。変更後のデータでは、TZ=UTC でも 60 回すべて通りました。なお、Playwright の timezoneId はブラウザのタイムゾーンを変えるだけなので、サーバーで描画するこの表示には影響しません。
まとめ
再現して分かったことから、教訓を 4 つ挙げます。
- 「このブラウザでは〜できない」と決めつけない。節 1 と節 5 は特定のブラウザで目立っただけで、どちらもブラウザ固有の問題ではありませんでした。
- 通った方法に含まれる違いのうち、どれが効いているのかを確かめる。節 3 では、座標を事前に取得して
page.mouseで 20 段階に分けて動かす方法で通った理由は、座標の事前取得ではなくsteps: 20でした。 - テストの書き方で回避する前に、アプリの不具合でないかを確かめる。節 5 では、
getByLabelが見つからないこと自体が不具合を示していました。 - テストが通っていることを、正しく動いている証拠にしない。節 7 では、
toContainTextの部分一致で確かめていたため、全選択されていなくてもテストが通っていました。