Agent Skills: E2E Testing with Playwright

Use when implementing E2E tests, debugging flaky tests, testing web applications with Playwright, or establishing E2E testing standards. Triggers on "e2e test", "end-to-end", "Playwright", "flaky test", "browser test".

UncategorizedID: v1-io/v1tamins/e2e-testing

Install this agent skill to your local

pnpm dlx add-skill https://github.com/v1-io/v1tamins/tree/HEAD/claude/skills/e2e-testing

Skill Files

Browse the full folder contents for e2e-testing.

Download Skill

Loading file tree…

claude/skills/e2e-testing/SKILL.md

Skill Metadata

Name
e2e-testing
Description
Use when implementing E2E tests, debugging flaky tests, testing web applications with Playwright, or establishing E2E testing standards. Triggers on "e2e test", "end-to-end", "Playwright", "flaky test", "browser test".

E2E Testing with Playwright

Build reliable, fast E2E test suites that catch regressions and enable confident deployments.

Quick Start

Test a local web application:

# If server not running, use the helper script
python scripts/with_server.py --server "npm run dev" --port 3000 -- python your_test.py

# If server already running, write Playwright directly

Basic Playwright test:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto('http://localhost:3000')
    page.wait_for_load_state('networkidle')  # CRITICAL: Wait for JS

    # Reconnaissance first
    page.screenshot(path='/tmp/inspect.png', full_page=True)

    # Then actions
    page.get_by_role('button', name='Login').click()
    browser.close()

When to Use E2E Tests

Good for:

  • Critical user journeys (login, checkout, signup)
  • Complex interactions (drag-and-drop, multi-step forms)
  • Cross-browser compatibility
  • Real API integration
  • Authentication flows

Not for:

  • Unit-level logic (use unit tests)
  • API contracts (use integration tests)
  • Every edge case (too slow)
  • Internal implementation details

Decision Tree

User task → Is it static HTML?
    ├─ Yes → Read HTML file directly for selectors
    │         └─ Write Playwright script using selectors
    │
    └─ No (dynamic webapp) → Is the server already running?
        ├─ No → Use scripts/with_server.py
        │
        └─ Yes → Reconnaissance-then-action:
            1. Navigate and wait for networkidle
            2. Take screenshot or inspect DOM
            3. Identify selectors from rendered state
            4. Execute actions with discovered selectors

Playwright Configuration

// 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"], ["github"]],
  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"] } },
  ],
});

Page Object Model

Encapsulate page logic in classes:

// pages/LoginPage.ts
import { Page, Locator } from "@playwright/test";

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

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

  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();
  }
}

// Usage in tests
test("successful login", async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login("user@example.com", "password123");
  await expect(page).toHaveURL("/dashboard");
});

Selectors (Priority Order)

  1. Role-based (best): page.getByRole('button', { name: 'Submit' })
  2. Label (for forms): page.getByLabel('Email')
  3. Placeholder: page.getByPlaceholder('Search...')
  4. Test ID: page.getByTestId('submit-button')
  5. Text: page.getByText('Welcome')
  6. CSS (avoid): page.locator('.btn-primary') - brittle!

Multi-candidate strategy for Auth0/iframes:

// Frame-aware selectors
const frame = page.frameLocator('[data-testid="auth0-frame"]');
await frame.getByRole('textbox', { name: 'Email' }).fill(email);

Waiting Strategies

// BAD: Fixed timeouts
await page.waitForTimeout(3000); // Flaky!

// GOOD: Wait for specific conditions
await page.waitForLoadState("networkidle");
await page.waitForURL("/dashboard");

// BETTER: Auto-waiting with assertions
await expect(page.getByText("Welcome")).toBeVisible();
await expect(page.getByRole("button")).toBeEnabled();

// Wait for API response
const responsePromise = page.waitForResponse(
  (res) => res.url().includes("/api/users") && res.status() === 200
);
await page.getByRole("button", { name: "Load" }).click();
await responsePromise;

Authentication

Storage state for pre-auth (recommended):

// global-setup.ts
async function globalSetup() {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.TEST_USER!);
  await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!);
  await page.getByRole("button", { name: "Login" }).click();
  await page.waitForURL("/dashboard");

  await page.context().storageState({ path: "auth.json" });
  await browser.close();
}

// playwright.config.ts
use: {
  storageState: "auth.json",
}

Debugging

# Headed mode
npx playwright test --headed

# Debug mode (step through)
npx playwright test --debug

# Pause in test
await page.pause();  # Opens inspector

# Trace viewer
npx playwright show-trace trace.zip

Add test steps for 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();
  });
});

Flaky Test Prevention

| Cause | Fix | |-------|-----| | Fixed timeouts | Use proper waits (networkidle, assertions) | | Race conditions | Wait for specific state before acting | | Test interdependence | Make tests independent, clean up data | | Stale selectors | Use role-based selectors, avoid CSS classes | | Animation interference | Wait for animations, disable in test mode |

Root cause checklist:

  • [ ] Using waitForTimeout() anywhere? Replace with proper waits
  • [ ] Tests share state? Add proper setup/teardown
  • [ ] Selectors rely on CSS classes? Use role/label/testid
  • [ ] Network timing issues? Wait for specific responses

Scripts Reference

scripts/with_server.py - Server lifecycle management

# Single server
python scripts/with_server.py --server "npm run dev" --port 3000 -- python test.py

# Multiple servers (backend + frontend)
python scripts/with_server.py \
  --server "cd backend && python server.py" --port 3000 \
  --server "cd frontend && npm run dev" --port 5173 \
  -- python test.py

Run python scripts/with_server.py --help first.

Common Pitfalls

  • Inspecting DOM before networkidle: Always wait for JS to execute on dynamic apps
  • Brittle CSS selectors: Avoid .btn.btn-primary, use roles
  • Tests depend on order: Each test must be independent
  • No cleanup: Create and destroy test data per test
  • Over-testing with E2E: Use unit tests for edge cases

Reference Files

For advanced Playwright patterns, see: