mcpField
E
e2e-testing-patterns

e2e-testing-patterns

github.com/ovachieverテスト・品質5653 回インストール48 週間前に更新
リポジトリ

e2e-testing-patterns について

e2e-testing-patternsは、エンドツーエンドテストの構築・保守に取り組む開発者向けのスキルです。PlaywrightとCypressのパターンに加え、待機、APIモック、ビジュアル検証、並列実行、アクセシビリティの指針を提供します。

主な機能

  1. 01Playwright・Cypressのテストパターン
  2. 02ページオブジェクトと再利用可能なフィクスチャ
  3. 03条件待機とAPIモック
  4. 04ビジュアル回帰テストと並列実行
  5. 05アクセシビリティ検査と不安定テストの調査

活用シーン

  1. 01重要なユーザーフローの自動化
  2. 02不安定なテストの原因調査
  3. 03ブラウザー間のテスト
  4. 04アクセシビリティとレスポンシブ表示の確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/ovachiever/droid-tings --skill e2e-testing-patterns

インストール後、Claude Code で/e2e-testing-patternsを入力して読み込まれたか確認します。

詳細

E2E テストのパターン

コードをすばやくリリースするための確信を高め、ユーザーが問題に遭遇する前にリグレッションを検出できる、信頼性が高く、高速で、保守しやすいエンドツーエンドテストスイートを構築します。

このスキルを使用する場面

  • エンドツーエンドテストの自動化を実装するとき
  • 不安定または信頼性の低いテストをデバッグするとき
  • 重要なユーザーワークフローをテストするとき
  • CI/CD テストパイプラインを設定するとき
  • 複数のブラウザーでテストするとき
  • アクセシビリティ要件を検証するとき
  • レスポンシブデザインをテストするとき
  • E2E テストの標準を確立するとき

主な概念

1. E2E テストの基本

E2E でテストする対象:

  • 重要なユーザージャーニー(ログイン、購入手続き、登録)
  • 複雑な操作(ドラッグアンドドロップ、複数ステップのフォーム)
  • クロスブラウザー互換性
  • 実際の API 連携
  • 認証フロー

E2E でテストしない対象:

  • ユニットレベルのロジック(ユニットテストを使用)
  • API コントラクト(結合テストを使用)
  • エッジケース(時間がかかりすぎます)
  • 内部実装の詳細
2. テストの考え方

テストピラミッド:

        /\
       /E2E\         ← Few, focused on critical paths
      /─────\
     /Integr\        ← More, test component interactions
    /────────\
   /Unit Tests\      ← Many, fast, isolated
  /────────────\

ベストプラクティス:

  • 実装ではなく、ユーザーの操作をテストします
  • テストを独立させます
  • テストを決定論的にします
  • 高速化を優先します
  • CSS セレクターではなく、data-testid を使用します

Playwright のパターン

セットアップと設定
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
    testDir: './e2e',
    timeout: 30000,
    expect: {
        timeout: 5000,
    },
    fullyParallel: true,
    forbidOnly: !!process.env.CI,
    retries: process.env.CI ? 2 : 0,
    workers: process.env.CI ? 1 : undefined,
    reporter: [
        ['html'],
        ['junit', { outputFile: 'results.xml' }],
    ],
    use: {
        baseURL: 'http://localhost:3000',
        trace: 'on-first-retry',
        screenshot: 'only-on-failure',
        video: 'retain-on-failure',
    },
    projects: [
        { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
        { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
        { name: 'webkit', use: { ...devices['Desktop Safari'] } },
        { name: 'mobile', use: { ...devices['iPhone 13'] } },
    ],
});
パターン 1:ページオブジェクトモデル
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
    readonly page: Page;
    readonly emailInput: Locator;
    readonly passwordInput: Locator;
    readonly loginButton: Locator;
    readonly errorMessage: Locator;

    constructor(page: Page) {
        this.page = page;
        this.emailInput = page.getByLabel('Email');
        this.passwordInput = page.getByLabel('Password');
        this.loginButton = page.getByRole('button', { name: 'Login' });
        this.errorMessage = page.getByRole('alert');
    }

    async goto() {
        await this.page.goto('/login');
    }

    async login(email: string, password: string) {
        await this.emailInput.fill(email);
        await this.passwordInput.fill(password);
        await this.loginButton.click();
    }

    async getErrorMessage(): Promise<string> {
        return await this.errorMessage.textContent() ?? '';
    }
}

// Test using Page Object
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('successful login', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('[email protected]', 'password123');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByRole('heading', { name: 'Dashboard' }))
        .toBeVisible();
});

test('failed login shows error', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('[email protected]', 'wrong');

    const error = await loginPage.getErrorMessage();
    expect(error).toContain('Invalid credentials');
});
パターン 2:テストデータ用フィクスチャ
// fixtures/test-data.ts
import { test as base } from '@playwright/test';

type TestData = {
    testUser: {
        email: string;
        password: string;
        name: string;
    };
    adminUser: {
        email: string;
        password: string;
    };
};

export const test = base.extend<TestData>({
    testUser: async ({}, use) => {
        const user = {
            email: `test-${Date.now()}@example.com`,
            password: 'Test123!@#',
            name: 'Test User',
        };
        // Setup: Create user in database
        await createTestUser(user);
        await use(user);
        // Teardown: Clean up user
        await deleteTestUser(user.email);
    },

    adminUser: async ({}, use) => {
        await use({
            email: '[email protected]',
            password: process.env.ADMIN_PASSWORD!,
        });
    },
});

// Usage in tests
import { test } from './fixtures/test-data';

test('user can update profile', async ({ page, testUser }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(testUser.email);
    await page.getByLabel('Password').fill(testUser.password);
    await page.getByRole('button', { name: 'Login' }).click();

    await page.goto('/profile');
    await page.getByLabel('Name').fill('Updated Name');
    await page.getByRole('button', { name: 'Save' }).click();

    await expect(page.getByText('Profile updated')).toBeVisible();
});
パターン 3:待機戦略
// ❌ Bad: Fixed timeouts
await page.waitForTimeout(3000);  // Flaky!

// ✅ Good: Wait for specific conditions
await page.waitForLoadState('networkidle');
await page.waitForURL('/dashboard');
await page.waitForSelector('[data-testid="user-profile"]');

// ✅ Better: Auto-waiting with assertions
await expect(page.getByText('Welcome')).toBeVisible();
await expect(page.getByRole('button', { name: 'Submit' }))
    .toBeEnabled();

// Wait for API response
const responsePromise = page.waitForResponse(
    response => response.url().includes('/api/users') && response.status() === 200
);
await page.getByRole('button', { name: 'Load Users' }).click();
const response = await responsePromise;
const data = await response.json();
expect(data.users).toHaveLength(10);

// Wait for multiple conditions
await Promise.all([
    page.waitForURL('/success'),
    page.waitForLoadState('networkidle'),
    expect(page.getByText('Payment successful')).toBeVisible(),
]);
パターン 4:ネットワークのモックとインターセプト
// Mock API responses
test('displays error when API fails', async ({ page }) => {
    await page.route('**/api/users', route => {
        route.fulfill({
            status: 500,
            contentType: 'application/json',
            body: JSON.stringify({ error: 'Internal Server Error' }),
        });
    });

    await page.goto('/users');
    await expect(page.getByText('Failed to load users')).toBeVisible();
});

// Intercept and modify requests
test('can modify API request', async ({ page }) => {
    await page.route('**/api/users', async route => {
        const request = route.request();
        const postData = JSON.parse(request.postData() || '{}');

        // Modify request
        postData.role = 'admin';

        await route.continue({
            postData: JSON.stringify(postData),
        });
    });

    // Test continues...
});

// Mock third-party services
test('payment flow with mocked Stripe', async ({ page }) => {
    await page.route('**/api/stripe/**', route => {
        route.fulfill({
            status: 200,
            body: JSON.stringify({
                id: 'mock_payment_id',
                status: 'succeeded',
            }),
        });
    });

    // Test payment flow with mocked response
});

Cypress のパターン

セットアップと設定
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
    e2e: {
        baseUrl: 'http://localhost:3000',
        viewportWidth: 1280,
        viewportHeight: 720,
        video: false,
        screenshotOnRunFailure: true,
        defaultCommandTimeout: 10000,
        requestTimeout: 10000,
        setupNodeEvents(on, config) {
            // Implement node event listeners
        },
    },
});
パターン 1:カスタムコマンド
// cypress/support/commands.ts
declare global {
    namespace Cypress {
        interface Chainable {
            login(email: string, password: string): Chainable<void>;
            createUser(userData: UserData): Chainable<User>;
            dataCy(value: string): Chainable<JQuery<HTMLElement>>;
        }
    }
}

Cypress.Commands.add('login', (email: string, password: string) => {
    cy.visit('/login');
    cy.get('[data-testid="email"]').type(email);
    cy.get('[data-testid="password"]').type(password);
    cy.get('[data-testid="login-button"]').click();
    cy.url().should('include', '/dashboard');
});

Cypress.Commands.add('createUser', (userData: UserData) => {
    return cy.request('POST', '/api/users', userData)
        .its('body');
});

Cypress.Commands.add('dataCy', (value: string) => {
    return cy.get(`[data-cy="${value}"]`);
});

// Usage
cy.login('[email protected]', 'password');
cy.dataCy('submit-button').click();
パターン 2:Cypress Intercept
// Mock API calls
cy.intercept('GET', '/api/users', {
    statusCode: 200,
    body: [
        { id: 1, name: 'John' },
        { id: 2, name: 'Jane' },
    ],
}).as('getUsers');

cy.visit('/users');
cy.wait('@getUsers');
cy.get('[data-testid="user-list"]').children().should('have.length', 2);

// Modify responses
cy.intercept('GET', '/api/users', (req) => {
    req.reply((res) => {
        // Modify response
        res.body.users = res.body.users.slice(0, 5);
        res.send();
    });
});

// Simulate slow network
cy.intercept('GET', '/api/data', (req) => {
    req.reply((res) => {
        res.delay(3000);  // 3 second delay
        res.send();
    });
});

高度なパターン

パターン 1:ビジュアルリグレッションテスト
// With Playwright
import { test, expect } from '@playwright/test';

test('homepage looks correct', async ({ page }) => {
    await page.goto('/');
    await expect(page).toHaveScreenshot('homepage.png', {
        fullPage: true,
        maxDiffPixels: 100,
    });
});

test('button in all states', async ({ page }) => {
    await page.goto('/components');

    const button = page.getByRole('button', { name: 'Submit' });

    // Default state
    await expect(button).toHaveScreenshot('button-default.png');

    // Hover state
    await button.hover();
    await expect(button).toHaveScreenshot('button-hover.png');

    // Disabled state
    await button.evaluate(el => el.setAttribute('disabled', 'true'));
    await expect(button).toHaveScreenshot('button-disabled.png');
});
パターン 2:シャーディングによる並列テスト
// playwright.config.ts
export default defineConfig({
    projects: [
        {
            name: 'shard-1',
            use: { ...devices['Desktop Chrome'] },
            grepInvert: /@slow/,
            shard: { current: 1, total: 4 },
        },
        {
            name: 'shard-2',
            use: { ...devices['Desktop Chrome'] },
            shard: { current: 2, total: 4 },
        },
        // ... more shards
    ],
});

// Run in CI
// npx playwright test --shard=1/4
// npx playwright test --shard=2/4
パターン 3:アクセシビリティテスト
// Install: npm install @axe-core/playwright
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('page should not have accessibility violations', async ({ page }) => {
    await page.goto('/');

    const accessibilityScanResults = await new AxeBuilder({ page })
        .exclude('#third-party-widget')
        .analyze();

    expect(accessibilityScanResults.violations).toEqual([]);
});

test('form is accessible', async ({ page }) => {
    await page.goto('/signup');

    const results = await new AxeBuilder({ page })
        .include('form')
        .analyze();

    expect(results.violations).toEqual([]);
});

ベストプラクティス

  1. データ属性を使用する:安定したセレクターには data-testid または data-cy を使用します
  2. 壊れやすいセレクターを避ける:CSS クラスや DOM 構造に依存しないでください
  3. ユーザーの操作をテストする:実装の詳細ではなく、クリック、入力、表示をテストします
  4. テストを独立させる:各テストは単独で実行できるようにします
  5. テストデータをクリーンアップする:各テストでテストデータを作成し、削除します
  6. ページオブジェクトを使用する:ページのロジックをカプセル化します
  7. 意味のあるアサーションを行う:実際にユーザーから見える動作を確認します
  8. 高速化を優先する:可能な場合はモックを使用し、並列実行します
// ❌ Bad selectors
cy.get('.btn.btn-primary.submit-button').click();
cy.get('div > form > div:nth-child(2) > input').type('text');

// ✅ Good selectors
cy.getByRole('button', { name: 'Submit' }).click();
cy.getByLabel('Email address').type('[email protected]');
cy.get('[data-testid="email-input"]').type('[email protected]');

よくある落とし穴

  • 不安定なテスト:固定タイムアウトではなく、適切な待機処理を使用します
  • 遅いテスト:外部 API をモックし、並列実行を使用します
  • 過剰なテスト:あらゆるエッジケースを E2E でテストしないでください
  • 依存し合うテスト:テスト同士が依存しないようにします
  • 不適切なセレクター:CSS クラスや nth-child は避けます
  • クリーンアップの不足:各テストの後にテストデータをクリーンアップします
  • 実装のテスト:内部処理ではなく、ユーザーの操作をテストします

失敗するテストのデバッグ

// Playwright debugging
// 1. Run in headed mode
npx playwright test --headed

// 2. Run in debug mode
npx playwright test --debug

// 3. Use trace viewer
await page.screenshot({ path: 'screenshot.png' });
await page.video()?.saveAs('video.webm');

// 4. Add test.step for better reporting
test('checkout flow', async ({ page }) => {
    await test.step('Add item to cart', async () => {
        await page.goto('/products');
        await page.getByRole('button', { name: 'Add to Cart' }).click();
    });

    await test.step('Proceed to checkout', async () => {
        await page.goto('/cart');
        await page.getByRole('button', { name: 'Checkout' }).click();
    });
});

// 5. Inspect page state
await page.pause();  // Pauses execution, opens inspector

リソース

  • references/playwright-best-practices.md:Playwright 固有のパターン
  • references/cypress-best-practices.md:Cypress 固有のパターン
  • references/flaky-test-debugging.md:信頼性の低いテストのデバッグ
  • assets/e2e-testing-checklist.md:E2E でテストする項目
  • assets/selector-strategies.md:信頼性の高いセレクターの見つけ方
  • scripts/test-analyzer.ts:テストの不安定さと所要時間を分析

コメント