複数ワーカーでPlaywrightのE2Eテストを実行すると、案件検索やページロードが断続的にタイムアウトしていました。
原因は、すべてのワーカーが同じ認証セッションを共有したまま、各テストの開始時に同じ重いデータ探索を繰り返していたことです。
そこで、探索処理を専用のPlaywrightプロジェクトへ移し、通常の1回のテスト実行につき一度だけ直列で実行するようにしました。探索結果はJSON snapshotへ保存し、各ワーカーはそれを読み込むだけにします。
手元の環境では、次の結果になりました。
- 並列実行時に発生していた探索処理由来のタイムアウトが再現しなくなった
- 探索回数を「テストごと」から「テスト実行全体で1回」へ削減できた
-
workers: 1でも、実行時間が約5.2分から約4分へ短縮した - 一部の探索失敗が無関係なテストへ波及しなくなった
この記事では、project dependenciesを使った前処理チェーンと、部分失敗を許容するfail-soft設計を紹介します。
前提:別のBrowserContextでも同じサーバーセッションになり得る
Playwrightはテストごとに独立したBrowserContextを作成します。
ただし、複数のContextが同じstorageStateを読み込む場合、同じセッションCookieも複製されます。ブラウザー側のContextは分離されていても、サーバーから見ると同じセッションIDからのリクエストです。
対象アプリケーションでは、Laravel側で同一セッション単位の排他制御が働いていました。
各ワーカーが重い探索リクエストを同時に送ると、後続リクエストはロック解放を待ちます。その待ち時間を含めてタイムアウト閾値を超えると、テストが失敗します。
Laravelには、同一セッションからの並行リクエストを制限するsession blockingがあります。
これはLaravelのすべてのリクエストへ自動的に適用される挙動ではありません。この記事では、対象環境で同様の排他制御が有効になっていることを確認したうえで原因を判断しています。
症状:ワーカーを増やすほど失敗が増える
変更前は、各テストが必要な実データを起動時に探索していました。
たとえば、次のような条件を満たすデータです。
- 明細を1件だけ持つ注文
- 複数の配送先を持つ注文
- 特定の状態にある請求書
- 編集可能な在庫データ
これらを各テストがcold状態から探索するため、同じAPIや画面へ短時間にリクエストが集中していました。
観測結果は次の通りです。
| workers | failures |
|---|---|
| 6 | 3 |
| 2 | 1 |
| 1 | 0 |
workers: 1にすれば安定します。しかし、探索と無関係なテストまで直列化されるため、テストスイート全体の実行時間が伸びます。
問題は並列実行そのものではありません。
並列化する必要のない重い前処理まで、各ワーカーが繰り返していたことが問題でした。
解法:探索を1回に集約してsnapshotを配る
構成を次のように変更しました。
- 認証状態を準備する
- 専用の
discoveryプロジェクトで探索を1回だけ実行する - 探索結果をJSON snapshotへ保存する
- 各ワーカーはsnapshotを読み込む
- snapshotに値がない場合だけlive探索へフォールバックする
Playwrightのdependenciesで、前処理の順序を明示します。
Playwrightのproject dependenciesで順序を固定する
設定の要点だけを抜き出すと、次のようになります。
import { defineConfig } from '@playwright/test';
const storageState = authStatePath();
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'discovery',
testMatch: /discovery\.warmup\.ts/,
dependencies: ['setup'],
use: {
storageState,
},
},
{
name: 'desktop',
testDir: desktopTestDir,
dependencies: ['discovery'],
use: {
storageState,
viewport: {
width: 1280,
height: 800,
},
},
},
{
name: 'mobile',
testDir: mobileTestDir,
dependencies: ['discovery'],
use: {
storageState,
viewport: {
width: 390,
height: 844,
},
},
},
],
});
desktopとmobileは、discoveryが完了するまで開始されません。
また、複数のプロジェクトが同じdiscoveryへ依存していても、依存プロジェクトはテスト実行全体の前処理として扱われます。discovery側のテストを1件にしておけば、探索処理を1か所へ集約できます。
認証状態の作り方は環境によって異なります。
この記事の本題は認証自動化ではなく、認証後の重い探索処理を1回に集約することです。
なぜglobalSetupではなくproject dependenciesなのか
globalSetupでも似た処理は実装できますが、今回はproject dependenciesを選びました。
主な理由は次の通りです。
- setup処理を通常のPlaywrightテストとして実装できる
- HTML report上で前処理の成功・失敗を確認できる
- traceやfixtureなど、Playwright Testの機能を利用できる
-
setup → discovery → testsという依存関係をconfigに明示できる -
--no-depsで依存プロジェクトを意図的に省略できる
前処理もテストスイートの一部として観測したい場合、project dependenciesの方が扱いやすい構成です。
warmupは登録済みの探索処理を直列に回す
discoveryプロジェクトでは、登録済みの探索処理を1件ずつ実行します。
ドメイン固有の探索ロジックは各モジュールへ置き、warmup自身は順番に実行して結果をsnapshotへ保存するだけにします。
import { test as setup } from '@playwright/test';
type DiscoveryFailure = {
name: string;
error: string;
};
const toErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
setup('warm discovery snapshot', async ({ playwright }) => {
const url = baseURL();
const context = await playwright.request.newContext({
baseURL: url,
storageState: authStatePath(),
extraHTTPHeaders: {
Referer: `${url}/`,
Origin: url,
},
});
const failures: DiscoveryFailure[] = [];
let total = 0;
try {
for (const discoveryModule of discoveryModules) {
for (const runner of discoveryModule.runners(context)) {
total += 1;
const label = `${discoveryModule.name}.${runner.name}`;
const startedAt = Date.now();
try {
await runner.run();
console.info(
`\t\t✓ ${label} (${Date.now() - startedAt}ms)`,
);
} catch (error) {
const message = toErrorMessage(error);
failures.push({
name: label,
error: message,
});
console.warn(`\t\t✘ ${label} — ${message}`);
}
}
}
} finally {
await context.dispose();
}
if (total === 0) {
throw new Error('探索対象が1件も登録されていません');
}
if (failures.length === total) {
const detail = failures
.map((failure) => `\t\t- ${failure.name}: ${failure.error}`)
.join('\n');
throw new Error(
[
'探索warmup: すべての探索処理が失敗しました。',
`認証状態または対象環境を確認してください: ${url}`,
detail,
].join('\n'),
);
}
writeDiscoverySnapshot(serializeDiscoverySnapshot());
if (failures.length > 0) {
const detail = failures
.map((failure) => `\t\t- ${failure.name}: ${failure.error}`)
.join('\n');
console.warn(
[
`[warmup] ${failures.length}/${total}件をsnapshotへ保存できませんでした。`,
'該当データを使うテストだけがlive探索へフォールバックします。',
detail,
].join('\n'),
);
}
});
ログは<domain>.<slot>形式にしています。
✓ orders.singleDestination (812ms)
✓ orders.multipleDestinations (945ms)
✘ invoices.editable — matching data was not found
これにより、次の情報を一度に確認できます。
- どの探索処理が成功したか
- どのslotが未充填か
- それぞれ何msかかったか
- 認証や環境全体が壊れているのか
- 一部のテストデータだけが不足しているのか
設計の要点:部分失敗はfail-soft、全滅はfail-fast
warmupを完全なハードバリアにすると、1件の探索失敗だけで全テストが停止します。
たとえば「複数配送先の注文」が存在しないだけで、請求書や在庫のテストまで実行できなくなる構成は過剰です。
そこで、失敗をslot単位で隔離しました。
| 状況 | 解釈 | 挙動 |
|---|---|---|
| 一部のslotが失敗 | 特定条件のデータだけが存在しない | 警告して部分snapshotを保存 |
| 必要なslotがsnapshotにない | warmupで取得できなかった | 該当テストだけlive探索 |
| snapshotファイルがない |
--no-depsや部分実行 |
live探索へフォールバック |
| snapshotが不正 | 破損またはschema不一致 | エラーとして停止 |
| すべてのslotが失敗 | 認証または環境全体の異常 | 即時にthrow |
| 探索対象が0件 | レジストリ設定の異常 | 即時にthrow |
重要なのは、すべての例外を握りつぶすことがfail-softではない点です。
回復可能な欠落だけを許容し、構成ミスや破損は早期に失敗させます。
各ワーカーはsnapshotを1回だけ読み込む
fixture側では、module scopeのフラグを使って読み込みをメモ化します。
Playwrightのworkerは独立したOSプロセスなので、この変数はworkerごとに保持されます。
import { readFileSync } from 'node:fs';
let snapshotAttempted = false;
const isFileNotFoundError = (
error: unknown,
): error is NodeJS.ErrnoException =>
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT';
const hydrateDiscoveryFromSnapshot = (): void => {
if (snapshotAttempted) {
return;
}
snapshotAttempted = true;
let serializedSnapshot: string;
try {
serializedSnapshot = readFileSync(
discoverySnapshotPath(),
'utf8',
);
} catch (error) {
if (isFileNotFoundError(error)) {
return;
}
throw error;
}
hydrateDiscoverySnapshot(serializedSnapshot);
};
ENOENTだけは、snapshotを生成せずにテストを部分実行したケースとして扱います。
一方で、次の問題は握りつぶしません。
- JSONが壊れている
- schema versionが一致しない
- 想定していないキーが含まれている
- ファイルを読み取る権限がない
- hydrate処理自体にバグがある
何でもlive探索へフォールバックすると、snapshotの不具合へ気づきにくくなります。
snapshotに値がない場合だけlive探索する
snapshotのhydrate後、必要なslotに値があればそのまま利用します。
値がなければ、該当テストだけが従来のlive探索を実行します。
const resolveDiscoverySlot = async <DiscoveredValue>(
slot: DiscoverySlot<DiscoveredValue>,
): Promise<DiscoveredValue> => {
hydrateDiscoveryFromSnapshot();
const cachedValue = slot.get();
if (cachedValue !== undefined) {
return cachedValue;
}
return slot.discover();
};
この構成では、あるslotのwarmupが失敗しても、影響を受けるのはそのslotを必要とするテストだけです。
無関係なテストは、欠落したslotに巻き込まれません。
なぜワーカーごとに別アカウントを使わなかったのか
Playwrightでは、並列テストがサーバー側の状態を変更する場合、ワーカーごとに独立したアカウントを割り当てる方法が推奨されています。
それが可能なら、認証セッション自体を分離する方が素直です。
今回の環境には、次の制約がありました。
- 認証フローが対話的である
- テスト用アカウントをワーカー数だけ用意できない
- 実環境に近いデータを使う必要がある
- 競合の大部分がテスト本体ではなく、起動時の探索処理へ集中している
そのため、認証セッションを増やすのではなく、共有セッションへ集中していた不要な探索リクエストを削減しました。
これは「ワーカーごとのアカウントより常に優れている」という話ではありません。
| 条件 | 適した方法 |
|---|---|
| テスト同士がサーバー状態を変更しない | 同じ認証状態を共有可能 |
| アカウントを複数用意できる | workerごとに認証状態を分離 |
| アカウントを増やせず、前処理だけが競合する | warmup + snapshot |
| テスト本体も同じ共有データを更新する | テストデータまたは実行単位の分離が必要 |
直列にしたのに速くなった理由
変更前は、探索処理を各テストが繰り返していました。
概念的には、実行コストが次のようになります。
変更前:
探索コスト × テスト数 + テスト本体
変更後:
探索コスト × 1 + snapshot読み込み × worker数 + テスト本体
snapshot生成自体は直列です。
それでも、重い探索を何度も実行するより、1回だけ直列で実行した方が総処理量は少なくなります。
手元の環境では、workers: 1に制限した状態でも次の差が出ました。
| 構成 | 探索回数 | 実行時間 |
|---|---|---|
| 変更前 | テストごと | 約5.2分 |
| warmup + snapshot | 実行全体で1回 | 約4分 |
並列化は、処理量そのものを減らしてくれるわけではありません。
重複した重い処理がある場合は、ワーカー数を増やす前に「本当に毎回実行する必要があるか」を見直す方が効くことがあります。
snapshotを永続キャッシュにしない
snapshotはテスト実行ごとに作り直しています。
永続化すると、対象環境のデータが変わった際に、次の問題が発生します。
- 削除済みIDを参照する
- 状態が変わったデータを利用する
- 別環境のsnapshotを誤って読み込む
- 古いschemaのsnapshotが残る
そのため、snapshotは.gitignore対象の一時ディレクトリへ保存します。
playwright/
├── .auth/
│ └── user.json
└── .cache/
└── discovery.json
snapshotには認証情報を含めなくても、環境固有のIDやURLが含まれる可能性があります。リポジトリへcommitしない方が安全です。
hydrate時は既知のキーだけを受け入れる
外部JSONを内部レジストリへ流し込むため、hydrate側では任意のキーをそのままコピーしないようにします。
最低限、次の検証を入れます。
-
schemaVersionが一致すること - snapshot全体が期待したobjectであること
- 登録済みdomainとslotだけをコピーすること
-
__proto__、constructor、prototypeなどを受け入れないこと - 値の型がslotのschemaと一致すること
snapshotはローカル生成物でも、入力検証を省略する理由にはなりません。
さらに堅牢にするなら
今回の問題を解消するうえでは必須ではありませんが、運用を長く続けるなら次も有効です。
schema versionを持たせる
{
"schemaVersion": 1,
"generatedAt": "2026-07-13T00:00:00.000Z",
"environment": "staging",
"slots": {}
}
構造変更後に古いsnapshotを読み込む事故を防げます。
一時ファイルへ書いてからrenameする
プロセス終了のタイミングによっては、JSONの書き込み途中でファイルが残る可能性があります。
discovery.json.tmpへ書く
↓
書き込み成功後にdiscovery.jsonへrename
同一ファイルシステム上のrenameを使えば、不完全なsnapshotを読み込む可能性を下げられます。
対象環境をsnapshotへ記録する
staging用のsnapshotを別環境で誤利用しないよう、base URLや環境識別子を記録してhydrate時に照合します。
この設計が向いているケース
warmup + snapshotは、次の条件で特に有効です。
- 複数テストが同じ重い探索を繰り返している
- 探索結果をテスト間で安全に共有できる
- 対象データはテスト実行中に大きく変わらない
- 認証セッションや外部APIなど、共有リソースに競合がある
- 一部のデータ不足を無関係なテストから隔離したい
一方で、次のケースでは別の方法を検討した方がよいです。
- テストごとに完全に独立したデータが必要
- snapshot生成後に対象データが頻繁に変わる
- テスト本体が同じ共有レコードを更新する
- ワーカーごとに独立したアカウントを簡単に用意できる
- 探索よりもテスト本体の競合が支配的
まとめ
今回の問題は、ワーカー数を減らすだけでも回避できました。
しかし、実際に必要だったのは並列実行を捨てることではなく、並列化する必要のない重い処理を1回に集約することでした。
要点は次の通りです。
- 同じ
storageStateを使うworkerは、サーバー側では同じセッションになり得る - 共有セッションへ重い探索リクエストを集中させると、排他制御とタイムアウトが競合する
- Playwrightのproject dependenciesで
認証 → 探索 → テストを明示できる - 探索結果をsnapshot化すると、各workerは読み込むだけで済む
- 部分的なデータ不足はfail-softで隔離する
- 全滅、schema不一致、破損はfail-fastで早期に検出する
- 直列処理を追加しても、重複処理を削減できれば全体は速くなる
このパターンはE2Eに限りません。
キャッシュwarmup、事前計算、プリフェッチ、fixture生成など、失敗しても全体を止める必要はないが、成功すれば後続処理を大幅に軽くできる最適化レイヤへそのまま応用できます。