PlaywrightをVPSで毎日cron運用して学んだこと。壊れないセレクタと待機処理の実践
Playwright+ChromeをVPSで毎日cron運用して得た実践知。CSSクラス依存セレクタが壊れた話、waitForSelectorとnetworkidleの使い分け、リトライとログ設計、メモリ制約下のChrome運用をE2Eテストに落とし込む。
自分はPlaywright + Chromeを、VPS上で毎日cronで動かしている。用途はE2EテストのCIではなくて、いくつかのWebサイトの新着情報を自分用に自動収集するスクレイパーだ。毎日決まった時刻に一覧を巡回して、条件に合う項目の詳細ページを開いて、SQLiteに保存する。この運用を続けていると、ブラウザ自動化の「壊れどころ」が嫌でも体に入ってくる。
E2Eテストとスクレイパーは目的こそ違うが、やっていることはほぼ同じだ。ページを開き、要素を待ち、値を取り、失敗したら原因を追う。だからこの記事は「Playwrightの機能一覧」ではなく、毎日運用して実際に壊れた場所と、その経験をE2Eテストを書くならどう活かすか、という話として書く。
セレクタは自分が思っているより早く壊れる
一番痛い目を見たのがここ。最初は開発者ツールで見えたCSSクラスをそのままセレクタにしていた。
// 最初に書いたやつ。数週間で壊れた
const title = await page.locator('.c-mediaItem__title').textContent();
対象サイトがフロントエンドを更新すると、この手のクラス名は普通に変わる。ビルドツールが生成するハッシュ付きクラス(css-1x2y3z みたいなもの)に至っては、デプロイごとに変わることすらある。スクレイパーが静かに空データを返し続けて、数日後にDBを見て気づく、というのを経験した。
直した後の方針はシンプルで、HTML構造やクラス名ではなく、「意味」に紐づくものを掴む。具体的にはロール・テキスト・data属性の順で検討する。
// ロール: アクセシビリティツリー基準なので見た目の変更に強い
await page.getByRole('link', { name: '詳細を見る' }).click();
// テキスト: 文言が変わらない限り生きる
const rows = page.locator('li', { hasText: '募集中' });
// 相対関係で絞る: 「この見出しを含むカードの中のリンク」
const card = page.locator('article').filter({ hasText: caseTitle });
await card.getByRole('link').first().click();
これはPlaywright公式がE2Eテスト向けに推奨している優先順位そのままなのだが、正直、公式ドキュメントを読んだ時点では「まあCSSセレクタでも動くし」と思っていた。毎日運用して初めて、この推奨が耐久性の話なのだと理解した。
E2Eテストに引き付けて言うと、テスト対象が自分のプロダクトなら、もっと強い手が使える。data-testid を自分で振ってしまうことだ。
// 実装側: <button data-testid="submit-order">注文する</button>
await page.getByTestId('submit-order').click();
スクレイパーは相手のHTMLを変えられないから意味ベースのセレクタで耐えるしかないが、E2Eテストは実装とテストを同じチームが持っている。デザイン変更でテストが壊れる事故は、testidを振る規約ひとつでほぼ消せる。自分のサイトを自動化する場面と他人のサイトを相手にする場面の、一番大きな違いがここだと思う。
waitForTimeoutで待つのをやめて、waitForSelectorで待つ
動かし始めた頃、読み込みが不安定なページに waitForTimeout(3000) を入れてしのいでいた。これは二重に悪くて、速いときは無駄に待ち、遅いときは3秒でも足りずに落ちる。毎日走らせると「たまに落ちる」が確実に積み上がっていく。
今は原則こうしている。「次に自分が触りたい要素そのもの」を待つ。
// 悪い: 時間で待つ
await page.waitForTimeout(3000);
// 良い: 欲しいものを待つ
await page.waitForSelector('[data-loaded="true"]');
// あるいはロケーターで
await page.getByRole('heading', { level: 1 }).waitFor();
waitForLoadState('networkidle') も試したが、常用はやめた。アクセス解析やチャットウィジェットが定期的に通信するページでは、ネットワークがいつまでも「idle」にならず、タイムアウトまで待ち切ってしまうことがあるからだ。networkidleが役に立ったのは「どの要素が出れば完了なのか特定できていない、初見のページの調査段階」だけで、本番のコードに残すものではなかった。公式ドキュメントにも今はnetworkidleは非推奨と明記されている。
E2Eテストの場合、実はこの問題の大半はPlaywrightが吸収してくれる。expect のWebファーストなアサーションは条件を満たすまで自動リトライするので、明示的な待機を書く場面自体が少ない。
import { test, expect } from '@playwright/test';
test('注文完了ページが表示される', async ({ page }) => {
await page.goto('/checkout');
await page.getByRole('button', { name: '注文を確定する' }).click();
// 出現するまで自動で待ってくれる。手動waitは不要
await expect(page.getByRole('heading', { name: '注文完了' })).toBeVisible();
await expect(page).toHaveURL(/\/orders\/complete/);
});
スクレイパーを素のPlaywright(playwright パッケージ)で書いていると全部自分で待機を組む必要があるが、@playwright/test でテストを書く分には、自動待機に乗るのが一番安定する。「waitForTimeoutが登場したら設計を疑う」は両方に共通するルールだ。
一覧ページのデータを信じてはいけない
スクレイパー特有に見えて、テスト設計に直結した学びがこれ。一覧ページに表示される件数やステータスが、詳細ページの値と平気で食い違う。一覧はキャッシュされた古い値を出していて、詳細ページが正、というパターンだった。最初は一覧だけ取って高速化していたのだが、「掲載中」と記録した項目が実際はとっくに終了していたことが何度もあり、結局「判断に使う値は詳細ページから取り直す」方式に改めた。
E2Eテストに翻訳すると、ユーザーが最終的に信頼する画面でアサートするということになる。たとえば「商品をカートに入れた」ことを検証するとき、カートバッジの数字(一覧側の表示)が更新されたことだけ見て安心すると、カートページ本体(詳細側)が壊れているのを見逃す。逆にAPIレスポンスだけ検証して画面を見ないのも同じ穴に落ちる。中間表現ではなく、ユーザーが意思決定に使う画面を最後に踏む。地味だが、これを意識してからテストの「通ったのに本番は壊れていた」が減る理屈が腹落ちした。
ヘッドレスChromeは「検知回避」より先に、素直に見え方を整える
ヘッドレスブラウザというと検知回避のテクニックが話題になりがちだが、自分の運用で実際に問題になったのはもっと手前だった。ヘッドレスのデフォルト状態は、UAに HeadlessChrome が入り、viewportも実際のユーザーと違う。その結果、サイトによってはモバイル向けレイアウトやフォールバックUIが返ってきて、デスクトップで確認したセレクタがそもそも存在しない。壊れているのはサイトでも検知でもなく、自分の見ているページと取得しているページが別物だっただけだ。
なので怪しいテクニックの前に、まず普通のブラウザとして振る舞う設定を入れる。
const context = await browser.newContext({
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
viewport: { width: 1280, height: 800 },
locale: 'ja-JP',
});
これだけで「手元では取れるのにVPSでは取れない」の類いはほぼ消えた。E2Eテストでも同じ話がそのまま効く。レスポンシブなサイトはviewport次第でナビゲーションの構造が変わる(デスクトップのナビリンクがモバイルではハンバーガーメニューの中、など)ので、playwright.config.ts の projects でデバイスを明示して、どの見た目をテストしているのかを固定する。
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
],
});
「デスクトップ用のテストがモバイルレイアウトを踏んで落ちる」は、原因に気づくまでが長い割に、設定1行で二度と起きなくなる種類のバグだ。
失敗前提のリトライとログ設計
毎日無人で走らせていると、成功率100%は幻想だと分かる。相手サーバーの一時的なエラー、タイムアウト、深夜のメンテナンス。原因を潰すより「失敗しても翌日リカバリできる」設計にする方が現実的だった。
自分のスクレイパーでやっているのは大きく2つ。1ページ単位のリトライと、失敗時に「何を見ていたか」を残すログだ。特にログは、エラーが発生しました だけ吐いても翌日の自分には何も分からないので、URL・どの段階か・その時のページ状態(スクリーンショットかHTML)をセットで残す。
try {
await page.goto(url, { timeout: 30_000 });
await page.getByRole('heading').first().waitFor();
} catch (e) {
await page.screenshot({ path: `logs/fail-${Date.now()}.png`, fullPage: true });
console.error(`[detail] failed url=${url} step=goto`, e);
continue; // 1件の失敗で全体を止めない
}
E2Eテストでは、これに相当する仕組みがフレームワークに最初から入っている。自前で組む必要はなくて、設定を有効にするだけでいい。
export default defineConfig({
retries: process.env.CI ? 2 : 0,
use: {
trace: 'on-first-retry', // リトライ時にトレースを記録
screenshot: 'only-on-failure', // 失敗時のスクショ
video: 'retain-on-failure',
},
});
トレースは npx playwright show-trace で各ステップのスナップショットとネットワークまで遡れる。スクレイパー側で「失敗時の状態保存」を手組みした経験からすると、これが標準装備なのは相当ありがたい機能で、E2Eを始めるならまず有効にしておくべき設定だと思う。
ひとつ注意点があるとすれば、リトライは「たまに落ちるテスト(flaky test)を隠す道具」にもなり得ることだ。自分のスクレイパーでも、リトライで通っているうちに根本原因(待機不足)を放置して、ある日リトライでも吸収できなくなったことがある。リトライで通った記録は「成功」ではなく「要調査」として扱う方が長期的には安い。
メモリの少ないVPSでChromeを動かすときの注意
最後に環境の話。うちのVPSはメモリに余裕がなく、Chromeを雑に扱うとすぐOOMでプロセスごと死ぬ。実際に効いた対策はこのあたり。
--disable-dev-shm-usageを付ける。 Linuxでは共有メモリ/dev/shmが小さい環境だとChromeがクラッシュする。コンテナやVPSでの定番フラグ。- ページとコンテキストを確実に閉じる。 何十件も詳細ページを巡回するループで
page.close()を怠ると、メモリがじわじわ増えて後半で落ちる。1件ごとに閉じるか、一定件数でコンテキストごと作り直す。 - 並列度を欲張らない。 Playwrightのテストなら
workersを絞る。省メモリ環境で並列4は無謀で、直列(workers: 1)の方がトータルで速いことすらある。落ちて全部やり直すのが一番遅い。
# CIや省メモリ環境ではworkersを明示する
npx playwright test --workers=1
GitHub ActionsのランナーでE2Eを回す場合も事情は同じで、メモリを食い潰して不可解に落ちるテストの原因が並列度だった、という話はよく見る。手元の開発機の感覚で設定を持ち込まないこと。
これからE2Eテストを始める人へ
自分はE2Eテストの大規模なCI運用者ではない。ただ、毎日ブラウザ自動化を運用して壊れ続けた経験から言えるのは、Playwrightのベストプラクティス(ロールベースのロケーター、自動待機、トレース)は机上の作法ではなく、全部「壊れた誰かの跡地」だということだ。自分はスクレイパーで一通り踏んでから理解したが、E2Eテストなら最初から従っておけばいい。
始め方としては npx playwright codegen <URL> でブラウザ操作をコードに起こして眺めるのが早い。生成されるコードがロールベースのロケーターになっているので、それ自体が教材になる。テストを増やすのはクリティカルパス(ログイン、購入、問い合わせ送信など、壊れたら実害が出る導線)からで、網羅を目指すのは後でいい。
ちなみに毎日の実行スケジュールはcronで管理していて、cron式を書くときは自作のcron式ジェネレータで確認している。取得したデータの整形やログのJSONを確認するときはJSON整形ツールも使う。ブラウザ自動化は書いて終わりではなく運用が本体なので、この手の周辺の道具も含めて整えておくと続けやすい。