Files
gh-anton-abyzov-specweave-p…/commands/e2e-setup.md
2025-11-29 17:57:09 +08:00

28 KiB

/specweave-testing:e2e-setup

Set up comprehensive Playwright E2E testing with best practices, page objects, and CI/CD integration.

You are an expert E2E testing engineer who implements production-ready Playwright test suites.

Your Task

Set up a complete Playwright E2E testing framework with page objects, fixtures, and testing patterns.

1. Playwright Stack Features

Cross-Browser Testing:

  • Chromium, Firefox, WebKit support
  • Mobile viewport emulation
  • Device-specific testing (iPhone, Pixel, etc.)
  • Browser context isolation
  • Persistent state management

Reliability Features:

  • Auto-wait for elements
  • Network idle detection
  • Retry mechanisms
  • Screenshot/video on failure
  • Trace recording for debugging

Performance:

  • Parallel test execution
  • Sharding for CI/CD
  • Browser reuse
  • Worker threads
  • Test isolation

2. Advanced Playwright Configuration

playwright.config.ts (Production-Grade):

import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';

dotenv.config();

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,

  // Reporter configuration
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['json', { outputFile: 'test-results/results.json' }],
    ['junit', { outputFile: 'test-results/junit.xml' }],
    ['github'], // GitHub Actions annotations
  ],

  use: {
    // Base URL for navigation
    baseURL: process.env.BASE_URL || 'http://localhost:3000',

    // Tracing and debugging
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',

    // Timeouts
    actionTimeout: 10000,
    navigationTimeout: 30000,

    // Browser options
    viewport: { width: 1280, height: 720 },
    ignoreHTTPSErrors: true,

    // Collect HTTP Archive (HAR) files
    recordHar: process.env.CI ? undefined : { path: 'test-results/har' },
  },

  // Test timeout
  timeout: 30000,

  // Global setup/teardown
  globalSetup: require.resolve('./tests/e2e/global-setup.ts'),
  globalTeardown: require.resolve('./tests/e2e/global-teardown.ts'),

  projects: [
    // Setup project for authentication
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },

    // Desktop browsers
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'webkit',
      use: {
        ...devices['Desktop Safari'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },

    // Mobile browsers
    {
      name: 'mobile-chrome',
      use: {
        ...devices['Pixel 5'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'mobile-safari',
      use: {
        ...devices['iPhone 12'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },

    // Branded browsers
    {
      name: 'edge',
      use: {
        ...devices['Desktop Edge'],
        channel: 'msedge',
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'chrome',
      use: {
        ...devices['Desktop Chrome'],
        channel: 'chrome',
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],

  // Web server configuration
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
    stdout: 'pipe',
    stderr: 'pipe',
  },
});

3. Page Object Model (POM)

tests/e2e/pages/BasePage.ts:

import { Page, Locator } from '@playwright/test';

export abstract class BasePage {
  readonly page: Page;

  constructor(page: Page) {
    this.page = page;
  }

  // Common navigation
  async goto(path: string) {
    await this.page.goto(path);
  }

  // Wait helpers
  async waitForNetworkIdle() {
    await this.page.waitForLoadState('networkidle');
  }

  async waitForDomContentLoaded() {
    await this.page.waitForLoadState('domcontentloaded');
  }

  // Screenshot helpers
  async takeScreenshot(name: string) {
    await this.page.screenshot({
      path: `test-results/screenshots/${name}.png`,
      fullPage: true,
    });
  }

  // Cookie helpers
  async getCookies() {
    return await this.page.context().cookies();
  }

  async setCookies(cookies: any[]) {
    await this.page.context().addCookies(cookies);
  }

  // Local storage helpers
  async getLocalStorage(key: string): Promise<string | null> {
    return await this.page.evaluate((key) => {
      return localStorage.getItem(key);
    }, key);
  }

  async setLocalStorage(key: string, value: string) {
    await this.page.evaluate(({ key, value }) => {
      localStorage.setItem(key, value);
    }, { key, value });
  }

  // Common assertions
  async assertUrl(expectedUrl: string) {
    await this.page.waitForURL(expectedUrl);
  }

  async assertTitle(expectedTitle: string) {
    await this.page.waitForFunction(
      (title) => document.title === title,
      expectedTitle
    );
  }
}

tests/e2e/pages/LoginPage.ts:

import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';

export class LoginPage extends BasePage {
  // Locators
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;
  readonly rememberMeCheckbox: Locator;
  readonly forgotPasswordLink: Locator;

  constructor(page: Page) {
    super(page);
    this.emailInput = page.locator('input[name="email"]');
    this.passwordInput = page.locator('input[name="password"]');
    this.loginButton = page.locator('button[type="submit"]');
    this.errorMessage = page.locator('[role="alert"]');
    this.rememberMeCheckbox = page.locator('input[name="rememberMe"]');
    this.forgotPasswordLink = page.locator('a[href="/forgot-password"]');
  }

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

  async login(email: string, password: string, rememberMe = false) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);

    if (rememberMe) {
      await this.rememberMeCheckbox.check();
    }

    await this.loginButton.click();
  }

  async loginAsAdmin() {
    await this.login(
      process.env.ADMIN_EMAIL || 'admin@example.com',
      process.env.ADMIN_PASSWORD || 'admin123'
    );
  }

  async loginAsUser() {
    await this.login(
      process.env.USER_EMAIL || 'user@example.com',
      process.env.USER_PASSWORD || 'user123'
    );
  }

  // Assertions
  async assertErrorMessage(expectedMessage: string) {
    await expect(this.errorMessage).toContainText(expectedMessage);
  }

  async assertLoginSuccessful() {
    await this.page.waitForURL(/\/(dashboard|home)/);
  }

  async assertOnLoginPage() {
    await expect(this.page).toHaveURL(/\/login/);
  }
}

tests/e2e/pages/DashboardPage.ts:

import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';

export class DashboardPage extends BasePage {
  readonly welcomeMessage: Locator;
  readonly userMenu: Locator;
  readonly logoutButton: Locator;
  readonly notificationBadge: Locator;
  readonly searchInput: Locator;

  constructor(page: Page) {
    super(page);
    this.welcomeMessage = page.locator('h1');
    this.userMenu = page.locator('[data-testid="user-menu"]');
    this.logoutButton = page.locator('button:has-text("Logout")');
    this.notificationBadge = page.locator('[data-testid="notification-badge"]');
    this.searchInput = page.locator('input[placeholder="Search..."]');
  }

  async navigate() {
    await this.goto('/dashboard');
  }

  async logout() {
    await this.userMenu.click();
    await this.logoutButton.click();
  }

  async search(query: string) {
    await this.searchInput.fill(query);
    await this.searchInput.press('Enter');
  }

  async getNotificationCount(): Promise<number> {
    const text = await this.notificationBadge.textContent();
    return parseInt(text || '0', 10);
  }

  async assertWelcomeMessage(username: string) {
    await expect(this.welcomeMessage).toContainText(`Welcome, ${username}`);
  }

  async assertOnDashboard() {
    await expect(this.page).toHaveURL(/\/dashboard/);
  }
}

4. Custom Fixtures

tests/e2e/fixtures/auth.fixture.ts:

import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

type AuthFixtures = {
  authenticatedPage: Page;
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();
    await loginPage.loginAsUser();
    await use(page);
  },

  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await use(loginPage);
  },

  dashboardPage: async ({ page }, use) => {
    const dashboardPage = new DashboardPage(page);
    await use(dashboardPage);
  },
});

export { expect } from '@playwright/test';

tests/e2e/fixtures/api.fixture.ts:

import { test as base, APIRequestContext } from '@playwright/test';

type ApiFixtures = {
  apiContext: APIRequestContext;
};

export const test = base.extend<ApiFixtures>({
  apiContext: async ({ playwright }, use) => {
    const context = await playwright.request.newContext({
      baseURL: process.env.API_BASE_URL || 'http://localhost:3000/api',
      extraHTTPHeaders: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
      },
    });
    await use(context);
    await context.dispose();
  },
});

export { expect } from '@playwright/test';

5. Global Setup and Teardown

tests/e2e/global-setup.ts:

import { chromium, FullConfig } from '@playwright/test';
import path from 'path';
import fs from 'fs';

async function globalSetup(config: FullConfig) {
  const { baseURL, storageState } = config.projects[0].use;

  // Create auth directory
  const authDir = path.dirname(storageState as string);
  if (!fs.existsSync(authDir)) {
    fs.mkdirSync(authDir, { recursive: true });
  }

  // Launch browser and authenticate
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  try {
    // Navigate to login
    await page.goto(`${baseURL}/login`);

    // Perform login
    await page.fill('input[name="email"]', process.env.USER_EMAIL || 'test@example.com');
    await page.fill('input[name="password"]', process.env.USER_PASSWORD || 'password123');
    await page.click('button[type="submit"]');

    // Wait for successful login
    await page.waitForURL(/\/(dashboard|home)/);

    // Save authentication state
    await context.storageState({ path: storageState as string });

    console.log('✓ Global setup: Authentication completed');
  } catch (error) {
    console.error('✗ Global setup: Authentication failed', error);
    throw error;
  } finally {
    await browser.close();
  }
}

export default globalSetup;

tests/e2e/global-teardown.ts:

import { FullConfig } from '@playwright/test';
import fs from 'fs';
import path from 'path';

async function globalTeardown(config: FullConfig) {
  try {
    // Clean up auth state
    const authDir = 'playwright/.auth';
    if (fs.existsSync(authDir)) {
      fs.rmSync(authDir, { recursive: true });
      console.log('✓ Global teardown: Cleaned auth state');
    }

    // Clean up test artifacts if not in CI
    if (!process.env.CI) {
      const artifactDirs = ['test-results', 'playwright-report'];
      artifactDirs.forEach(dir => {
        if (fs.existsSync(dir)) {
          const files = fs.readdirSync(dir);
          console.log(`✓ Global teardown: ${dir} contains ${files.length} files`);
        }
      });
    }
  } catch (error) {
    console.error('✗ Global teardown: Cleanup failed', error);
  }
}

export default globalTeardown;

6. Authentication Setup Tests

tests/e2e/auth.setup.ts:

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

const authFile = path.join(__dirname, '../../playwright/.auth/user.json');

setup('authenticate', async ({ page }) => {
  // Navigate to login page
  await page.goto('/login');

  // Perform login
  await page.fill('input[name="email"]', process.env.USER_EMAIL || 'test@example.com');
  await page.fill('input[name="password"]', process.env.USER_PASSWORD || 'password123');
  await page.click('button[type="submit"]');

  // Wait for successful navigation
  await page.waitForURL(/\/(dashboard|home)/);

  // Verify authentication succeeded
  await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();

  // Save signed-in state
  await page.context().storageState({ path: authFile });
});

setup('admin authenticate', async ({ page }) => {
  await page.goto('/login');

  await page.fill('input[name="email"]', process.env.ADMIN_EMAIL || 'admin@example.com');
  await page.fill('input[name="password"]', process.env.ADMIN_PASSWORD || 'admin123');
  await page.click('button[type="submit"]');

  await page.waitForURL(/\/(dashboard|admin)/);
  await expect(page.locator('[data-testid="admin-menu"]')).toBeVisible();

  await page.context().storageState({
    path: path.join(__dirname, '../../playwright/.auth/admin.json')
  });
});

7. Test Utilities

tests/e2e/utils/test-helpers.ts:

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

export class TestHelpers {
  static async waitForApiResponse(page: Page, urlPattern: string | RegExp, timeout = 5000) {
    return await page.waitForResponse(
      response => {
        const url = response.url();
        const matches = typeof urlPattern === 'string'
          ? url.includes(urlPattern)
          : urlPattern.test(url);
        return matches && response.status() === 200;
      },
      { timeout }
    );
  }

  static async interceptApi(page: Page, urlPattern: string | RegExp, mockResponse: any) {
    await page.route(urlPattern, route => {
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify(mockResponse),
      });
    });
  }

  static async blockRequests(page: Page, resourceTypes: string[]) {
    await page.route('**/*', route => {
      if (resourceTypes.includes(route.request().resourceType())) {
        route.abort();
      } else {
        route.continue();
      }
    });
  }

  static async mockGeolocation(page: Page, latitude: number, longitude: number) {
    await page.context().setGeolocation({ latitude, longitude });
    await page.context().grantPermissions(['geolocation']);
  }

  static async fillForm(page: Page, formData: Record<string, string>) {
    for (const [name, value] of Object.entries(formData)) {
      await page.fill(`[name="${name}"]`, value);
    }
  }

  static async selectDropdown(page: Page, selector: string, value: string) {
    await page.selectOption(selector, value);
  }

  static async uploadFile(page: Page, selector: string, filePath: string) {
    await page.setInputFiles(selector, filePath);
  }

  static async scrollToElement(page: Page, selector: string) {
    await page.locator(selector).scrollIntoViewIfNeeded();
  }

  static async assertAccessibility(page: Page) {
    // Basic accessibility checks
    const violations = await page.evaluate(() => {
      const issues: string[] = [];

      // Check for alt text on images
      document.querySelectorAll('img').forEach(img => {
        if (!img.alt) {
          issues.push(`Image missing alt text: ${img.src}`);
        }
      });

      // Check for form labels
      document.querySelectorAll('input, textarea, select').forEach(input => {
        const id = input.getAttribute('id');
        if (id && !document.querySelector(`label[for="${id}"]`)) {
          issues.push(`Form element missing label: ${id}`);
        }
      });

      return issues;
    });

    expect(violations).toHaveLength(0);
  }
}

tests/e2e/utils/mock-data.ts:

export const mockUsers = [
  {
    id: '1',
    email: 'john@example.com',
    name: 'John Doe',
    role: 'user',
  },
  {
    id: '2',
    email: 'admin@example.com',
    name: 'Admin User',
    role: 'admin',
  },
];

export const mockProducts = [
  {
    id: '1',
    name: 'Product A',
    price: 29.99,
    inStock: true,
  },
  {
    id: '2',
    name: 'Product B',
    price: 49.99,
    inStock: false,
  },
];

export function createMockApiResponse(data: any, delay = 0) {
  return {
    body: JSON.stringify({ data, success: true }),
    status: 200,
    headers: { 'Content-Type': 'application/json' },
    delay,
  };
}

8. Example E2E Tests

tests/e2e/auth/login.spec.ts:

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

test.describe('Login Flow', () => {
  let loginPage: LoginPage;
  let dashboardPage: DashboardPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    dashboardPage = new DashboardPage(page);
    await loginPage.navigate();
  });

  test('should login with valid credentials', async ({ page }) => {
    await loginPage.login('test@example.com', 'password123');
    await dashboardPage.assertOnDashboard();
    await dashboardPage.assertWelcomeMessage('Test User');
  });

  test('should show error for invalid credentials', async () => {
    await loginPage.login('wrong@example.com', 'wrongpassword');
    await loginPage.assertErrorMessage('Invalid credentials');
    await loginPage.assertOnLoginPage();
  });

  test('should validate empty fields', async () => {
    await loginPage.loginButton.click();

    await expect(loginPage.emailInput).toHaveAttribute('aria-invalid', 'true');
    await expect(loginPage.passwordInput).toHaveAttribute('aria-invalid', 'true');
  });

  test('should remember user when checkbox checked', async ({ page }) => {
    await loginPage.login('test@example.com', 'password123', true);
    await dashboardPage.assertOnDashboard();

    const cookies = await page.context().cookies();
    const rememberMeCookie = cookies.find(c => c.name === 'rememberMe');
    expect(rememberMeCookie).toBeDefined();
  });

  test('should navigate to forgot password', async ({ page }) => {
    await loginPage.forgotPasswordLink.click();
    await expect(page).toHaveURL('/forgot-password');
  });
});

tests/e2e/api/users.spec.ts:

import { test, expect } from '../fixtures/api.fixture';

test.describe('User API', () => {
  test('should fetch users list', async ({ apiContext }) => {
    const response = await apiContext.get('/users');
    expect(response.ok()).toBeTruthy();

    const data = await response.json();
    expect(data).toHaveProperty('users');
    expect(Array.isArray(data.users)).toBeTruthy();
  });

  test('should create new user', async ({ apiContext }) => {
    const newUser = {
      email: 'newuser@example.com',
      name: 'New User',
      role: 'user',
    };

    const response = await apiContext.post('/users', { data: newUser });
    expect(response.ok()).toBeTruthy();

    const data = await response.json();
    expect(data.user).toMatchObject(newUser);
    expect(data.user.id).toBeDefined();
  });

  test('should update user', async ({ apiContext }) => {
    const updates = { name: 'Updated Name' };

    const response = await apiContext.patch('/users/1', { data: updates });
    expect(response.ok()).toBeTruthy();

    const data = await response.json();
    expect(data.user.name).toBe(updates.name);
  });

  test('should delete user', async ({ apiContext }) => {
    const response = await apiContext.delete('/users/1');
    expect(response.ok()).toBeTruthy();

    // Verify deletion
    const getResponse = await apiContext.get('/users/1');
    expect(getResponse.status()).toBe(404);
  });

  test('should handle authentication errors', async ({ apiContext }) => {
    const response = await apiContext.get('/users/protected', {
      headers: { 'Authorization': 'Bearer invalid-token' },
    });
    expect(response.status()).toBe(401);
  });
});

9. Visual Regression Testing

tests/e2e/visual/homepage.spec.ts:

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

test.describe('Visual Regression', () => {
  test('homepage should match snapshot', async ({ page }) => {
    await page.goto('/');
    await page.waitForLoadState('networkidle');

    await expect(page).toHaveScreenshot('homepage.png', {
      fullPage: true,
      maxDiffPixels: 100,
    });
  });

  test('login page should match snapshot', async ({ page }) => {
    await page.goto('/login');

    await expect(page).toHaveScreenshot('login.png', {
      mask: [page.locator('.timestamp')], // Mask dynamic content
    });
  });

  test('mobile viewport should match snapshot', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/');

    await expect(page).toHaveScreenshot('homepage-mobile.png');
  });
});

10. Performance Testing

tests/e2e/performance/metrics.spec.ts:

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

test.describe('Performance Metrics', () => {
  test('should meet Core Web Vitals thresholds', async ({ page }) => {
    await page.goto('/');

    const metrics = await page.evaluate(() => {
      return new Promise((resolve) => {
        new PerformanceObserver((list) => {
          const entries = list.getEntries();
          const values = entries.reduce((acc, entry) => {
            acc[entry.name] = entry.value;
            return acc;
          }, {} as Record<string, number>);
          resolve(values);
        }).observe({ entryTypes: ['measure', 'navigation'] });
      });
    });

    // Verify performance metrics
    expect(metrics['first-contentful-paint']).toBeLessThan(1800); // FCP < 1.8s
    expect(metrics['largest-contentful-paint']).toBeLessThan(2500); // LCP < 2.5s
  });

  test('should load page within 3 seconds', async ({ page }) => {
    const startTime = Date.now();
    await page.goto('/');
    await page.waitForLoadState('load');
    const loadTime = Date.now() - startTime;

    expect(loadTime).toBeLessThan(3000);
  });

  test('should have minimal bundle size', async ({ page }) => {
    const response = await page.goto('/');
    const transferSize = await response?.request().sizes().then(s => s.responseBodySize);

    expect(transferSize).toBeLessThan(500000); // < 500KB
  });
});

11. CI/CD Integration

GitHub Actions (.github/workflows/e2e.yml):

name: E2E Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    strategy:
      matrix:
        browser: [chromium, firefox, webkit]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright Browsers
        run: npx playwright install --with-deps ${{ matrix.browser }}

      - name: Run Playwright tests
        run: npx playwright test --project=${{ matrix.browser }}
        env:
          BASE_URL: http://localhost:3000
          USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
          USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-report-${{ matrix.browser }}
          path: playwright-report/
          retention-days: 30

      - name: Upload test videos
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: test-videos-${{ matrix.browser }}
          path: test-results/**/video.webm
          retention-days: 7

  test-sharded:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shardIndex: [1, 2, 3, 4]
        shardTotal: [4]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright
        run: npx playwright install --with-deps

      - name: Run sharded tests
        run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}

      - name: Upload blob report
        uses: actions/upload-artifact@v3
        with:
          name: blob-report-${{ matrix.shardIndex }}
          path: blob-report
          retention-days: 1

  merge-reports:
    if: always()
    needs: [test-sharded]
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4

      - name: Download all reports
        uses: actions/download-artifact@v3
        with:
          path: all-blob-reports

      - name: Merge reports
        run: npx playwright merge-reports --reporter html ./all-blob-reports

      - name: Upload merged report
        uses: actions/upload-artifact@v3
        with:
          name: playwright-report
          path: playwright-report/

12. Package Dependencies

{
  "devDependencies": {
    "@playwright/test": "^1.40.0",
    "dotenv": "^16.3.1",
    "playwright": "^1.40.0"
  },
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:debug": "playwright test --debug",
    "test:e2e:headed": "playwright test --headed",
    "test:e2e:chromium": "playwright test --project=chromium",
    "test:e2e:firefox": "playwright test --project=firefox",
    "test:e2e:webkit": "playwright test --project=webkit",
    "test:e2e:mobile": "playwright test --project=mobile-chrome --project=mobile-safari",
    "test:e2e:report": "playwright show-report",
    "test:e2e:codegen": "playwright codegen"
  }
}

13. Environment Configuration

.env.example:

# Base URLs
BASE_URL=http://localhost:3000
API_BASE_URL=http://localhost:3000/api

# Test credentials
USER_EMAIL=test@example.com
USER_PASSWORD=password123
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=admin123

# CI/CD
CI=false
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=false

14. Best Practices

Test Organization:

  • Group tests by feature/domain
  • Use descriptive test names
  • One assertion per test (when possible)
  • Use page objects for reusability
  • Keep tests independent

Reliability:

  • Use auto-waiting features
  • Avoid hard-coded waits (page.waitForTimeout)
  • Use data-testid attributes
  • Retry flaky tests automatically
  • Clean up test data

Performance:

  • Run tests in parallel
  • Use test sharding for large suites
  • Reuse authentication state
  • Block unnecessary resources
  • Use fast selectors (data-testid > text)

Debugging:

  • Use Playwright Inspector (--debug)
  • Record traces on failure
  • Take screenshots on failure
  • Use UI mode for interactive debugging
  • Check network logs

Workflow

  1. Ask about application type and E2E requirements
  2. Install Playwright and dependencies
  3. Create playwright.config.ts with all browsers
  4. Set up page object model structure
  5. Create custom fixtures for auth and API
  6. Implement global setup/teardown
  7. Create authentication setup tests
  8. Generate test utilities and helpers
  9. Write example E2E test suite
  10. Configure CI/CD pipeline
  11. Set up visual regression testing
  12. Add performance testing
  13. Provide debugging and maintenance guide

When to Use

  • Setting up E2E testing from scratch
  • Migrating from Selenium/Cypress to Playwright
  • Adding cross-browser testing
  • Implementing visual regression testing
  • Setting up CI/CD for E2E tests
  • Adding performance testing
  • Creating page object model architecture
  • Implementing authentication testing

Set up production-ready Playwright E2E testing with comprehensive coverage!