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 | スナップショットの一括更新 |
重要ポイント
- Web-first アサーションを優先的に使う - 自動リトライにより
sleepやwaitForが不要になる。非同期UIでも安定したテストが書ける awaitを忘れない - Web-first アサーションにawaitを付け忘れると、リトライされず不安定なテストになる- ビジュアルテストには閾値を設定する - 環境差異によるフォントレンダリングの違いを吸収するために、適切な
maxDiffPixelRatioを設定する
練習問題
問題1: 基本
ECサイトの商品詳細ページのテストを書いてください。以下を検証すること:
- 商品名が表示されている
- 価格が「¥」で始まる
- 「カートに追加」ボタンが有効である
- 在庫数が0より大きいことをカスタムメッセージ付きで検証
問題2: ソフトアサーション
ユーザープロフィール画面で、5つのフィールド(名前、メール、電話番号、住所、生年月日)をソフトアサーションで一括検証するテストを書いてください。
チャレンジ問題
以下のシナリオをテストするコードを書いてください:
- ダッシュボードのグラフコンポーネントをビジュアルスナップショットで検証
- 日時やランダムな値を表示する要素はマスクする
- maxDiffPixelRatio を 0.02 に設定
- アニメーションを無効化する
参考リンク
- Assertions - Playwright公式ドキュメント
- Visual comparisons - Playwright公式ドキュメント
- expect API - Playwright公式ドキュメント
次回予告: Day 6では「ネットワーク制御とモック」について学びます。page.route() を使ったAPIリクエストのインターセプトとモック、page.waitForResponse() によるレスポンス監視を習得しましょう。