PlaywrightのE2Eテストで無駄なリクエストを8割削減

E2Eテストを別のツールからPlaywrightに移行し、実行していたところ、テスト対象サイトへの転送量が以前より多いことに気がつきました。以前のツールではテスト一括実行時の転送量は約380MBほどでしたが、Playwright移行後は826.8MB。 アクセスログを見ると、サーバへのリクエストは34,173回。内訳から、同じCSSやJS、画像などの静的ファイルを何度も取り直していることがわかりました。 この記事では、自前のキャッシュでリクエストを7,670回(約8割減)・転送量を230.9MB(約7割減)まで減らした方法をご紹介します。

E2Eテストを別のツールからPlaywrightに移行し、実行していたところ、テスト対象サイトへの転送量が以前より多いことに気がつきました。以前のツールではテスト一括実行時の転送量は約380MBほどでしたが、Playwright移行後は826.8MB

アクセスログを見ると、サーバへのリクエストは34,173回。内訳から、同じCSSやJS、画像などの静的ファイルを何度も取り直していることがわかりました。

この記事では、自前のキャッシュでリクエストを7,670回(約8割減)・転送量を230.9MB(約7割減)まで減らした方法をご紹介します。

現状確認:同じ静的ファイルを何百回も取り直している

繰り返し取得されていたファイルの一部が、以下のようなCSS・JSです。サイトの共通ファイルということもあり、どれも同じ483回取得が繰り返されていました。

ファイル サーバ取得回数
style.css 483回
jquery-3.7.1.min.js 483回
bootstrap-3.4.1.min.js 483回
site-common.js 483回

ブラウザには標準のキャッシュがあります。これが有効であればこんな数にはならないのでは?と思うかもしれませんが、Playwrightではキャッシュの有効範囲が違っていました。

Playwrightのブラウザキャッシュの有効範囲は1つのtest()の中だけですtest()のたびに新しいシークレットブラウザが立ち上がるような形になるため、その都度新しくファイルを取り直し、積み重なってこのリクエスト数になっていたようです。

解決策:自前のキャッシュ

現状のままでもテストの動作自体は問題ありませんが、今後テストが増えるとその分無駄なリクエストが増えることになります。そこで、テストをまたいでも残る自前のキャッシュを使う方式にしました。やりたいことは、CSSやJSなど静的ファイルへのアクセスを横取りし、以下のような流れにするということです。

  • すでにキャッシュ済みなら、それを返す
  • 無ければ、サーバから取得し、キャッシュしてから返す

こうしておけば、テスト全体で同じキャッシュを使い回せます。

自前キャッシュの実装

キャッシュの保存先はe2e/.asset-cache/に置き、実装はe2e/fixtures.jsに書いていきます。まず今回の説明に必要な部分だけを抜き出したfixtures.jsの全体像が以下になります。

①対象判定 → ②保存キー → ③取得・保存 → ④登録 → ⑤適用の5パートでできています。少し長いですが、以下順に見ていきます。

e2e/fixtures.js
const { test: base, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

・・・・・ // 通常の設定など

// --- 設定 ---
const ASSET_CACHE_DIR = path.join(__dirname, '.asset-cache');
const ASSET_CACHE_HOST = process.env.BASE_URL // 同一ホストだけ対象に
  ? (() => { try { return new URL(process.env.BASE_URL).host; } catch { return null; } })()
  : null;
const STATIC_EXT_RE = /\.(?:js|mjs|css|woff2?|ttf|otf|eot|png|jpe?g|gif|svg|ico|webp|avif)$/i;

// --- ① キャッシュ対象判定 ---
function isCacheableAsset(url) {
  if (!ASSET_CACHE_HOST || url.host !== ASSET_CACHE_HOST) return false;
  return STATIC_EXT_RE.test(url.pathname);
}

// --- ② URL全体をSHA1でファイル名化 ---
function assetCacheKey(url) {
  return crypto.createHash('sha1').update(url).digest('hex');
}

// --- ③ キャッシュがあれば返す / 無ければ取得して保存 ---
async function serveAssetFromCache(route) {
  // 本体は長いので後述します
}

// --- ④ contextにrouteを登録 ---
async function installAssetCache(context) {
  if (!ASSET_CACHE_HOST) return;
  fs.mkdirSync(ASSET_CACHE_DIR, { recursive: true });
  await context.route((url) => isCacheableAsset(url), serveAssetFromCache);
}

// --- ⑤ 全テストへ適用 ---
const test = base.extend({
  context: async ({ context }, use) => {
    await installAssetCache(context);
    await use(context);
  },
});

・・・・・ // 他のfixtureなど

module.exports = { test, expect, installAssetCache };

① キャッシュ対象判定

キャッシュ対象が1つのディレクトリ内に収まっていればそのディレクトリを指定する形でもOKでしたが、今回は複数ディレクトリにキャッシュしたいファイルがあったため、以下のようにファイル拡張子での判定としました。

const STATIC_EXT_RE = /\.(?:js|mjs|css|woff2?|ttf|otf|eot|png|jpe?g|gif|svg|ico|webp|avif)$/i;

function isCacheableAsset(url) {
  if (!ASSET_CACHE_HOST || url.host !== ASSET_CACHE_HOST) return false;
  return STATIC_EXT_RE.test(url.pathname);
}

外部サイトを対象にしないようBASE_URLと同じホストかどうかも見ています。

② URL全体をSHA1でファイル名化

URLをそのままファイル名にすると/?が混じってしまうため、URL全体をSHA1でハッシュしてファイル名としています。

function assetCacheKey(url) {
  return crypto.createHash('sha1').update(url).digest('hex');
}

③ キャッシュがあれば返し、なければ保存

横取りした静的ファイルのリクエストを「過去にキャッシュ済みならそれを返す」→「無ければサーバから取得し、キャッシュしてから返す」のように処理します。

async function serveAssetFromCache(route) {
  const req = route.request();
  if (req.method() !== 'GET') return route.fallback();
  const url = req.url();
  const key = assetCacheKey(url); // URL全体から作る保存の目印(キー)
  const bodyPath = path.join(ASSET_CACHE_DIR, key);
  const metaPath = bodyPath + '.json';

  // ヒットしたらキャッシュから配信
  try {
    if (fs.existsSync(bodyPath) && fs.existsSync(metaPath)) {
      const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
      await route.fulfill({
        status: 200,
        contentType: meta.contentType,
        body: fs.readFileSync(bodyPath),
      });
      return;
    }
  } catch { /* 読み込み失敗時はサーバ取得にフォールバック */ }

  // 無ければ実サーバから取得 → 保存 → その応答で fulfill
  try {
    const response = await route.fetch();
    if (response.status() === 200) {
      try {
        const body = await response.body();
        fs.writeFileSync(bodyPath, body);
        fs.writeFileSync(metaPath, JSON.stringify({
          contentType: response.headers()['content-type'] || 'application/octet-stream',
          url,
        }));
      } catch { /* 保存失敗はキャッシュせず続行 */ }
    }
    await route.fulfill({ response });
  } catch {
    await route.fallback();
  }
}

route.fulfill()は、サーバの代わりにこちらで用意したレスポンスをブラウザへ返すメソッドです。自前のキャッシュを返すときも、サーバから取得した応答{ response }を返すときも、どちらもこのfulfill()を使用しています。

④ 登録

ここまでの流れをrouteとして登録します。これがキャッシュの入口となります。

async function installAssetCache(context) {
  if (!ASSET_CACHE_HOST) return;
  fs.mkdirSync(ASSET_CACHE_DIR, { recursive: true });
  await context.route((url) => isCacheableAsset(url), serveAssetFromCache);
}

⑤ 全テストへ適用

最後に、Playwright標準のcontextを上書きし、その中で先ほどのinstallAssetCache()を呼びます。こうすることで、各テストがpagecontextを使った時点でキャッシュが自動的に効くようになります。

const test = base.extend({
  context: async ({ context }, use) => {
    await installAssetCache(context);  // ← これで全テストに自動適用
    await use(context);
  },
});

module.exports = { test, expect, installAssetCache };

効果

この自前キャッシュを実装した結果、共通CSS/JSの取得数は以下のようになりました。最初の1回だけサーバから取得し、あとは全テストでキャッシュを使い回していることがわかります。

ファイル 自前キャッシュなし 自前キャッシュあり
style.css 483回 1回
jquery-3.7.1.min.js 483回 1回
bootstrap-3.4.1.min.js 483回 1回
site-common.js 483回 1回

全テスト一括実行の結果も、以下のように大きく削減できました!

指標 自前キャッシュなし 自前キャッシュあり 削減
サーバへのリクエスト 34,173回 7,670回 −78%
転送量 826.8 MB 230.9 MB −72%

さらに良くするなら

今回はできるだけシンプルな作りにしましたが、実行のたびにキャッシュをリセットするなどの仕組みをプラスするのも良いと思います。そうしておくと、ファイル名にバージョンが付かない静的ファイルでも実行の都度の最新状態でキャッシュされます。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。