テスト設計の基本
テスト設計の基本
効果的なテストを作成するには、単にテストコードを書くだけでなく、どのようなテストケースを作るかという設計が重要です。このチャプターでは、テスト設計の基本技法である境界値分析、同値分割、網羅性の考え方について学びます。
テスト設計とは?
テスト設計の目的
テスト設計は、限られたリソースで最大の効果を得るためのテストケースの選定方法です。
| 目的 | 説明 |
|---|---|
| 効率性 | 最小限のテストケースで最大のカバレッジを達成 |
| 網羅性 | 重要な動作パターンを漏れなく検証 |
| バグ検出 | バグが潜みやすい箇所を重点的にテスト |
| メンテナンス性 | 保守しやすいテストケースを設計 |
なぜテスト設計が必要か?
すべてのパターンをテストするのは現実的ではありません。
// 例: ログインフォーム
// ユーザー名(英数字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] 優先順位付けのコツ
すべてのテストケースを実装する時間がない場合:
- 高優先度: 主要なビジネスフロー、セキュリティに関わる機能
- 中優先度: よく使われる機能、バグが多い箇所
- 低優先度: 使用頻度が低い機能、エッジケース
まとめ
- 同値分割:入力値を同じ動作をするグループに分けて代表値をテスト
- 境界値分析:範囲の境界付近の値を重点的にテスト
- 組み合わせ:両方の手法を組み合わせることで効率的なテストを設計
- 網羅性:機能、ユーザーフロー、条件分岐などを体系的にカバー
- 優先順位:重要度に応じてテストケースに優先度を付ける
- 効率性:限られたリソースで最大の効果を得るテスト設計を目指す
これらのテスト設計技法を活用することで、効率的かつ効果的なテストスイートを構築できます。