自動テストチュートリアル

Playwright、Seleniumなどの自動テストツールを学ぼう

テスト設計の基本

テスト設計の基本

効果的なテストを作成するには、単にテストコードを書くだけでなく、どのようなテストケースを作るかという設計が重要です。このチャプターでは、テスト設計の基本技法である境界値分析、同値分割、網羅性の考え方について学びます。

テスト設計とは?

テスト設計の目的

テスト設計は、限られたリソースで最大の効果を得るためのテストケースの選定方法です。

目的 説明
効率性 最小限のテストケースで最大のカバレッジを達成
網羅性 重要な動作パターンを漏れなく検証
バグ検出 バグが潜みやすい箇所を重点的にテスト
メンテナンス性 保守しやすいテストケースを設計

なぜテスト設計が必要か?

すべてのパターンをテストするのは現実的ではありません。

// 例: ログインフォーム
// ユーザー名(英数字20文字まで)× パスワード(英数字記号8〜20文字)
// = 組み合わせは事実上無限

// ❌ すべてをテストするのは不可能
test('全パターンテスト', async ({ page }) => {
  // 数億通りのパターンをテスト...(現実的でない)
});

// ✅ 重要なパターンを選んでテスト
test('有効な入力でログイン成功', async ({ page }) => {
  // 代表的なパターンをテスト
});

[!INFO] 組み合わせ爆発の問題

入力パターンが増えるほど、テストケースは指数関数的に増加します。例えば、5つの入力項目があり、それぞれ10パターンの入力がある場合、組み合わせは10^5 = 100,000通りになります。

同値分割(Equivalence Partitioning)

基本概念

同値分割は、入力値を同じ動作をする「グループ」に分ける手法です。

各グループから代表的な値を1つ選んでテストすることで、効率的にテストできます。

同値分割の例

年齢入力フォームの場合:

グループ 範囲 代表値 期待する動作
無効(未成年) 0〜17 10 エラーメッセージ表示
有効(成人) 18〜119 30 登録成功
無効(異常値) 120〜 150 エラーメッセージ表示
無効(負の数) 負の数 -5 エラーメッセージ表示

Playwright での実装例

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

test.describe('年齢入力の同値分割テスト', () => {
  test('未成年の場合、エラーメッセージが表示される', async ({ page }) => {
    await page.goto('https://example.com/register');

    // 代表値: 10(0〜17のグループ)
    await page.fill('#age', '10');
    await page.click('#submit');

    await expect(page.locator('.error-message')).toBeVisible();
    await expect(page.locator('.error-message')).toContainText('18歳以上である必要があります');
  });

  test('成人の場合、登録が成功する', async ({ page }) => {
    await page.goto('https://example.com/register');
    await page.fill('#name', '山田太郎');
    await page.fill('#email', 'yamada@example.com');

    // 代表値: 30(18〜119のグループ)
    await page.fill('#age', '30');
    await page.click('#submit');

    await expect(page.locator('.success-message')).toBeVisible();
    await expect(page).toHaveURL(/.*success/);
  });

  test('異常に大きい年齢の場合、エラーメッセージが表示される', async ({ page }) => {
    await page.goto('https://example.com/register');

    // 代表値: 150(120〜のグループ)
    await page.fill('#age', '150');
    await page.click('#submit');

    await expect(page.locator('.error-message')).toBeVisible();
    await expect(page.locator('.error-message')).toContainText('有効な年齢を入力してください');
  });

  test('負の数の場合、エラーメッセージが表示される', async ({ page }) => {
    await page.goto('https://example.com/register');

    // 代表値: -5(負の数グループ)
    await page.fill('#age', '-5');
    await page.click('#submit');

    await expect(page.locator('.error-message')).toBeVisible();
  });
});

商品数量の同値分割例

test.describe('カート商品数量の同値分割', () => {
  test('数量0: カートに追加されない', async ({ page }) => {
    await page.goto('https://example.com/products/123');
    await page.fill('#quantity', '0');
    await page.click('#add-to-cart');

    await expect(page.locator('.error-message')).toContainText('数量は1以上を指定してください');
  });

  test('数量1〜99: 正常に追加される', async ({ page }) => {
    await page.goto('https://example.com/products/123');
    await page.fill('#quantity', '5'); // 代表値
    await page.click('#add-to-cart');

    await expect(page.locator('.success-message')).toBeVisible();
    await expect(page.locator('#cart-count')).toContainText('5');
  });

  test('数量100以上: 在庫制限エラー', async ({ page }) => {
    await page.goto('https://example.com/products/123');
    await page.fill('#quantity', '150');
    await page.click('#add-to-cart');

    await expect(page.locator('.error-message')).toContainText('在庫数を超えています');
  });
});

境界値分析(Boundary Value Analysis)

基本概念

境界値分析は、範囲の境界付近の値を重点的にテストする手法です。

バグは境界値付近で発生しやすいという経験則に基づいています。

境界値の選び方

位置 テストする値
最小値 最も小さい有効な値
最小値-1 最小値の直前(無効)
最大値 最も大きい有効な値
最大値+1 最大値の直後(無効)
ゼロ 0(該当する場合)

境界値分析の例

年齢制限(18〜65歳)の場合:

テスト値 区分 期待結果
17 最小値-1 エラー
18 最小値 成功
19 最小値+1 成功
64 最大値-1 成功
65 最大値 成功
66 最大値+1 エラー

Playwright での実装例

test.describe('年齢制限の境界値テスト(18〜65歳)', () => {
  test('17歳: 最小値-1(エラー)', async ({ page }) => {
    await page.goto('https://example.com/job-application');
    await page.fill('#age', '17');
    await page.click('#submit');

    await expect(page.locator('.error-message')).toContainText('18歳以上である必要があります');
  });

  test('18歳: 最小値(成功)', async ({ page }) => {
    await page.goto('https://example.com/job-application');
    await page.fill('#name', '山田太郎');
    await page.fill('#age', '18');
    await page.click('#submit');

    await expect(page.locator('.success-message')).toBeVisible();
  });

  test('19歳: 最小値+1(成功)', async ({ page }) => {
    await page.goto('https://example.com/job-application');
    await page.fill('#name', '山田太郎');
    await page.fill('#age', '19');
    await page.click('#submit');

    await expect(page.locator('.success-message')).toBeVisible();
  });

  test('64歳: 最大値-1(成功)', async ({ page }) => {
    await page.goto('https://example.com/job-application');
    await page.fill('#name', '山田太郎');
    await page.fill('#age', '64');
    await page.click('#submit');

    await expect(page.locator('.success-message')).toBeVisible();
  });

  test('65歳: 最大値(成功)', async ({ page }) => {
    await page.goto('https://example.com/job-application');
    await page.fill('#name', '山田太郎');
    await page.fill('#age', '65');
    await page.click('#submit');

    await expect(page.locator('.success-message')).toBeVisible();
  });

  test('66歳: 最大値+1(エラー)', async ({ page }) => {
    await page.goto('https://example.com/job-application');
    await page.fill('#age', '66');
    await page.click('#submit');

    await expect(page.locator('.error-message')).toContainText('65歳以下である必要があります');
  });
});

文字数制限の境界値テスト

test.describe('ユーザー名の文字数制限(3〜20文字)', () => {
  test('2文字: 最小値-1(エラー)', async ({ page }) => {
    await page.goto('https://example.com/register');
    await page.fill('#username', 'ab'); // 2文字
    await page.click('#submit');

    await expect(page.locator('#username-error')).toContainText('3文字以上で入力してください');
  });

  test('3文字: 最小値(成功)', async ({ page }) => {
    await page.goto('https://example.com/register');
    await page.fill('#username', 'abc'); // 3文字
    await page.fill('#email', 'user@example.com');
    await page.fill('#password', 'password123');
    await page.click('#submit');

    await expect(page.locator('.success-message')).toBeVisible();
  });

  test('20文字: 最大値(成功)', async ({ page }) => {
    await page.goto('https://example.com/register');
    await page.fill('#username', 'a'.repeat(20)); // 20文字
    await page.fill('#email', 'user@example.com');
    await page.fill('#password', 'password123');
    await page.click('#submit');

    await expect(page.locator('.success-message')).toBeVisible();
  });

  test('21文字: 最大値+1(エラー)', async ({ page }) => {
    await page.goto('https://example.com/register');
    await page.fill('#username', 'a'.repeat(21)); // 21文字
    await page.click('#submit');

    await expect(page.locator('#username-error')).toContainText('20文字以内で入力してください');
  });
});

同値分割と境界値分析の組み合わせ

実際のテストでは、両方の手法を組み合わせることで、効率的かつ効果的なテストを設計できます。

価格の割引適用の例

仕様:

  • 1,000円未満: 割引なし
  • 1,000円以上10,000円未満: 10%割引
  • 10,000円以上: 20%割引
test.describe('価格割引の適用テスト', () => {
  // 境界値: 999円(1,000円未満の境界)
  test('999円: 割引なし(境界値)', async ({ page }) => {
    await page.goto('https://example.com/cart');
    await page.fill('#price', '999');
    await page.click('#calculate');

    await expect(page.locator('#discount')).toHaveText('0円');
    await expect(page.locator('#total')).toHaveText('999円');
  });

  // 境界値: 1,000円(10%割引の開始)
  test('1,000円: 10%割引が適用される(境界値)', async ({ page }) => {
    await page.goto('https://example.com/cart');
    await page.fill('#price', '1000');
    await page.click('#calculate');

    await expect(page.locator('#discount')).toHaveText('100円');
    await expect(page.locator('#total')).toHaveText('900円');
  });

  // 同値分割: 1,000円以上10,000円未満の代表値
  test('5,000円: 10%割引が適用される(代表値)', async ({ page }) => {
    await page.goto('https://example.com/cart');
    await page.fill('#price', '5000');
    await page.click('#calculate');

    await expect(page.locator('#discount')).toHaveText('500円');
    await expect(page.locator('#total')).toHaveText('4,500円');
  });

  // 境界値: 9,999円(10%割引の終了直前)
  test('9,999円: 10%割引が適用される(境界値)', async ({ page }) => {
    await page.goto('https://example.com/cart');
    await page.fill('#price', '9999');
    await page.click('#calculate');

    await expect(page.locator('#discount')).toHaveText('999円');
  });

  // 境界値: 10,000円(20%割引の開始)
  test('10,000円: 20%割引が適用される(境界値)', async ({ page }) => {
    await page.goto('https://example.com/cart');
    await page.fill('#price', '10000');
    await page.click('#calculate');

    await expect(page.locator('#discount')).toHaveText('2,000円');
    await expect(page.locator('#total')).toHaveText('8,000円');
  });

  // 同値分割: 10,000円以上の代表値
  test('50,000円: 20%割引が適用される(代表値)', async ({ page }) => {
    await page.goto('https://example.com/cart');
    await page.fill('#price', '50000');
    await page.click('#calculate');

    await expect(page.locator('#discount')).toHaveText('10,000円');
    await expect(page.locator('#total')).toHaveText('40,000円');
  });
});

網羅性の考え方

テストカバレッジの種類

カバレッジ 説明 目標
機能カバレッジ すべての機能を網羅 主要機能100%
ユーザーフローカバレッジ 主要なユーザー操作フローを網羅 重要フロー100%
条件カバレッジ 条件分岐のすべてのパターンを網羅 80%以上
コードカバレッジ 実行されたコードの割合 70〜80%が目安

機能カバレッジマトリクス

ECサイトの例:

機能 正常系 異常系 境界値 優先度
ログイン -
商品検索
カート追加
購入処理 -
レビュー投稿
お気に入り登録 - -

ユーザーフローの網羅

test.describe('主要ユーザーフロー', () => {
  test('フロー1: 新規ユーザーの初回購入', async ({ page }) => {
    // 1. 会員登録
    await page.goto('https://example.com/register');
    await page.fill('#username', 'newuser');
    await page.fill('#email', 'newuser@example.com');
    await page.fill('#password', 'password123');
    await page.click('#submit');

    // 2. 商品検索
    await page.fill('#search', 'ノートPC');
    await page.click('#search-button');

    // 3. 商品選択
    await page.click('.product-card:first-child');

    // 4. カート追加
    await page.click('#add-to-cart');

    // 5. 購入手続き
    await page.click('#checkout');
    await page.fill('#address', '東京都渋谷区...');
    await page.fill('#card-number', '4111111111111111');
    await page.click('#purchase');

    // 6. 購入完了確認
    await expect(page.locator('.order-success')).toBeVisible();
  });

  test('フロー2: 既存ユーザーの再購入', async ({ page }) => {
    // 1. ログイン
    await page.goto('https://example.com/login');
    await page.fill('#email', 'existing@example.com');
    await page.fill('#password', 'password');
    await page.click('#submit');

    // 2. 購入履歴から再購入
    await page.click('#order-history');
    await page.click('.reorder-button:first-child');

    // 3. 前回の配送先で購入
    await page.click('#use-previous-address');
    await page.click('#purchase');

    await expect(page.locator('.order-success')).toBeVisible();
  });

  test('フロー3: ゲスト購入', async ({ page }) => {
    // 1. 商品ページに直接アクセス
    await page.goto('https://example.com/products/123');

    // 2. ゲストとしてカート追加
    await page.click('#add-to-cart');
    await page.click('#guest-checkout');

    // 3. ゲスト情報入力
    await page.fill('#guest-email', 'guest@example.com');
    await page.fill('#address', '大阪府大阪市...');
    await page.fill('#card-number', '4111111111111111');
    await page.click('#purchase');

    await expect(page.locator('.order-success')).toBeVisible();
  });
});

テスト設計のチェックリスト

テスト設計時に確認すべき項目:

基本項目

  • 正常系をテストしているか?
  • 異常系をテストしているか?
  • 境界値をテストしているか?
  • 代表的な値を選んでいるか?

網羅性

  • 主要機能がすべてカバーされているか?
  • 重要なユーザーフローがテストされているか?
  • エッジケースを考慮しているか?

効率性

  • 重複したテストがないか?
  • 優先度が設定されているか?
  • 実行時間は適切か?

メンテナンス性

  • テストの意図が明確か?
  • 保守しやすい構造か?
  • 再利用可能な部品があるか?

実践的なテスト設計例

ログインフォームの完全なテスト設計

test.describe('ログイン機能の完全テスト', () => {
  // 正常系
  test('有効な認証情報でログイン成功', async ({ page }) => {
    await page.goto('https://example.com/login');
    await page.fill('#email', 'valid@example.com');
    await page.fill('#password', 'ValidPass123!');
    await page.click('#login-button');

    await expect(page).toHaveURL(/.*dashboard/);
  });

  // 異常系: メールアドレス
  test('無効なメールアドレス形式でエラー', async ({ page }) => {
    await page.goto('https://example.com/login');
    await page.fill('#email', 'invalid-email');
    await page.fill('#password', 'password');
    await page.click('#login-button');

    await expect(page.locator('.error')).toContainText('有効なメールアドレスを入力してください');
  });

  // 異常系: パスワード
  test('誤ったパスワードでエラー', async ({ page }) => {
    await page.goto('https://example.com/login');
    await page.fill('#email', 'valid@example.com');
    await page.fill('#password', 'WrongPassword');
    await page.click('#login-button');

    await expect(page.locator('.error')).toContainText('メールアドレスまたはパスワードが正しくありません');
  });

  // 境界値: パスワード文字数(8〜20文字と仮定)
  test('パスワード7文字(最小値-1)でエラー', async ({ page }) => {
    await page.goto('https://example.com/login');
    await page.fill('#email', 'valid@example.com');
    await page.fill('#password', 'Pass12!'); // 7文字
    await page.click('#login-button');

    await expect(page.locator('.error')).toContainText('パスワードは8文字以上である必要があります');
  });

  test('パスワード8文字(最小値)で成功', async ({ page }) => {
    await page.goto('https://example.com/login');
    await page.fill('#email', 'valid@example.com');
    await page.fill('#password', 'Pass123!'); // 8文字
    await page.click('#login-button');

    await expect(page).toHaveURL(/.*dashboard/);
  });

  // 空値テスト
  test('メールアドレスが空でエラー', async ({ page }) => {
    await page.goto('https://example.com/login');
    await page.fill('#password', 'password');
    await page.click('#login-button');

    await expect(page.locator('.error')).toContainText('メールアドレスは必須です');
  });

  test('両方が空でエラー', async ({ page }) => {
    await page.goto('https://example.com/login');
    await page.click('#login-button');

    await expect(page.locator('.error')).toBeVisible();
  });

  // 特殊文字
  test('特殊文字を含むパスワードで成功', async ({ page }) => {
    await page.goto('https://example.com/login');
    await page.fill('#email', 'valid@example.com');
    await page.fill('#password', 'P@$$w0rd!#'); // 特殊文字
    await page.click('#login-button');

    await expect(page).toHaveURL(/.*dashboard/);
  });
});

[!TIP] 優先順位付けのコツ

すべてのテストケースを実装する時間がない場合:

  1. 高優先度: 主要なビジネスフロー、セキュリティに関わる機能
  2. 中優先度: よく使われる機能、バグが多い箇所
  3. 低優先度: 使用頻度が低い機能、エッジケース

まとめ

  • 同値分割:入力値を同じ動作をするグループに分けて代表値をテスト
  • 境界値分析:範囲の境界付近の値を重点的にテスト
  • 組み合わせ:両方の手法を組み合わせることで効率的なテストを設計
  • 網羅性:機能、ユーザーフロー、条件分岐などを体系的にカバー
  • 優先順位:重要度に応じてテストケースに優先度を付ける
  • 効率性:限られたリソースで最大の効果を得るテスト設計を目指す

これらのテスト設計技法を活用することで、効率的かつ効果的なテストスイートを構築できます。