T-CREATOR

Playwright でスクリーンショット・動画自動取得を使い倒す

Playwright でスクリーンショット・動画自動取得を使い倒す

皆さんは、Web アプリケーションのテストや監視を行う際に、「何が起こったのか分からない」という経験はありませんか? エラーが発生した瞬間のスクリーンショットや、ユーザー操作の一連の流れを動画で記録できたら、どれほど開発効率が向上するでしょうか。

実は、Playwright を使えば、これらの悩みを一気に解決できるのです。今回は、Playwright のスクリーンショット・動画自動取得機能を徹底的に掘り下げて、実際のプロジェクトで即座に活用できるテクニックをご紹介していきます。

背景

現代の Web アプリケーション開発では、UI の複雑化とユーザー体験の向上が求められています。しかし、その分テストやデバッグも難しくなってきました。

従来の手動テストでは、バグが発生した瞬間の状況を正確に把握することが困難でした。また、CI/CD パイプラインでテストが失敗した際も、ログだけでは原因特定に時間がかかってしまいます。

課題

開発現場でよく直面する課題をまとめてみましょう。

課題影響従来の対処法の限界
テスト失敗時の状況把握困難デバッグ時間の増大ログのみでは視覚的な情報が不足
本番環境での問題再現が困難顧客満足度の低下エラー報告のみでは詳細が不明
パフォーマンス問題の可視化不足最適化の遅れ数値データのみでは改善点が分からない
複数ブラウザでの挙動差異互換性問題手動確認では工数が膨大

解決策

Playwright の視覚的キャプチャ機能を活用することで、これらの課題を効率的に解決できます。具体的には以下のような機能を活用していきます。

  • 自動スクリーンショット取得: テスト実行時の画面状態を自動保存
  • 動画録画機能: ユーザー操作の一連の流れを動画で記録
  • 要素単位でのキャプチャ: 特定の要素のみを切り出して保存
  • 失敗時の自動キャプチャ: エラー発生時の状況を確実に記録

Playwright 視覚的キャプチャ機能の全体像

Playwright が提供する視覚的キャプチャ機能は、大きく分けて 2 つのカテゴリに分類されます。まず、その全体像を把握していきましょう。

キャプチャ機能の分類

Playwright のキャプチャ機能は以下のように整理できます。

機能カテゴリ主要メソッド用途出力形式
スクリーンショットpage.screenshot()ページ全体のキャプチャPNG, JPEG
要素キャプチャlocator.screenshot()特定要素のキャプチャPNG, JPEG
動画録画BrowserContext video 設定操作の動画記録WebM
モバイルシミュレートデバイス設定 + キャプチャレスポンシブ確認PNG, JPEG, WebM

基本的な設定方法

まずは、Playwright でキャプチャ機能を使用するための基本設定を見ていきましょう。

以下のコードは、Playwright プロジェクトでキャプチャ機能を有効にする基本的な設定です。

typescriptimport { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  // テストディレクトリの指定
  testDir: './tests',

  // スクリーンショットの設定
  use: {
    // 失敗時に自動でスクリーンショットを取得
    screenshot: 'only-on-failure',

    // 動画録画の設定(失敗時のみ)
    video: 'retain-on-failure',

    // トレース機能の有効化
    trace: 'retain-on-failure',
  },

  // プロジェクト別の設定
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
});

この設定により、テストが失敗した際に自動的にスクリーンショットと動画が保存されるようになります。

ディレクトリ構造の最適化

キャプチャファイルを効率的に管理するために、以下のようなディレクトリ構造を推奨します。

bashproject-root/
├── tests/
│   ├── screenshots/
│   │   ├── actual/          # 実際のスクリーンショット
│   │   ├── expected/        # 期待値のスクリーンショット
│   │   └── diff/           # 差分画像
│   ├── videos/
│   │   ├── passed/         # 成功時の動画
│   │   └── failed/         # 失敗時の動画
│   └── traces/             # トレースファイル
└── playwright.config.ts

このような構造にすることで、キャプチャファイルの整理が格段に楽になります。

スクリーンショット機能を極める

ここからは、Playwright のスクリーンショット機能について詳しく解説していきます。単純にスクリーンショットを撮るだけでなく、様々なオプションを駆使して、より効果的なキャプチャを実現しましょう。

page.screenshot()の全オプション解説

page.screenshot()メソッドには、多くのオプションが用意されています。それぞれの機能を詳しく見ていきましょう。

以下のコードは、page.screenshot()の主要なオプションを網羅した例です。

typescriptimport { test, expect } from '@playwright/test';

test('スクリーンショットの詳細オプション', async ({
  page,
}) => {
  await page.goto('https://example.com');

  // 基本的なフルページスクリーンショット
  await page.screenshot({
    path: 'screenshots/full-page.png',
    fullPage: true, // ページ全体をキャプチャ
    type: 'png', // 画像形式(png, jpeg)
    quality: 90, // JPEG品質(1-100)
  });

  // ビューポートのみのキャプチャ
  await page.screenshot({
    path: 'screenshots/viewport-only.png',
    fullPage: false, // ビューポートのみ
  });

  // 特定領域のクリップキャプチャ
  await page.screenshot({
    path: 'screenshots/clipped-area.png',
    clip: {
      x: 100, // 開始X座標
      y: 100, // 開始Y座標
      width: 800, //
      height: 600, // 高さ
    },
  });
});

マスキングとアニメーション制御

実際のテストでは、動的なコンテンツや個人情報を含む要素を隠したい場合があります。Playwright では、これらを簡単にマスキングできます。

以下のコードは、マスキング機能とアニメーション制御の例です。

typescripttest('マスキングとアニメーション制御', async ({ page }) => {
  await page.goto('https://example.com/dashboard');

  // 個人情報をマスキングしてスクリーンショット
  await page.screenshot({
    path: 'screenshots/masked-content.png',
    fullPage: true,

    // 特定要素をマスクする
    mask: [
      page.locator('.user-email'), // メールアドレスを隠す
      page.locator('.api-key'), // APIキーを隠す
      page.locator('[data-sensitive]'), // data属性で指定された要素
    ],

    // アニメーションを無効化
    animations: 'disabled',
  });

  // CSSアニメーションの制御
  await page.addStyleTag({
    content: `
      *, *::before, *::after {
        animation-duration: 0s !important;
        animation-delay: 0s !important;
        transition-duration: 0s !important;
        transition-delay: 0s !important;
      }
    `,
  });
});

locator.screenshot()での要素キャプチャ

ページ全体ではなく、特定の要素のみをキャプチャしたい場合は、locator.screenshot()を使用します。

以下のコードは、要素単位でのスクリーンショット取得の例です。

typescripttest('要素別スクリーンショット', async ({ page }) => {
  await page.goto('https://example.com/form');

  // ヘッダー要素のみをキャプチャ
  const header = page.locator('header');
  await header.screenshot({
    path: 'screenshots/header-component.png',
  });

  // フォーム要素のキャプチャ(エラー状態)
  const form = page.locator('#contact-form');

  // エラー状態を発生させる
  await page.fill('#email', 'invalid-email');
  await page.click('#submit');

  // エラー状態のフォームをキャプチャ
  await form.screenshot({
    path: 'screenshots/form-error-state.png',
  });

  // 特定のボタンをキャプチャ
  const submitButton = page.locator('#submit');
  await submitButton.screenshot({
    path: 'screenshots/submit-button.png',
  });
});

エラーハンドリングとリトライ機能

スクリーンショット取得時によく発生するエラーと、その対処法について解説します。

typescripttest('スクリーンショットのエラーハンドリング', async ({
  page,
}) => {
  try {
    await page.goto('https://example.com');

    // 要素が存在するまで待機してからスクリーンショット
    const targetElement = page.locator('#dynamic-content');
    await targetElement.waitFor({
      state: 'visible',
      timeout: 10000,
    });

    await targetElement.screenshot({
      path: 'screenshots/dynamic-content.png',
      timeout: 5000, // スクリーンショット取得のタイムアウト
    });
  } catch (error) {
    console.error('スクリーンショット取得エラー:', error);

    // エラー時のフォールバック処理
    await page.screenshot({
      path: 'screenshots/error-fallback.png',
      fullPage: true,
    });

    throw error;
  }
});

よく発生するエラーとその解決策をまとめました。

エラーメッセージ原因解決策
Target page, context or browser has been closedページが既に閉じられているページの状態を確認してからキャプチャ
Timeout 30000ms exceeded要素の読み込みが完了していないwaitFor()で要素の表示を待つ
Element is not visible要素が画面外にあるscrollIntoViewIfNeeded()で要素を表示領域に移動

動画録画機能の詳細活用

続いて、Playwright の動画録画機能について詳しく解説していきます。動画録画は、ユーザー操作の流れを時系列で把握できる非常に強力な機能です。

BrowserContext での録画設定

動画録画は、BrowserContext レベルで設定を行います。以下のコードで、録画の詳細設定を見ていきましょう。

typescriptimport { test, chromium } from '@playwright/test';

test('動画録画の詳細設定', async () => {
  // ブラウザを手動で起動
  const browser = await chromium.launch();

  // コンテキストに録画設定を適用
  const context = await browser.newContext({
    // 録画設定
    recordVideo: {
      dir: 'videos/', // 保存ディレクトリ
      size: { width: 1280, height: 720 }, // 動画サイズ
    },

    // ビューポートサイズ(録画サイズと合わせる)
    viewport: { width: 1280, height: 720 },
  });

  const page = await context.newPage();

  // テスト操作
  await page.goto('https://example.com');
  await page.click('#menu-button');
  await page.fill('#search', 'Playwright');
  await page.click('#search-submit');

  // ページを閉じる(この時点で録画が停止される)
  await page.close();
  await context.close();
  await browser.close();
});

録画品質とコーデックの選択

動画の品質とファイルサイズのバランスを調整するために、様々な設定オプションが用意されています。

typescripttest('録画品質の最適化', async () => {
  const browser = await chromium.launch();

  // 高品質設定(ファイルサイズ大)
  const highQualityContext = await browser.newContext({
    recordVideo: {
      dir: 'videos/high-quality/',
      size: { width: 1920, height: 1080 },
    },
  });

  // 標準品質設定(バランス重視)
  const standardContext = await browser.newContext({
    recordVideo: {
      dir: 'videos/standard/',
      size: { width: 1280, height: 720 },
    },
  });

  // 低品質設定(ファイルサイズ小)
  const lowQualityContext = await browser.newContext({
    recordVideo: {
      dir: 'videos/low-quality/',
      size: { width: 854, height: 480 },
    },
  });

  // それぞれの設定でテストを実行
  const highQualityPage =
    await highQualityContext.newPage();
  const standardPage = await standardContext.newPage();
  const lowQualityPage = await lowQualityContext.newPage();

  // 同じ操作を並行実行
  await Promise.all([
    performTestOperations(highQualityPage),
    performTestOperations(standardPage),
    performTestOperations(lowQualityPage),
  ]);

  // クリーンアップ
  await highQualityContext.close();
  await standardContext.close();
  await lowQualityContext.close();
  await browser.close();
});

async function performTestOperations(page) {
  await page.goto('https://example.com');
  await page.waitForTimeout(1000);
  await page.click('#navigation');
  await page.waitForTimeout(500);
  await page.fill('#input', 'test data');
  await page.click('#submit');
  await page.waitForLoadState('networkidle');
}

録画開始・停止のタイミング制御

特定のタイミングでのみ録画を行いたい場合の制御方法を見てみましょう。

typescripttest('録画タイミングの制御', async ({ browser }) => {
  const context = await browser.newContext({
    recordVideo: {
      dir: 'videos/controlled/',
      size: { width: 1280, height: 720 },
    },
  });

  const page = await context.newPage();

  // 初期設定(録画はまだ開始されていない)
  await page.goto('https://example.com');
  await page.waitForLoadState('networkidle');

  // 重要な操作の前に録画開始をマーク
  console.log('重要な操作を開始します');

  // 複雑なユーザーフローを実行
  await page.click('#start-complex-flow');
  await page.waitForSelector('#step1', {
    state: 'visible',
  });

  await page.fill('#form-field1', 'データ1');
  await page.click('#next-step');

  await page.waitForSelector('#step2', {
    state: 'visible',
  });
  await page.fill('#form-field2', 'データ2');
  await page.click('#next-step');

  await page.waitForSelector('#confirmation', {
    state: 'visible',
  });
  await page.click('#confirm');

  // 録画終了をマーク
  console.log('重要な操作が完了しました');

  // 動画ファイルのパスを取得
  const videoPath = await page.video()?.path();
  console.log('録画ファイル:', videoPath);

  await context.close();
});

自動化スクリプトの実装パターン

実際のプロジェクトでキャプチャ機能を活用するための、実践的な実装パターンをご紹介します。

テスト失敗時の自動キャプチャ

テストが失敗した際に、詳細な情報を自動収集するヘルパー関数を作成しましょう。

typescriptimport { test, expect } from '@playwright/test';
import { Page } from '@playwright/test';

// 失敗時の詳細情報収集関数
async function captureFailureDetails(
  page: Page,
  testName: string
) {
  const timestamp = new Date()
    .toISOString()
    .replace(/[:.]/g, '-');
  const baseDir = `failure-captures/${testName}/${timestamp}`;

  try {
    // フルページスクリーンショット
    await page.screenshot({
      path: `${baseDir}/full-page.png`,
      fullPage: true,
    });

    // ビューポートスクリーンショット
    await page.screenshot({
      path: `${baseDir}/viewport.png`,
      fullPage: false,
    });

    // ページのHTMLソースを保存
    const htmlContent = await page.content();
    require('fs').writeFileSync(
      `${baseDir}/page-source.html`,
      htmlContent
    );

    // ブラウザのコンソールログを保存
    const consoleLogs = await page.evaluate(() => {
      return (window as any).capturedLogs || [];
    });
    require('fs').writeFileSync(
      `${baseDir}/console-logs.json`,
      JSON.stringify(consoleLogs, null, 2)
    );

    console.log(
      `失敗時の詳細情報を保存しました: ${baseDir}`
    );
  } catch (error) {
    console.error('キャプチャ中にエラーが発生:', error);
  }
}

// テストでの使用例
test('複雑な操作フローのテスト', async ({ page }) => {
  try {
    await page.goto('https://example.com/complex-form');

    // 複雑な操作を実行
    await page.fill('#step1-input', 'データ1');
    await page.click('#step1-next');

    await expect(page.locator('#step2')).toBeVisible();
    await page.fill('#step2-input', 'データ2');
    await page.click('#step2-next');

    // 最終確認
    await expect(
      page.locator('#success-message')
    ).toBeVisible();
  } catch (error) {
    // テスト失敗時に詳細情報を収集
    await captureFailureDetails(
      page,
      'complex-operation-flow'
    );
    throw error;
  }
});

定期実行でのモニタリング

本番環境の監視目的で、定期的にスクリーンショットを取得するスクリプトの例です。

typescriptimport { chromium } from '@playwright/test';
import * as fs from 'fs';

// 監視対象サイトの設定
interface MonitoringTarget {
  name: string;
  url: string;
  selectors: string[];
}

const targets: MonitoringTarget[] = [
  {
    name: 'homepage',
    url: 'https://example.com',
    selectors: ['header', 'main', 'footer'],
  },
  {
    name: 'dashboard',
    url: 'https://example.com/dashboard',
    selectors: ['.metrics-panel', '.chart-container'],
  },
];

async function performMonitoring() {
  const browser = await chromium.launch();
  const timestamp = new Date()
    .toISOString()
    .replace(/[:.]/g, '-');

  for (const target of targets) {
    console.log(`監視開始: ${target.name}`);

    const context = await browser.newContext({
      recordVideo: {
        dir: `monitoring/videos/${target.name}/${timestamp}`,
        size: { width: 1280, height: 720 },
      },
    });

    const page = await context.newPage();

    try {
      // ページに移動
      await page.goto(target.url, {
        waitUntil: 'networkidle',
      });

      // 全体のスクリーンショット
      await page.screenshot({
        path: `monitoring/screenshots/${target.name}/${timestamp}/full-page.png`,
        fullPage: true,
      });

      // 重要な要素のスクリーンショット
      for (const selector of target.selectors) {
        try {
          const element = page.locator(selector);
          await element.screenshot({
            path: `monitoring/screenshots/${
              target.name
            }/${timestamp}/${selector.replace(
              /[^a-zA-Z0-9]/g,
              '_'
            )}.png`,
          });
        } catch (error) {
          console.error(
            `要素 ${selector} のキャプチャに失敗:`,
            error
          );
        }
      }

      // パフォーマンス情報を収集
      const performanceMetrics = await page.evaluate(() => {
        const navigation = performance.getEntriesByType(
          'navigation'
        )[0] as PerformanceNavigationTiming;
        return {
          loadTime:
            navigation.loadEventEnd -
            navigation.loadEventStart,
          domContentLoaded:
            navigation.domContentLoadedEventEnd -
            navigation.domContentLoadedEventStart,
          firstContentfulPaint:
            (
              performance.getEntriesByName(
                'first-contentful-paint'
              )[0] as any
            )?.startTime || 0,
        };
      });

      // メトリクスを保存
      fs.writeFileSync(
        `monitoring/metrics/${target.name}/${timestamp}/performance.json`,
        JSON.stringify(performanceMetrics, null, 2)
      );
    } catch (error) {
      console.error(`監視エラー (${target.name}):`, error);

      // エラー時のスクリーンショット
      await page.screenshot({
        path: `monitoring/screenshots/${target.name}/${timestamp}/error.png`,
        fullPage: true,
      });
    }

    await context.close();
  }

  await browser.close();
  console.log('監視完了');
}

// 定期実行の設定
setInterval(performMonitoring, 5 * 60 * 1000); // 5分間隔
performMonitoring(); // 初回実行

複数ブラウザでの並列キャプチャ

異なるブラウザでの動作確認を並列で実行するパターンです。

typescriptimport {
  chromium,
  firefox,
  webkit,
} from '@playwright/test';

async function crossBrowserCapture() {
  const browsers = [
    { name: 'chromium', engine: chromium },
    { name: 'firefox', engine: firefox },
    { name: 'webkit', engine: webkit },
  ];

  const timestamp = new Date()
    .toISOString()
    .replace(/[:.]/g, '-');

  // 並列でブラウザテストを実行
  await Promise.all(
    browsers.map(async (browserInfo) => {
      const browser = await browserInfo.engine.launch();
      const context = await browser.newContext({
        recordVideo: {
          dir: `cross-browser/${browserInfo.name}/${timestamp}`,
          size: { width: 1280, height: 720 },
        },
      });

      const page = await context.newPage();

      try {
        // 共通のテストシナリオを実行
        await page.goto('https://example.com');

        // ブラウザ別のスクリーンショット
        await page.screenshot({
          path: `cross-browser/${browserInfo.name}/${timestamp}/homepage.png`,
          fullPage: true,
        });

        // インタラクションのテスト
        await page.click('#menu-toggle');
        await page.waitForTimeout(500);

        await page.screenshot({
          path: `cross-browser/${browserInfo.name}/${timestamp}/menu-open.png`,
          fullPage: true,
        });

        // フォームのテスト
        await page.fill('#search-input', 'test query');
        await page.click('#search-button');
        await page.waitForLoadState('networkidle');

        await page.screenshot({
          path: `cross-browser/${browserInfo.name}/${timestamp}/search-results.png`,
          fullPage: true,
        });
      } catch (error) {
        console.error(
          `${browserInfo.name} でエラー:`,
          error
        );
      }

      await context.close();
      await browser.close();
    })
  );

  console.log('クロスブラウザテストが完了しました');
}

crossBrowserCapture();

ファイル管理とワークフロー最適化

キャプチャファイルが増加すると、ディスク容量やファイル管理が課題になります。効率的な管理方法をご紹介します。

ディレクトリ構造とファイル命名規則

まず、拡張性のあるディレクトリ構造を設計しましょう。

typescriptimport * as fs from 'fs';
import * as path from 'path';

class CaptureFileManager {
  private baseDir: string;

  constructor(baseDir: string = 'captures') {
    this.baseDir = baseDir;
    this.initializeDirectories();
  }

  private initializeDirectories() {
    const directories = [
      'screenshots/daily',
      'screenshots/test-results',
      'screenshots/monitoring',
      'videos/test-failures',
      'videos/user-scenarios',
      'traces',
      'archives',
    ];

    directories.forEach((dir) => {
      const fullPath = path.join(this.baseDir, dir);
      if (!fs.existsSync(fullPath)) {
        fs.mkdirSync(fullPath, { recursive: true });
      }
    });
  }

  // ファイル名の生成(一意性を保証)
  generateFilename(
    type: 'screenshot' | 'video' | 'trace',
    category: string,
    description: string
  ): string {
    const timestamp = new Date()
      .toISOString()
      .replace(/[:.]/g, '-');
    const randomId = Math.random()
      .toString(36)
      .substring(2, 8);

    return `${timestamp}_${category}_${description}_${randomId}`;
  }

  // ファイルパスの生成
  getFilePath(
    type: 'screenshot' | 'video' | 'trace',
    category: string,
    filename: string
  ): string {
    const extension =
      type === 'video'
        ? 'webm'
        : type === 'trace'
        ? 'zip'
        : 'png';
    const subDir =
      type === 'screenshot'
        ? 'screenshots'
        : type === 'video'
        ? 'videos'
        : 'traces';

    return path.join(
      this.baseDir,
      subDir,
      category,
      `${filename}.${extension}`
    );
  }
}

// 使用例
const fileManager = new CaptureFileManager();

// テストでの使用
test('ファイル管理システムの使用例', async ({ page }) => {
  await page.goto('https://example.com');

  const filename = fileManager.generateFilename(
    'screenshot',
    'daily',
    'homepage'
  );
  const filePath = fileManager.getFilePath(
    'screenshot',
    'daily',
    filename
  );

  await page.screenshot({
    path: filePath,
    fullPage: true,
  });

  console.log(`スクリーンショットを保存: ${filePath}`);
});

古いファイルの自動削除

ディスク容量を管理するために、古いキャプチャファイルを自動削除する仕組みを作りましょう。

typescriptimport * as fs from 'fs';
import * as path from 'path';

class CaptureCleanupManager {
  private retentionPolicies = {
    screenshots: {
      daily: 7, // 7日間保持
      testResults: 30, // 30日間保持
      monitoring: 90, // 90日間保持
    },
    videos: {
      testFailures: 14, // 14日間保持
      userScenarios: 7, // 7日間保持
    },
    traces: {
      all: 14, // 14日間保持
    },
  };

  async cleanupOldFiles(baseDir: string) {
    console.log(
      '古いファイルのクリーンアップを開始します...'
    );

    for (const [type, categories] of Object.entries(
      this.retentionPolicies
    )) {
      for (const [
        category,
        retentionDays,
      ] of Object.entries(categories)) {
        const dirPath = path.join(baseDir, type, category);
        await this.cleanupDirectory(dirPath, retentionDays);
      }
    }

    console.log('クリーンアップが完了しました');
  }

  private async cleanupDirectory(
    dirPath: string,
    retentionDays: number
  ) {
    if (!fs.existsSync(dirPath)) {
      return;
    }

    const files = fs.readdirSync(dirPath);
    const cutoffDate = new Date();
    cutoffDate.setDate(
      cutoffDate.getDate() - retentionDays
    );

    let deletedCount = 0;
    let totalSize = 0;

    for (const file of files) {
      const filePath = path.join(dirPath, file);
      const stats = fs.statSync(filePath);

      if (stats.mtime < cutoffDate) {
        totalSize += stats.size;
        fs.unlinkSync(filePath);
        deletedCount++;
      }
    }

    if (deletedCount > 0) {
      const sizeMB = (totalSize / 1024 / 1024).toFixed(2);
      console.log(
        `${dirPath}: ${deletedCount}個のファイル(${sizeMB}MB)を削除しました`
      );
    }
  }

  // アーカイブ機能
  async archiveOldFiles(
    baseDir: string,
    archiveDays: number = 30
  ) {
    const archiver = require('archiver');
    const archive = archiver('zip');
    const archiveDate = new Date()
      .toISOString()
      .split('T')[0];
    const archivePath = path.join(
      baseDir,
      'archives',
      `captures-${archiveDate}.zip`
    );

    const output = fs.createWriteStream(archivePath);
    archive.pipe(output);

    // 指定日数より古いファイルをアーカイブに追加
    const cutoffDate = new Date();
    cutoffDate.setDate(cutoffDate.getDate() - archiveDays);

    const addToArchive = (dirPath: string) => {
      if (!fs.existsSync(dirPath)) return;

      const files = fs.readdirSync(dirPath);
      for (const file of files) {
        const filePath = path.join(dirPath, file);
        const stats = fs.statSync(filePath);

        if (stats.mtime < cutoffDate && stats.isFile()) {
          archive.file(filePath, {
            name: path.relative(baseDir, filePath),
          });
        }
      }
    };

    // 各ディレクトリをアーカイブに追加
    addToArchive(path.join(baseDir, 'screenshots'));
    addToArchive(path.join(baseDir, 'videos'));
    addToArchive(path.join(baseDir, 'traces'));

    await archive.finalize();
    console.log(`アーカイブファイルを作成: ${archivePath}`);
  }
}

// 定期実行用のスクリプト
const cleanupManager = new CaptureCleanupManager();

// 毎日午前2時にクリーンアップを実行
const schedule = require('node-schedule');
schedule.scheduleJob('0 2 * * *', () => {
  cleanupManager.cleanupOldFiles('captures');
});

クラウドストレージとの連携

重要なキャプチャファイルをクラウドストレージに自動アップロードする仕組みです。

typescriptimport {
  S3Client,
  PutObjectCommand,
} from '@aws-sdk/client-s3';
import * as fs from 'fs';
import * as path from 'path';

class CloudStorageUploader {
  private s3Client: S3Client;
  private bucketName: string;

  constructor(bucketName: string) {
    this.bucketName = bucketName;
    this.s3Client = new S3Client({
      region: process.env.AWS_REGION || 'ap-northeast-1',
    });
  }

  async uploadCapture(
    localFilePath: string,
    category: string
  ) {
    try {
      const fileContent = fs.readFileSync(localFilePath);
      const fileName = path.basename(localFilePath);
      const s3Key = `captures/${category}/${fileName}`;

      const command = new PutObjectCommand({
        Bucket: this.bucketName,
        Key: s3Key,
        Body: fileContent,
        ContentType: this.getContentType(localFilePath),
        Metadata: {
          uploadedAt: new Date().toISOString(),
          category: category,
        },
      });

      await this.s3Client.send(command);
      console.log(`ファイルをアップロード: ${s3Key}`);

      return `https://${this.bucketName}.s3.amazonaws.com/${s3Key}`;
    } catch (error) {
      console.error('アップロードエラー:', error);
      throw error;
    }
  }

  private getContentType(filePath: string): string {
    const ext = path.extname(filePath).toLowerCase();
    const contentTypes: { [key: string]: string } = {
      '.png': 'image/png',
      '.jpg': 'image/jpeg',
      '.jpeg': 'image/jpeg',
      '.webm': 'video/webm',
      '.zip': 'application/zip',
    };

    return contentTypes[ext] || 'application/octet-stream';
  }

  // 失敗時のファイルを自動アップロード
  async uploadFailureCaptures(captureDir: string) {
    const failureFiles = this.findFailureFiles(captureDir);

    for (const file of failureFiles) {
      await this.uploadCapture(file, 'test-failures');
    }
  }

  private findFailureFiles(captureDir: string): string[] {
    const files: string[] = [];

    const searchDir = (dir: string) => {
      if (!fs.existsSync(dir)) return;

      const items = fs.readdirSync(dir);
      for (const item of items) {
        const fullPath = path.join(dir, item);
        const stats = fs.statSync(fullPath);

        if (stats.isDirectory()) {
          searchDir(fullPath);
        } else if (
          stats.isFile() &&
          this.isRecentFile(stats.mtime)
        ) {
          files.push(fullPath);
        }
      }
    };

    searchDir(captureDir);
    return files;
  }

  private isRecentFile(mtime: Date): boolean {
    const oneHourAgo = new Date();
    oneHourAgo.setHours(oneHourAgo.getHours() - 1);
    return mtime > oneHourAgo;
  }
}

// 使用例
test('クラウドアップロード機能', async ({ page }) => {
  const uploader = new CloudStorageUploader(
    'my-test-captures-bucket'
  );

  try {
    await page.goto('https://example.com');

    const screenshotPath = 'screenshots/test-result.png';
    await page.screenshot({
      path: screenshotPath,
      fullPage: true,
    });

    // 成功時のアップロード
    await uploader.uploadCapture(screenshotPath, 'success');
  } catch (error) {
    // 失敗時の自動アップロード
    await uploader.uploadFailureCaptures('captures');
    throw error;
  }
});

実運用での設定例とコード集

最後に、実際のプロジェクトですぐに使える設定例とヘルパー関数をまとめてご紹介します。

プロジェクト設定ファイルのテンプレート

実運用に適した Playwright の設定ファイルの例です。

typescriptimport { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  // テストファイルの場所
  testDir: './tests',

  // 並列実行数
  fullyParallel: true,

  // CI環境での設定
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,

  // レポーター設定
  reporter: [
    ['html'],
    ['json', { outputFile: 'test-results.json' }],
    ['junit', { outputFile: 'test-results.xml' }],
  ],

  // グローバル設定
  use: {
    // スクリーンショット設定
    screenshot: 'only-on-failure',

    // 動画録画設定
    video: 'retain-on-failure',

    // トレース設定
    trace: 'retain-on-failure',

    // アクション実行時のタイムアウト
    actionTimeout: 10000,

    // ナビゲーション時のタイムアウト
    navigationTimeout: 30000,
  },

  // プロジェクト設定
  projects: [
    // デスクトップブラウザ
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        // 高解像度での実行
        viewport: { width: 1920, height: 1080 },
      },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },

    // モバイルデバイス
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    },

    // 特殊な設定のプロジェクト
    {
      name: 'accessibility',
      use: {
        ...devices['Desktop Chrome'],
        // アクセシビリティテスト用の設定
        colorScheme: 'dark',
        reducedMotion: 'reduce',
      },
    },
  ],

  // Webサーバー設定(開発サーバーの自動起動)
  webServer: {
    command: 'yarn dev',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
});

再利用可能なヘルパー関数

プロジェクト全体で使用できるキャプチャ関連のヘルパー関数集です。

typescriptimport { Page, Locator } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';

export class CaptureHelpers {
  // スマートスクリーンショット(要素の読み込み待ちを含む)
  static async smartScreenshot(
    page: Page,
    options: {
      path: string;
      waitFor?: string;
      fullPage?: boolean;
      mask?: string[];
    }
  ) {
    // 指定された要素の読み込みを待つ
    if (options.waitFor) {
      await page.waitForSelector(options.waitFor, {
        state: 'visible',
      });
    }

    // ページの安定化を待つ
    await page.waitForLoadState('networkidle');

    // マスク対象の要素を準備
    const maskSelectors = options.mask || [];
    const maskLocators = maskSelectors.map((selector) =>
      page.locator(selector)
    );

    await page.screenshot({
      path: options.path,
      fullPage: options.fullPage ?? true,
      mask: maskLocators,
      animations: 'disabled',
    });
  }

  // 比較用スクリーンショット
  static async compareScreenshot(
    page: Page,
    name: string,
    options?: {
      threshold?: number;
      maxDiffPixels?: number;
    }
  ) {
    await page.screenshot({
      path: `screenshots/expected/${name}.png`,
      fullPage: true,
      animations: 'disabled',
    });

    // 実際の比較は expect で行う
    // expect(await page.screenshot()).toMatchSnapshot(`${name}.png`, options);
  }

  // プログレッシブスクリーンショット(読み込み過程を記録)
  static async progressiveScreenshot(
    page: Page,
    baseName: string,
    steps: string[]
  ) {
    for (let i = 0; i < steps.length; i++) {
      const step = steps[i];

      // ステップ実行前のスクリーンショット
      await page.screenshot({
        path: `screenshots/progressive/${baseName}/step-${
          i + 1
        }-before.png`,
        fullPage: true,
      });

      // ステップを実行
      if (step.startsWith('click:')) {
        await page.click(step.replace('click:', ''));
      } else if (step.startsWith('fill:')) {
        const [selector, value] = step
          .replace('fill:', '')
          .split('|');
        await page.fill(selector, value);
      } else if (step.startsWith('wait:')) {
        await page.waitForSelector(
          step.replace('wait:', '')
        );
      }

      // ステップ実行後のスクリーンショット
      await page.screenshot({
        path: `screenshots/progressive/${baseName}/step-${
          i + 1
        }-after.png`,
        fullPage: true,
      });
    }
  }

  // エラー状態の詳細キャプチャ
  static async captureErrorState(
    page: Page,
    errorName: string
  ) {
    const timestamp = new Date()
      .toISOString()
      .replace(/[:.]/g, '-');
    const baseDir = `captures/errors/${errorName}/${timestamp}`;

    // ディレクトリ作成
    if (!fs.existsSync(baseDir)) {
      fs.mkdirSync(baseDir, { recursive: true });
    }

    // 複数のスクリーンショット
    await page.screenshot({
      path: `${baseDir}/full-page.png`,
      fullPage: true,
    });

    await page.screenshot({
      path: `${baseDir}/viewport.png`,
      fullPage: false,
    });

    // ページ情報の保存
    const pageInfo = {
      url: page.url(),
      title: await page.title(),
      userAgent: await page.evaluate(
        () => navigator.userAgent
      ),
      timestamp: new Date().toISOString(),
      viewportSize: await page.viewportSize(),
      cookies: await page.context().cookies(),
    };

    fs.writeFileSync(
      `${baseDir}/page-info.json`,
      JSON.stringify(pageInfo, null, 2)
    );

    // DOM情報の保存
    const domInfo = await page.evaluate(() => {
      return {
        documentReady: document.readyState,
        elementCount: document.querySelectorAll('*').length,
        scriptCount:
          document.querySelectorAll('script').length,
        styleCount: document.querySelectorAll(
          'style, link[rel="stylesheet"]'
        ).length,
        imageCount: document.querySelectorAll('img').length,
      };
    });

    fs.writeFileSync(
      `${baseDir}/dom-info.json`,
      JSON.stringify(domInfo, null, 2)
    );

    return baseDir;
  }

  // 要素のアニメーション完了待ち
  static async waitForAnimations(
    page: Page,
    selector?: string
  ) {
    const target = selector ? page.locator(selector) : page;

    await page.evaluate((sel) => {
      const elements = sel
        ? document.querySelectorAll(sel)
        : document.querySelectorAll('*');

      const promises = Array.from(elements).map(
        (element) => {
          return new Promise<void>((resolve) => {
            const animations = element.getAnimations();
            if (animations.length === 0) {
              resolve();
              return;
            }

            Promise.all(
              animations.map(
                (animation) => animation.finished
              )
            )
              .then(() => resolve())
              .catch(() => resolve()); // エラーでも続行
          });
        }
      );

      return Promise.all(promises);
    }, selector);
  }
}

環境別の設定切り替え

環境に応じて設定を切り替える仕組みです。

typescript// config/environments.ts
export interface EnvironmentConfig {
  baseUrl: string;
  screenshotDir: string;
  videoDir: string;
  retries: number;
  timeout: number;
  uploadToCloud: boolean;
}

export const environments: Record<
  string,
  EnvironmentConfig
> = {
  development: {
    baseUrl: 'http://localhost:3000',
    screenshotDir: 'captures/dev/screenshots',
    videoDir: 'captures/dev/videos',
    retries: 0,
    timeout: 10000,
    uploadToCloud: false,
  },

  staging: {
    baseUrl: 'https://staging.example.com',
    screenshotDir: 'captures/staging/screenshots',
    videoDir: 'captures/staging/videos',
    retries: 1,
    timeout: 15000,
    uploadToCloud: true,
  },

  production: {
    baseUrl: 'https://example.com',
    screenshotDir: 'captures/prod/screenshots',
    videoDir: 'captures/prod/videos',
    retries: 2,
    timeout: 20000,
    uploadToCloud: true,
  },
};

export function getEnvironmentConfig(): EnvironmentConfig {
  const env = process.env.NODE_ENV || 'development';
  return environments[env] || environments.development;
}

// テストでの使用例
import { getEnvironmentConfig } from './config/environments';

test('環境別設定の使用', async ({ page }) => {
  const config = getEnvironmentConfig();

  await page.goto(config.baseUrl);

  const timestamp = new Date()
    .toISOString()
    .replace(/[:.]/g, '-');
  await page.screenshot({
    path: `${config.screenshotDir}/homepage-${timestamp}.png`,
    fullPage: true,
  });
});

まとめ

今回は、Playwright のスクリーンショット・動画自動取得機能について詳しく解説してきました。これらの機能を活用することで、テストの品質向上、デバッグ効率の改善、本番環境の監視など、様々な場面で開発効率を大幅に向上させることができます。

特に重要なポイントをまとめると、以下のようになります。

まず、適切な設定とファイル管理が成功の鍵となります。プロジェクトの規模や要件に応じて、ディレクトリ構造や保持ポリシーを設計することで、長期的に運用しやすいシステムを構築できます。

次に、エラー時の自動キャプチャは、問題解決の速度を劇的に改善してくれます。テストが失敗した瞬間の状況を詳細に記録することで、原因特定の時間を大幅に短縮できるでしょう。

また、環境別の設定切り替えにより、開発環境から本番環境まで一貫したテスト戦略を実現できます。これにより、環境固有の問題を早期に発見し、より安定したシステムを構築できます。

動画録画機能は、複雑なユーザーフローの検証や、問題の再現性確認において非常に強力なツールです。特に、ステークホルダーへの報告や、チーム内での情報共有において、視覚的な情報は言葉では伝えきれない価値を提供してくれます。

これらの機能を組み合わせることで、皆さんのプロジェクトでも、より効率的で信頼性の高いテスト環境を構築できることでしょう。まずは小さな機能から始めて、徐々に拡張していくことをお勧めします。きっと、開発体験の向上を実感していただけるはずです。

関連リンク