0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude CodeとPlaywrightでE2E/VRTテスト基盤をゼロから構築した備忘録

0
Posted at

はじめに

Claude Codeを活用しながら、アプリケーションのE2E/VRTテスト基盤をゼロから構築した。ローカル環境の構築からCI連携、Lintによる品質担保まで一通り整備したので、忘れないうちに全体像を残しておく。

すでにPlaywrightでのテスト経験がある人向けに、「基盤づくり」で工夫した点を中心にまとめる。

注記: 記事中のコード(docker-compose.yml、ビルドスクリプト、ワークフロー、Page Object、fixtureなど)はすべて説明用に単純化した例であり、実際に運用しているコードそのものではない。構成や考え方を伝えるためのサンプルとしてご了承いただきたい。

Claude Codeとの進め方

今回の基盤構築は、最初から完成形を自分で設計したわけではなく、Claude Codeとやり取りしながら形にしていった。基本的な流れは以下の通り。

  1. 「こういう構成にしたい」「こういう課題を解決したい」という実現したいことをClaude Codeに投げる
  2. docker-compose構成やビルドスクリプト、CIワークフローなどのたたき台を提案してもらう
  3. 提案内容を自分たちのリポジトリ構成やルールに合わせてブラッシュアップ
  4. 問題なければそのまま実装、気になる点があれば再度Claude Codeに相談して調整

ゼロから自分で叩き台を書く手間が省けるうえ、複数の実現方法(例: キャッシュの持たせ方、fixtureの設計パターンなど)を提案してもらった上で選べるのがやりやすかった。ただし、提案をそのまま採用するのではなく、リポジトリの既存ルールや運用に合っているかは都度確認しながら進めている。

全体構成

大まかな構成は以下の通り。

  • アプリ本体とPlaywrightをそれぞれ別のDockerコンテナで起動
  • テスト実行に必要なセットアップ(ビルド・マイグレーション・SEED投入)はPlaywright側のリポジトリで一元管理
  • GitHub ActionsによるCI(アプリ側からのリポジトリディスパッチにも対応)
  • ESLintでテストコードの品質をルール化
  • E2E軸・VRT(Visual Regression Test)軸でディレクトリとCIワークフローを分離
  • Page Object Model(POM)を採用し、fixtureで注入する形にして呼び出しをシンプルに
  • ワーカーごとにログイン状態をストレージへ保存し使い回す
  • CIはキャッシュ・GHCRを活用し実行時間を短縮(ビルドスクリプトは共通化し、イメージビルド部分のみCI時に分岐)
repo-app/            # アプリ本体
repo-playwright/      # E2E/VRTテスト一式
  ├── docker-compose.yml
  ├── scripts/
  │   └── build.sh
  ├── tests/
  │   ├── e2e/
  │   └── vrt/
  ├── pages/          # Page Objectモデル
  ├── .auth/          # ワーカー別ログイン状態の保存先
  └── .github/workflows/

アプリ側とテスト側のリポジトリを分けることで、テスト基盤の変更がアプリのビルドやデプロイに影響しないようにしている。

ローカル環境の構築

docker-compose構成

アプリ・Playwright・DBをそれぞれ別コンテナで起動する。DBはテスト専用のコンテナを用意し、既存の開発用DBと混ざらないようにした。

# docker-compose.yml
services:
  app:
    build: ../repo-app
    ports:
      - "3000:3000"
    depends_on:
      - db
    volumes:
      - ../repo-app:/app
    environment:
      DATABASE_URL: postgres://test:test@db:5432/test_db

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: test_db
    volumes:
      - db_data:/var/lib/postgresql/data

  playwright:
    build: .
    depends_on:
      - app
    volumes:
      - ./tests:/tests
      - ./playwright-report:/playwright-report
    working_dir: /tests

volumes:
  db_data:

一部のディレクトリ(テストコードやレポート出力先)はボリュームマウントし、コンテナ再起動なしで編集・確認できるようにしている。

ビルドスクリプト

「起動してからテストが動くまで」に必要な処理を1本のスクリプトにまとめ、誰が実行しても同じ手順で再現できるようにした。

#!/bin/bash
# scripts/build.sh
set -eu

echo "1. フロントエンドビルド"
docker compose exec app npm run build

echo "2. DBマイグレーション"
docker compose exec app npm run migrate

echo "3. SEED投入"
docker compose exec app npm run seed

echo "4. アプリ & Playwright起動確認"
if [ "${CI:-false}" = "true" ]; then
  # CI実行時のみGHCRのキャッシュを使ってPlaywrightイメージをビルド
  docker buildx build \
    --cache-from type=registry,ref=ghcr.io/org/repo-playwright:cache \
    --cache-to type=registry,ref=ghcr.io/org/repo-playwright:cache,mode=max \
    -t ghcr.io/org/repo-playwright:latest .
else
  docker compose build playwright
fi
docker compose up -d app playwright
./scripts/wait-for-app.sh http://localhost:3000

echo "5. テスト実行"
docker compose exec playwright npx playwright test

「ビルド→マイグレーション→SEED→起動→テスト実行」を1本のスクリプトに集約し、ローカルでもCIでもこのbuild.shをそのまま使い回している。手順書としてREADMEに書くだけでなく、実行可能なスクリプトとして残すことで属人化を防ぐ狙いもある。

処理のほとんどはローカル・CIで完全に共通だが、Playwrightイメージのビルド部分だけはCI環境変数の有無で分岐させ、CI実行時のみGHCRのキャッシュを使う形にしている(詳細は後述)。マイグレーションやSEED投入はこの分岐の影響を受けず、常に同じ処理が走る。

なお、マイグレーションだけはテスト用に独自で用意したものではなく、アプリ側リポジトリに元々あるnpm run migrateをそのまま呼び出している。SEED投入はテスト実行のために別途用意したものだが、マイグレーションに関してはアプリの正しいスキーマ管理をそのまま使う形にしている。

なぜローカルでテストしているか

環境をどこに置くかは最初に迷ったポイント。ステージングのような構築済み環境を使う案もあったが、以下の理由でまずはローカル(Docker)を選んだ。

  • データの安定性を優先した: ステージング環境は他の作業者やバッチ処理などでデータが変動しやすく、テストに使うデータを完全にコントロールするのが難しい。ローカルであればSEEDから毎回同じ状態を作り直せるため、テストの再現性・安定性を確保しやすい
  • 環境構築・依存関係の管理もリポジトリ内で完結させたかった

ただし、ローカル完結の構成には限界もある。本番相当のインフラ構成やネットワーク条件に依存する検証はローカルでは難しいため、将来的には専用のテスト環境向けのテストも検討の余地があると考えている。現状は「まずデータ制御可能な環境で安定して回せること」を優先した結果、という位置づけ。

CI(GitHub Actions)

CIもPlaywright側のリポジトリでワークフローを管理している。E2EとVRTでジョブ(ワークフロー)を分割し、それぞれ独立して実行・再実行できるようにした。

# .github/workflows/e2e.yml
name: E2E Tests

on:
  pull_request:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Docker Composeで環境構築
        run: docker compose up -d --build

      - name: セットアップ & テスト実行
        run: ./scripts/build.sh

      - name: レポートをArtifactとして保存
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/

VRT用のワークフローも基本構造は同じだが、スクリーンショット差分の許容閾値やベースライン画像の管理(Artifact保存/更新フロー)が別途必要になるため、あえてファイルを分けている。ジョブを分けたことで、「VRTだけ落ちている」「E2Eだけ落ちている」がひと目で分かるようになった。

アプリ側からのリポジトリディスパッチ実行

Playwright側のリポジトリだけでなく、アプリ側のリポジトリからもrepository_dispatchイベント経由でE2E/VRTを起動できるようにしている。リリース前のPRやコメントコマンドをトリガーに、テストリポジトリのCIを呼び出す形。

# repo-app 側: リリース前PRやコメントから呼び出す
name: Trigger E2E

on:
  issue_comment:
    types: [created]

jobs:
  dispatch:
    if: github.event.comment.body == '/run-e2e'
    runs-on: ubuntu-latest
    steps:
      - name: repo-playwrightへdispatch
        run: |
          curl -X POST \
            -H "Authorization: token ${{ secrets.DISPATCH_TOKEN }}" \
            -H "Accept: application/vnd.github.v3+json" \
            https://api.github.com/repos/org/repo-playwright/dispatches \
            -d '{"event_type":"run-e2e","client_payload":{"ref":"${{ github.event.issue.pull_request.head.ref }}"}}'
# repo-playwright 側: dispatchイベントを受けて実行
on:
  repository_dispatch:
    types: [run-e2e]

こうしておくと、アプリ側の開発者がテストリポジトリを意識せずに「PRコメントを打つだけ」でE2Eを回せる。リリース前の最終確認をアプリ側のワークフローに乗せられるのが地味に便利。

CIの実行時間短縮(キャッシュ・GHCR活用)

CIを回すたびに毎回イメージをフルビルドしていると実行時間がかさむため、キャッシュとGHCR(GitHub Container Registry)を使ってビルド済みイメージ・依存関係を再利用する方向にしている。

# .github/workflows/e2e.yml (抜粋)
jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: GHCRにログイン
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: node_modulesをキャッシュ
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: npm-${{ hashFiles('**/package-lock.json') }}

      - name: セットアップ & テスト実行
        run: ./scripts/build.sh

scripts/build.sh自体はローカル・CIで共通のものを使っており、別ファイルに分けているわけではない。前段の「ビルドスクリプト」で触れた通り、スクリプト内でPlaywrightイメージのビルド部分だけをCI環境変数の有無で分岐させ、CI実行時のみGHCRのレイヤーキャッシュ(--cache-from/--cache-to)を使う形にしている。GitHub Actionsのジョブ内ではCI=trueが自動的に設定されるため、追加の設定なしにこの分岐が効く。

ローカル環境ではこのキャッシュ機構を使う必要はなく、むしろ手元では素直にdocker compose buildでイメージを作り直したいケースが多いため、この部分だけ分岐させている。それ以外のマイグレーション・SEED投入・テスト実行などはローカル・CIで完全に同じ処理が走る。

Lintによる品質担保

テストコードもプロダクトコードと同様に品質を保つ必要があると考え、ESLintでルールを設けている。

  • テストケースにタグ(@smoke, @regression など)の付与を必須化
  • Page Objectのメソッドに対するJSDocコメントを必須化
// eslint.config.js (抜粋イメージ)
export default [
  {
    rules: {
      "playwright/no-standalone-expect": "error",
      "custom/require-test-tag": "error",     // 独自ルール: test.describeへのタグ必須
      "custom/require-method-jsdoc": "error", // 独自ルール: Page Objectメソッドへのjsdoc必須
    },
  },
];
// pages/LoginPage.ts
export class LoginPage {
  constructor(private readonly page: Page) {}

  /**
   * ログインフォームにメールアドレスとパスワードを入力し、ログインを実行する
   * @param email ログインに使用するメールアドレス
   * @param password ログインに使用するパスワード
   */
  async login(email: string, password: string) {
    await this.page.getByLabel("メールアドレス").fill(email);
    await this.page.getByLabel("パスワード").fill(password);
    await this.page.getByRole("button", { name: "ログイン" }).click();
  }
}

タグを必須にしたのは、CIで「スモークテストだけ回す」「回帰テストだけ回す」といった実行対象の絞り込みをしやすくするため。JSDoc必須化は、Page Objectのメソッドが増えてきた際に「これは何をするメソッドか」をコードジャンプ・ホバー時点で把握できるようにする狙いがある。

テスト設計:E2E軸とVRT軸の分離

E2EテストとVRTでは「見るべきもの」が異なるため、ディレクトリとCIワークフローの両方を分離して運用している。

tests/
├── e2e/
│   └── login.spec.ts       # 機能・業務フローの検証
└── vrt/
    └── top-page.spec.ts    # 見た目の差分検証
  • E2E軸: ユーザー操作の一連の流れ(ログイン→検索→購入、など)が正しく動くかを検証
  • VRT軸: レイアウト崩れやスタイル崩れがないかをスクリーンショット比較で検証

同じPlaywrightを使っていても目的が異なるため、実行タイミング(PRごと/リリース前のみ、など)や失敗時の扱い(即ブロックするか/後で確認するか)も分けたほうが運用しやすいと感じている。

Page Object Modelの採用

要素のセレクタや操作をテストコードに直書きせず、ページ単位のクラスに切り出している。

// tests/e2e/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../../pages/LoginPage";

test.describe("ログイン機能 @smoke", () => {
  test("正しい認証情報でログインできる", async ({ page }) => {
    const loginPage = new LoginPage(page);
    await page.goto("/login");
    await loginPage.login("user@example.com", "password123");

    await expect(page.getByText("マイページ")).toBeVisible();
  });
});

UIの変更でセレクタが変わっても、修正箇所をPage Object側に閉じ込められるため、テストケース側の可読性・保守性が上がった。

fixtureでPage Objectを注入する

テストケース側で毎回new LoginPage(page)のように書くのは地味に手間だし書き忘れも起きるので、Playwrightのfixtureを拡張してpom.loginのような形でそのまま使えるようにしている。

// fixtures.ts
import { test as base } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";
import { TopPage } from "./pages/TopPage";

type Pom = {
  login: LoginPage;
  top: TopPage;
};

export const test = base.extend<{ pom: Pom }>({
  pom: async ({ page }, use) => {
    await use({
      login: new LoginPage(page),
      top: new TopPage(page),
    });
  },
});

export { expect } from "@playwright/test";
// tests/e2e/login.spec.ts
import { test, expect } from "../../fixtures";

test.describe("ログイン機能 @smoke", () => {
  test("正しい認証情報でログインできる", async ({ page, pom }) => {
    await page.goto("/login");
    await pom.login.login("user@example.com", "password123");

    await expect(page.getByText("マイページ")).toBeVisible();
  });
});

Page Objectのインスタンス化をfixture側に寄せたことで、テストケース側はpom.loginpom.topのように呼ぶだけで済むようになった。Page Objectが増えてもfixtures.tsに追加するだけでよく、テストコード側の記述はシンプルなまま保てている。

ワーカー別にログイン状態を保存する

Playwrightは複数ワーカーで並列実行するため、毎回ログイン処理から始めるとその分実行時間が伸びる。そこでstorageStateを使い、ワーカーごとにログイン済みの状態をファイルへ保存し、以降のテストではそれを読み込む形にしている。

// global-setup.ts
import { chromium, FullConfig } from "@playwright/test";

async function globalSetup(config: FullConfig) {
  const { workers } = config;

  for (let i = 0; i < workers; i++) {
    const browser = await chromium.launch();
    const page = await browser.newPage();

    await page.goto("http://localhost:3000/login");
    await page.getByLabel("メールアドレス").fill(`user${i}@example.com`);
    await page.getByLabel("パスワード").fill("password123");
    await page.getByRole("button", { name: "ログイン" }).click();

    await page.context().storageState({ path: `.auth/worker-${i}.json` });
    await browser.close();
  }
}

export default globalSetup;
// playwright.config.ts (抜粋)
export default defineConfig({
  globalSetup: "./global-setup.ts",
  use: {
    storageState: process.env.STORAGE_STATE ?? ".auth/worker-0.json",
  },
});

各テストの実行前に都度ログインさせる必要がなくなり、実行時間の短縮につながっている。テスト間でユーザーやセッションが混ざらないよう、ワーカーごとに別アカウント・別ストレージファイルを用意しているのがポイント。

現在の取り組み

現時点では、UIテストに特化したワークフローを構築するかどうかを検討しつつ、実装対象のテストケースを洗い出して計画・タスク化し、それに沿って実装を進めている段階。「思いついたところから書く」のではなく、対象機能ごとにテストケースを一覧化してから着手することで、抜け漏れを減らし、レビューもしやすくなっている。

まとめ

  • アプリとPlaywrightをそれぞれDockerで独立して起動できる環境を構築
  • セットアップ手順をビルドスクリプトとして1本化し、ローカル・CIで共通化
  • ローカルでテストしている理由は、データ制御のしやすさ・安定性を優先したため(専用環境向けテストは今後の検討課題)
  • GitHub ActionsでE2E/VRTを別ワークフローとして運用し、アプリ側からのリポジトリディスパッチにも対応
  • CIはキャッシュ・GHCRを活用して実行時間を短縮しつつ、ビルドスクリプト自体はローカル・CIで共通化(イメージビルド部分のみCI時に分岐)
  • Lintでタグ・JSDocを必須化し、テストコードの品質と可読性を担保
  • Page Object Modelでセレクタ変更に強い構成にし、fixtureでpom.loginのように呼び出せる形に
  • ワーカー別にログイン状態をストレージへ保存し、実行時間を短縮

基盤づくりの部分に工数がかかった分、テストケース自体の追加・改修はかなりスムーズに行えるようになった。今後、テストケースが増えてきた際の運用(実行時間の最適化、フレーキーテストの扱いなど)についても、機会があれば別記事でまとめたい。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?