10日で覚えるPlaywrightDay 5: アサーションとスナップショット
books.chapter 510日で覚えるPlaywright

Day 5: アサーションとスナップショット

今日学ぶこと

  • 汎用アサーション(toBe, toEqual, toContain など)
  • Web-first アサーション(toBeVisible, toHaveText など)
  • Web-first アサーションの自動リトライ
  • ソフトアサーション(expect.soft)
  • 否定アサーション(.not)
  • カスタムアサーションメッセージ
  • スナップショットテスト(toMatchSnapshot)
  • ビジュアルリグレッションテスト(toHaveScreenshot)
  • スナップショットの更新方法

アサーションの2つの世界

Playwrightには大きく分けて2種類のアサーションがあります。

flowchart TB
    subgraph Types["アサーションの種類"]
        GENERIC["汎用アサーション\nexpect(value)"]
        WEBFIRST["Web-first アサーション\nexpect(locator)"]
    end
    GENERIC -->|"即時評価\nリトライなし"| USE1["値・オブジェクトの比較"]
    WEBFIRST -->|"自動リトライ\n非同期対応"| USE2["DOM要素の検証"]
    style GENERIC fill:#f59e0b,color:#fff
    style WEBFIRST fill:#22c55e,color:#fff
    style USE2 fill:#22c55e,color:#fff

この違いを理解することが、安定したテストを書くための第一歩です。


汎用アサーション

汎用アサーションは、JavaScriptの値を即座に検証します。Jest/Vitestと同じ expect 構文です。

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

test('generic assertions', async ({ page }) => {
  const title = await page.title();

  // 厳密等価
  expect(title).toBe('My App');

  // 深い等価(オブジェクト・配列の比較)
  expect({ name: 'Taro', age: 25 }).toEqual({ name: 'Taro', age: 25 });

  // 部分一致
  expect(title).toContain('App');

  // 正規表現マッチ
  expect(title).toMatch(/My\s+App/);

  // 真偽値
  expect(true).toBeTruthy();
  expect(0).toBeFalsy();
  expect(null).toBeNull();
  expect(undefined).toBeUndefined();

  // 数値比較
  expect(10).toBeGreaterThan(5);
  expect(10).toBeGreaterThanOrEqual(10);
  expect(3).toBeLessThan(5);
  expect(3).toBeLessThanOrEqual(3);
  expect(0.1 + 0.2).toBeCloseTo(0.3, 5);

  // 配列・文字列の長さ
  expect([1, 2, 3]).toHaveLength(3);

  // オブジェクトのプロパティ
  expect({ id: 1, name: 'Test' }).toHaveProperty('name', 'Test');

  // 部分一致(オブジェクト)
  expect({ id: 1, name: 'Test', role: 'admin' }).toMatchObject({
    name: 'Test',
    role: 'admin',
  });
});

注意: 汎用アサーションはリトライされません。await で取得した値をその場で検証するだけです。


Web-first アサーション

Web-first アサーションは、Playwrightの最も強力な機能の一つです。Locatorを渡すと、条件が満たされるまで自動的にリトライしてくれます。

要素の表示・状態

test('element visibility and state', async ({ page }) => {
  await page.goto('/form');

  // 要素が表示されている
  await expect(page.getByRole('heading')).toBeVisible();

  // 要素が非表示
  await expect(page.getByTestId('loading')).toBeHidden();

  // 要素がDOMに存在する(表示・非表示問わず)
  await expect(page.getByTestId('hidden-input')).toBeAttached();

  // 要素が有効
  await expect(page.getByRole('button', { name: '送信' })).toBeEnabled();

  // 要素が無効
  await expect(page.getByRole('button', { name: '送信' })).toBeDisabled();

  // チェックボックスがチェック済み
  await expect(page.getByLabel('利用規約に同意')).toBeChecked();

  // 入力フィールドが編集可能
  await expect(page.getByLabel('名前')).toBeEditable();

  // フォーカスされている
  await expect(page.getByLabel('名前')).toBeFocused();
});

テキスト・属性・値

test('text, attributes, and values', async ({ page }) => {
  await page.goto('/profile');

  // テキスト内容(完全一致)
  await expect(page.getByTestId('username')).toHaveText('田中太郎');

  // テキスト内容(部分一致)
  await expect(page.getByTestId('bio')).toContainText('エンジニア');

  // テキスト内容(正規表現)
  await expect(page.getByTestId('date')).toHaveText(/\d{4}\d{1,2}/);

  // 属性
  await expect(page.getByRole('link', { name: 'ホーム' }))
    .toHaveAttribute('href', '/');

  // CSS クラス
  await expect(page.getByTestId('alert')).toHaveClass(/alert-danger/);

  // CSS プロパティ
  await expect(page.getByTestId('alert'))
    .toHaveCSS('background-color', 'rgb(239, 68, 68)');

  // 入力フィールドの値
  await expect(page.getByLabel('メール')).toHaveValue('user@example.com');

  // 入力フィールドの値(正規表現)
  await expect(page.getByLabel('メール')).toHaveValue(/.+@.+\..+/);
});

要素数・リスト

test('element count and lists', async ({ page }) => {
  await page.goto('/todos');

  // 要素の数
  await expect(page.getByRole('listitem')).toHaveCount(5);

  // 複数要素のテキストを一括検証
  await expect(page.getByRole('listitem')).toHaveText([
    '牛乳を買う',
    'レポートを書く',
    'ジムに行く',
    '本を読む',
    '料理する',
  ]);
});

ページレベルのアサーション

test('page-level assertions', async ({ page }) => {
  await page.goto('/dashboard');

  // URL
  await expect(page).toHaveURL('/dashboard');
  await expect(page).toHaveURL(/\/dashboard/);

  // タイトル
  await expect(page).toHaveTitle('ダッシュボード | My App');
  await expect(page).toHaveTitle(/ダッシュボード/);
});

自動リトライの仕組み

Web-first アサーションは、条件が満たされるまで繰り返しチェックを行います。デフォルトのタイムアウトは5秒です。

flowchart TB
    START["expect(locator).toBeVisible()"] --> CHECK["要素の状態を確認"]
    CHECK -->|"条件を満たす"| PASS["テスト通過"]
    CHECK -->|"条件を満たさない"| TIMEOUT["タイムアウト?"]
    TIMEOUT -->|"まだ時間がある"| WAIT["少し待機"] --> CHECK
    TIMEOUT -->|"5秒経過"| FAIL["テスト失敗"]
    style START fill:#3b82f6,color:#fff
    style PASS fill:#22c55e,color:#fff
    style FAIL fill:#ef4444,color:#fff
test('auto-retry example', async ({ page }) => {
  await page.goto('/async-content');

  // ボタンをクリック後、非同期でコンテンツが表示される場合
  await page.getByRole('button', { name: 'データを読み込む' }).click();

  // sleep や waitFor は不要!
  // Playwright が自動的にリトライしてくれる
  await expect(page.getByTestId('result')).toBeVisible();
  await expect(page.getByTestId('result')).toHaveText('読み込み完了');
});

test('custom timeout', async ({ page }) => {
  await page.goto('/slow-api');

  // 重い処理の場合はタイムアウトを延長
  await expect(page.getByTestId('result'))
    .toBeVisible({ timeout: 30000 });
});

重要: await を忘れると、アサーションがリトライされず即座に評価されます。Web-first アサーションには必ず await を付けてください。


ソフトアサーション

通常のアサーションは失敗した時点でテストが即座に停止します。expect.soft() を使うと、失敗してもテストの実行を続けることができます。

test('form validation - all fields', async ({ page }) => {
  await page.goto('/settings');

  // ソフトアサーション: 失敗しても次の検証に進む
  await expect.soft(page.getByLabel('名前')).toHaveValue('田中太郎');
  await expect.soft(page.getByLabel('メール')).toHaveValue('taro@example.com');
  await expect.soft(page.getByLabel('電話番号')).toHaveValue('090-1234-5678');
  await expect.soft(page.getByLabel('住所')).toHaveValue('東京都渋谷区');

  // すべての検証を実行した後、失敗があればテストは失敗として報告される
});

ソフトアサーションの活用場面

flowchart TB
    subgraph Normal["通常のアサーション"]
        N1["検証1: OK"] --> N2["検証2: NG"]
        N2 -->|"即停止"| NSTOP["テスト失敗\n検証3以降は実行されない"]
    end
    subgraph Soft["ソフトアサーション"]
        S1["検証1: OK"] --> S2["検証2: NG"]
        S2 -->|"続行"| S3["検証3: OK"]
        S3 --> S4["検証4: NG"]
        S4 --> SSTOP["テスト失敗\nすべての失敗を一括報告"]
    end
    style N2 fill:#ef4444,color:#fff
    style NSTOP fill:#ef4444,color:#fff
    style S2 fill:#ef4444,color:#fff
    style S4 fill:#ef4444,color:#fff
    style SSTOP fill:#f59e0b,color:#fff

ソフトアサーションは、一度のテスト実行で複数の問題を把握したい場合に便利です。ただし、後続の操作が前の検証結果に依存する場合は通常のアサーションを使いましょう。


否定アサーション

.not を使うことで、条件の否定を検証できます。

test('negating assertions', async ({ page }) => {
  await page.goto('/dashboard');

  // 要素が表示されていないことを確認
  await expect(page.getByTestId('error-message')).not.toBeVisible();

  // テキストを含まないことを確認
  await expect(page.getByTestId('status')).not.toHaveText('エラー');

  // URLが特定のパスでないことを確認
  await expect(page).not.toHaveURL('/login');

  // チェックされていないことを確認
  await expect(page.getByLabel('プレミアム')).not.toBeChecked();

  // 特定の属性を持たないことを確認
  await expect(page.getByRole('button')).not.toHaveAttribute('disabled');
});

.not を使った Web-first アサーションも自動リトライされます。例えば not.toBeVisible() は、要素が非表示になるまでリトライします。


カスタムアサーションメッセージ

アサーションが失敗した時に表示されるメッセージをカスタマイズできます。第1引数に文字列を渡します。

test('custom messages', async ({ page }) => {
  await page.goto('/cart');

  const cartCount = page.getByTestId('cart-count');

  // カスタムメッセージ付きアサーション
  await expect(cartCount, 'カートの商品数が正しくない').toHaveText('3');

  // 汎用アサーションでも使える
  const price = await page.getByTestId('total-price').textContent();
  expect(parseInt(price!), '合計金額が1000円以上であるべき')
    .toBeGreaterThanOrEqual(1000);
});

テストが失敗した場合、デフォルトのエラーメッセージの代わりにカスタムメッセージが表示されます。特に複雑なテストでは、失敗箇所を素早く特定するのに役立ちます。


スナップショットテスト

テキスト・データスナップショット

toMatchSnapshot() は、テスト結果を保存されたスナップショットと比較します。初回実行時にスナップショットファイルが作成され、以降のテストではその内容と一致するかを検証します。

test('API response snapshot', async ({ request }) => {
  const response = await request.get('/api/config');
  const data = await response.json();

  // JSON データをスナップショットと比較
  expect(data).toMatchSnapshot('api-config.json');
});

test('page text content snapshot', async ({ page }) => {
  await page.goto('/about');

  const content = await page.getByRole('main').textContent();
  expect(content).toMatchSnapshot('about-page-content.txt');
});

test('list items snapshot', async ({ page }) => {
  await page.goto('/categories');

  const items = await page.getByRole('listitem').allTextContents();
  expect(items).toMatchSnapshot('category-list.json');
});

スナップショットファイルは __snapshots__ ディレクトリに保存されます。

ビジュアルリグレッションテスト

toHaveScreenshot() は、ページや要素のスクリーンショットをピクセル単位で比較します。

test('visual regression - full page', async ({ page }) => {
  await page.goto('/');

  // ページ全体のスクリーンショットを比較
  await expect(page).toHaveScreenshot('homepage.png');
});

test('visual regression - component', async ({ page }) => {
  await page.goto('/components');

  // 特定の要素のスクリーンショットを比較
  const card = page.getByTestId('product-card');
  await expect(card).toHaveScreenshot('product-card.png');
});

test('full page screenshot', async ({ page }) => {
  await page.goto('/blog');

  // ページ全体(スクロール含む)を撮影
  await expect(page).toHaveScreenshot('blog-full.png', {
    fullPage: true,
  });
});

スナップショットの閾値設定

ピクセル単位の完全一致は現実的でない場合があります。フォントレンダリングやアンチエイリアシングの違いを許容するために、閾値を設定できます。

test('visual with threshold', async ({ page }) => {
  await page.goto('/chart');

  // 最大で100ピクセルの差異を許容
  await expect(page).toHaveScreenshot('chart.png', {
    maxDiffPixels: 100,
  });

  // 全ピクセルの1%までの差異を許容
  await expect(page).toHaveScreenshot('chart-ratio.png', {
    maxDiffPixelRatio: 0.01,
  });

  // ピクセルごとの色差の閾値(0-1、デフォルト0.2)
  await expect(page).toHaveScreenshot('chart-threshold.png', {
    threshold: 0.3,
  });
});

playwright.config.ts でプロジェクト全体の閾値を設定することもできます。

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

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.01,
      threshold: 0.2,
    },
    toMatchSnapshot: {
      maxDiffPixelRatio: 0.01,
    },
  },
});

アニメーション・動的コンテンツの対策

test('screenshot with animations disabled', async ({ page }) => {
  await page.goto('/animated-page');

  await expect(page).toHaveScreenshot('no-animation.png', {
    // アニメーションを無効化
    animations: 'disabled',
    // 特定の要素をマスク(日時表示など)
    mask: [page.getByTestId('current-time')],
    // マスクの色
    maskColor: '#FF00FF',
  });
});

スナップショットの更新

UIの変更を行った場合は、スナップショットを更新する必要があります。

# すべてのスナップショットを更新
npx playwright test --update-snapshots

# 特定のテストファイルのスナップショットのみ更新
npx playwright test tests/visual.spec.ts --update-snapshots

実践: ダッシュボードのテスト

学んだアサーションを組み合わせて、ダッシュボード画面のテストを書きましょう。

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

test.describe('ダッシュボード', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/dashboard');
  });

  test('ヘッダー情報が正しく表示される', async ({ page }) => {
    await expect(page).toHaveTitle(/ダッシュボード/);
    await expect(page).toHaveURL('/dashboard');

    await expect(page.getByRole('heading', { level: 1 }))
      .toHaveText('ダッシュボード');

    await expect(page.getByTestId('user-name'))
      .toContainText('田中');
  });

  test('統計カードが正しく表示される', async ({ page }) => {
    const cards = page.getByTestId('stat-card');
    await expect(cards).toHaveCount(4);

    // ソフトアサーションで全カードを検証
    await expect.soft(cards.nth(0)).toContainText('売上');
    await expect.soft(cards.nth(1)).toContainText('注文数');
    await expect.soft(cards.nth(2)).toContainText('顧客数');
    await expect.soft(cards.nth(3)).toContainText('在庫');
  });

  test('データ読み込み後に結果が表示される', async ({ page }) => {
    // ローディング中
    await expect(page.getByTestId('loading')).toBeVisible();

    // データ読み込み完了(自動リトライで待機)
    await expect(page.getByTestId('loading')).not.toBeVisible();
    await expect(page.getByTestId('data-table')).toBeVisible();

    // テーブルにデータがある
    const rows = page.getByRole('row');
    const count = await rows.count();
    expect(count, 'テーブルに最低1行のデータがあること')
      .toBeGreaterThan(1);
  });

  test('ビジュアルリグレッション', async ({ page }) => {
    // データ読み込み完了を待つ
    await expect(page.getByTestId('loading')).not.toBeVisible();

    await expect(page).toHaveScreenshot('dashboard.png', {
      animations: 'disabled',
      mask: [
        page.getByTestId('current-date'),
        page.getByTestId('last-updated'),
      ],
    });
  });
});

まとめ

概念 説明
汎用アサーション expect(value).toBe() など。即時評価、リトライなし
Web-first アサーション expect(locator).toBeVisible() など。自動リトライ付き
ソフトアサーション expect.soft() で失敗しても続行。全失敗を一括報告
否定アサーション .not で条件の逆を検証。リトライも有効
カスタムメッセージ expect の第2引数でエラーメッセージをカスタマイズ
toMatchSnapshot テキスト・データの差分を検出
toHaveScreenshot ピクセル単位のビジュアル比較
maxDiffPixels 許容するピクセル差異の絶対数
maxDiffPixelRatio 許容するピクセル差異の割合
--update-snapshots スナップショットの一括更新

重要ポイント

  1. Web-first アサーションを優先的に使う - 自動リトライにより sleepwaitFor が不要になる。非同期UIでも安定したテストが書ける
  2. await を忘れない - Web-first アサーションに await を付け忘れると、リトライされず不安定なテストになる
  3. ビジュアルテストには閾値を設定する - 環境差異によるフォントレンダリングの違いを吸収するために、適切な maxDiffPixelRatio を設定する

練習問題

問題1: 基本

ECサイトの商品詳細ページのテストを書いてください。以下を検証すること:

  1. 商品名が表示されている
  2. 価格が「¥」で始まる
  3. 「カートに追加」ボタンが有効である
  4. 在庫数が0より大きいことをカスタムメッセージ付きで検証

問題2: ソフトアサーション

ユーザープロフィール画面で、5つのフィールド(名前、メール、電話番号、住所、生年月日)をソフトアサーションで一括検証するテストを書いてください。

チャレンジ問題

以下のシナリオをテストするコードを書いてください:

  • ダッシュボードのグラフコンポーネントをビジュアルスナップショットで検証
  • 日時やランダムな値を表示する要素はマスクする
  • maxDiffPixelRatio を 0.02 に設定
  • アニメーションを無効化する

参考リンク


次回予告: Day 6では「ネットワーク制御とモック」について学びます。page.route() を使ったAPIリクエストのインターセプトとモック、page.waitForResponse() によるレスポンス監視を習得しましょう。