zudo-test-wisdom

Type to search...

to open search from anywhere

Playwrightパターン

CI と本番環境検証のための Playwright E2E テストパターン。

CI安全テスト vs @interactiveテストの分割

すべてのE2EテストがCIで実行できるわけではありません。キーボードショートカット、クリップボードアクセス、デスクトップ固有のインタラクションを必要とするテストにはタグを付けて分割します:

// e2e/basic-navigation.spec.ts -- runs in CI
import { test, expect } from "@playwright/test";

test("loads the home page", async ({ page }) => {
  await page.goto("/");
  await expect(page.locator("h1")).toBeVisible();
});
// e2e/keyboard-shortcuts.spec.ts -- only runs locally
import { test, expect } from "@playwright/test";

test("@interactive Ctrl+S saves document", async ({ page }) => {
  await page.goto("/editor");
  await page.keyboard.press("Control+KeyS");
  await expect(page.locator(".save-indicator")).toHaveText("Saved");
});
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  projects: [
    {
      name: "ci",
      testMatch: /.*\.spec\.ts/,
      testIgnore: /.*@interactive.*/,
    },
    {
      name: "interactive",
      testMatch: /.*@interactive.*\.spec\.ts/,
    },
  ],
});

💡 Tip

CIでは npx playwright test --project=ci を実行し、フルキーボード/クリップボードテストが必要な場合はローカルで npx playwright test --project=interactive を実行します。

フレイキーテストの隔離:リトライ回数の非対称性という罠

CI安全テスト vs @interactive の分割に加えて、知っておく価値のある3つ目のタグがあります:@flaky です。このタグが存在する理由は、ある微妙な罠にあります。CIとローカルのプッシュ前ゲートは、しばしば異なるリトライ回数(リトライバジェット)で実行されるため、同じテストが一方ではグリーン、もう一方ではレッドになりうるのです。

罠はここから始まります:

// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  // CI retries twice; local runs get zero retries.
  retries: process.env.CI ? 2 : 0,
});

CIで retries: 2 が設定されていると、2回目や3回目の試行でパスするテストはグリーンとして報告されます。まったく同じテストを retries: 0 のローカル b4push ゲートで実行すると、最初の失敗でレッドになります。テスト自体は変わっていません。変わったのはリトライ回数だけです。ここで腑に落とすべき洞察はこれです:「フレイキーかどうか」はゲート相対的である。 テストは、それがクリアしなければならない最も厳しいゲートの分だけフレイキーなのです。

すでに main に存在する既知のフレイキーテストがある場合、削除してしまうとカバレッジが失われます。そうではなく、タイトルに @flaky タグを付けて、削除することなく厳格なローカルゲートから隔離します:

# scripts/run-b4push.sh -- exclude @flaky from the strict local gate
CHROMIUM_INVERT="@interactive|@flaky"
WEBKIT_INVERT="@flaky"

# Chromium step: skip both @interactive and @flaky
pnpm test:e2e --project=chromium --grep-invert="$CHROMIUM_INVERT"

# WebKit @interactive step: run @interactive but still drop @flaky
pnpm test:e2e --project=webkit --grep="@interactive" --grep-invert="$WEBKIT_INVERT"

Chromiumステップは(@interactive に加えて)--grep-invert@flaky を追加し、WebKitの @interactive ステップも @flaky を除外します。テストはスイートに残ったまま(CIは引き続き実行し、ときおりのリトライを許容します)ですが、リトライ回数ゼロのローカルゲートを引っかけることはなくなります。

⚠️ Warning

@flaky は隔離であって、恒久的なスキップではありません。すでに main 上で既知のフレイキーと分かっているテストにのみタグを付けてください。新規テストにタグを付けてゲートを通そうとしてはいけません。根本的なレース条件を修正したら、同じPR内でタグを削除してください。そうしないとリストが知らぬ間に膨らみ、本物のカバレッジが失われていきます。

💡 Tip

フレイキーなマシンがプッシュをブロックしないよう、ローカルゲートには脱出口を用意しておきましょう。例:WebKitパスだけをスキップする SKIP_E2E_WEBKIT=1、E2Eステージ全体をスキップする SKIP_E2E=1、そして修正の検証時に隔離されたテストを実行するためのオプトイン RUN_FLAKY=1

E2Eでのエディター入力

コードエディター(CodeMirror、Monaco、ProseMirror、あるいは任意の contenteditable)をPlaywrightから操作するのは、page.fill() よりも厄介です。エディターにvimモードがある場合、page.keyboard.type("hello") は悲惨なことになります。先頭の h でカーソルが左に移動し、i でインサートモードに入り、残りはテキストではなくコマンドとして解釈されてしまうのです。

確実な方法は、DOM Selection API で既存コンテンツをすべて選択し、page.keyboard.insertText() で新しいコンテンツを流し込むことです。insertText はエディターが直接処理する合成 input イベントを発火させ、vimモードのコマンド解釈を完全にバイパスします

// e2e/helpers.ts
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import os from "os";

// Platform-aware modifier: Meta on macOS, Control on Linux/Windows
export const mod = os.platform() === "darwin" ? "Meta" : "Control";

export async function setEditorContent(page: Page, content: string) {
  const editor = page.locator(".cm-content");
  await editor.waitFor({ timeout: 5000 });
  await editor.click();

  // Select all content via the DOM Selection API (works regardless of vim mode)
  await page.evaluate(() => {
    const el = document.querySelector(".cm-content");
    if (!el) return;
    const range = document.createRange();
    range.selectNodeContents(el);
    const sel = window.getSelection();
    sel?.removeAllRanges();
    sel?.addRange(range);
  });

  // insertText dispatches an input event the editor handles directly,
  // bypassing vim-mode command interpretation entirely.
  await page.keyboard.insertText(content);

  // Wait for the Lezer parse + decoration updates to land before asserting.
  const firstLine = content.split("\n").find((l) => l.trim()) || content;
  await expect(page.locator(".cm-content")).toContainText(firstLine.slice(0, 20), {
    timeout: 5000,
  });

  // Arbitrary timeout — acceptable ONLY because 500ms is the known auto-save
  // debounce constant. Features like split-pane read content back from the
  // backend, so the test must wait >= the debounce or it races the persist.
  await page.waitForTimeout(500);
}

プラットフォームを判別する mod ヘルパーにより、同一のspecがmacOS(Meta)とLinux/Windows(Control)の両方でエディターのショートカットを操作できます。テストごとに分岐を書く必要はありません。

⚠️ Warning

この waitForTimeout(500) は、通常の「任意の waitForTimeout を決して使うな」というルールの正当な例外です。任意の待機が許容されるのは、それが既知のアプリケーション定数にひも付けられている場合(ここでは500msの自動保存デバウンス)に限り、かつコメントでその理由を文書化しているときだけです。理由のない裸の waitForTimeout(500) は依然としてフレイキーの火種です。実在する定数にひも付けるか、適切な expect 待機に置き換えてください。

コンソールエラーモニタリング

Playwrightのテストフィクスチャを拡張して、コンソールエラー時に自動的にテストを失敗させます:

// e2e/fixtures.ts
import { test as base, expect } from "@playwright/test";

export const test = base.extend<{ consoleErrors: string[] }>({
  consoleErrors: async ({ page }, use) => {
    const errors: string[] = [];

    page.on("console", (msg) => {
      if (msg.type() === "error") {
        errors.push(msg.text());
      }
    });

    page.on("pageerror", (error) => {
      errors.push(error.message);
    });

    await use(errors);

    // Assert no console errors after each test
    expect(errors).toEqual([]);
  },
});

export { expect };
// e2e/app.spec.ts
import { test, expect } from "./fixtures";

test("home page has no console errors", async ({ page, consoleErrors }) => {
  await page.goto("/");
  await page.waitForLoadState("networkidle");
  // consoleErrors assertion happens automatically in fixture teardown
});

許容リスト(allowlist)で無害なエラーをフィルタする

上記の expect(errors).toEqual([]) というアサーションは、まっさらなアプリでは機能します。しかし実際のスイートはすぐに壁にぶつかります。たいていの場合、無害なエラーが必ず存在するのです。フレームワークの開発時警告、サードパーティSDKのノイズ、本来のランタイム外でグレースフルに失敗するアダプターなど。厳格な空配列アサーションは、それらすべてをレッドのテストに変えてしまいます。そして典型的な対処――文句が出なくなるまでチェックを緩める――は、リグレッションを捕捉するという価値そのものを捨て去ることになります。

解決策は、**精選された許容リスト(curated allowlist)**でフィルタする assertNoConsoleErrors() です。これを健全に保つための規律はこうです:許容リストのすべてのエントリは、なぜそのメッセージを無視してよいのかを正当化するwhyコメントを伴うこと。

// e2e/helpers.ts
import { expect } from "@playwright/test";

export function assertNoConsoleErrors(errors: string[]) {
  const unexpected = errors.filter((msg) => {
    // React DevTools install nag — dev-only, not an app error.
    if (msg.includes("Download the React DevTools")) return false;
    // Favicon 404 — the mock server has no favicon; harmless.
    if (msg.includes("Failed to load resource") && msg.includes("favicon")) return false;
    // Tauri listen() fails in browser/mock mode: @tauri-apps/api's transformCallback
    // is undefined outside the WebView runtime. The error is caught internally and
    // the mock adapter registers its own in-memory listeners instead.
    if (msg.includes("Failed to register Tauri event listener")) return false;
    // React warns on an iframe rendered with src="" — known v1 limitation of the
    // preview pane when no URL is seeded; the iframe renders harmlessly.
    if (msg.includes('An empty string ("") was passed to the %s attribute') && msg.includes("src")) {
      return false;
    }
    return true;
  });
  expect(
    unexpected,
    `Unexpected console errors:\n${unexpected.join("\n")}`,
  ).toHaveLength(0);
}

⚠️ Warning

各エントリのwhyコメントは官僚的な儀式ではなく、本質的に重要な部分です。理由がなければ、許容リストは知らぬ間に「すべてを無視する」リストへと腐っていきます。数か月後、誰もそのエントリが本物の既知問題を守っているのか、それとも本物のリグレッションを黙らせるために追加されたのかを覚えておらず、結果として「何も削除しない」が安全策になってしまうのです。1行の理由があれば、次に読む人は根本原因が修正されたその日にエントリを削除できます。許容リストが本来あるべき姿――膨らむのではなく縮む――になるのは、まさにそのときなのです。

CI画像インターセプトによる高速化

CIでは、大きな画像のネットワークリクエストがテストを遅くします。インターセプトして小さなプレースホルダーに置き換えます:

// e2e/fixtures.ts
export const test = base.extend({
  page: async ({ page }, use) => {
    // Intercept image requests in CI
    if (process.env.CI) {
      await page.route("**/*.{png,jpg,jpeg,webp,gif}", (route) => {
        route.fulfill({
          status: 200,
          contentType: "image/png",
          // 1x1 transparent PNG
          body: Buffer.from(
            "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
            "base64"
          ),
        });
      });
    }
    await use(page);
  },
});

📝 Note

zmodのこのパターンにより、画像アセットのネットワークレイテンシーが排除され、CI E2Eテスト時間が40%削減されました。

本番ビルド検証

devサーバーではなく、本番ビルドに対してテストを実行します。これによりビルド固有の問題をキャッチできます:

// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  webServer: {
    command: "npm run build && npm run preview",
    port: 4173,
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: "http://localhost:4173",
  },
});
// e2e/production.spec.ts
import { test, expect } from "@playwright/test";

test("production build serves all pages", async ({ page }) => {
  const urls = ["/", "/docs", "/about", "/contact"];
  for (const url of urls) {
    const response = await page.goto(url);
    expect(response?.status()).toBe(200);
  }
});

test("production build has no broken links", async ({ page }) => {
  await page.goto("/");
  const links = await page.locator("a[href^='/']").all();
  for (const link of links) {
    const href = await link.getAttribute("href");
    if (href) {
      const response = await page.goto(href);
      expect(response?.status()).toBe(200);
    }
  }
});

シャードCIラン

大規模なテストスイートの場合、複数のCIランナーにシャードします:

# .github/workflows/e2e.yml
jobs:
  e2e:
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      - uses: actions/checkout@v4
      - run: npx playwright install --with-deps
      - run: npx playwright test --shard=${{ matrix.shard }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report-${{ strategy.job-index }}
          path: playwright-report/

フロントエント専用E2Eのためのモックバックエンドアダプター

実際のバックエンドから独立してフロントエンドの動作をテストする場合:

// e2e/mocks/backend-adapter.ts
import { Page } from "@playwright/test";

export async function mockBackend(page: Page) {
  await page.route("**/api/**", async (route) => {
    const url = new URL(route.request().url());

    const mocks: Record<string, unknown> = {
      "/api/user": { id: 1, name: "Test User", email: "test@example.com" },
      "/api/settings": { theme: "dark", language: "en" },
      "/api/documents": [
        { id: 1, title: "Doc 1" },
        { id: 2, title: "Doc 2" },
      ],
    };

    const mockData = mocks[url.pathname];
    if (mockData) {
      await route.fulfill({
        status: 200,
        contentType: "application/json",
        body: JSON.stringify(mockData),
      });
    } else {
      await route.continue();
    }
  });
}
// e2e/frontend.spec.ts
import { test, expect } from "@playwright/test";
import { mockBackend } from "./mocks/backend-adapter";

test.beforeEach(async ({ page }) => {
  await mockBackend(page);
});

test("displays user name from mock API", async ({ page }) => {
  await page.goto("/dashboard");
  await expect(page.locator(".user-name")).toHaveText("Test User");
});

⚠️ Warning

モックバックエンドはフロントエント中心のテストには最適ですが、実際のAPIに対するインテグレーションテストの代わりにはなりません。両方を使用してください:UIの動作にはモック、データフローには実際のAPI。

Revision History