Install
$ agentstack add skill-noah-sheldon-ai-dev-kit-e2e-testing ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
E2E Testing with Playwright
Comprehensive E2E testing patterns using Playwright. Covers page object model, test fixtures, parallel execution, flaky test prevention, visual regression, API mocking, and cross-browser validation. Use this skill when writing or maintaining E2E tests for web applications.
When to Use
- Writing new E2E tests for critical user flows
- Converting manual QA scenarios to automated tests
- Debugging flaky E2E tests
- Setting up cross-browser test infrastructure
- Implementing visual regression testing
- Mocking external APIs in end-to-end scenarios
- Establishing E2E testing standards for a project
Core Concepts
Critical User Flows
Not every path needs an E2E test. Focus on flows that represent real user journeys:
| Priority | Flow | Example | |---|---|---| | P0 | Authentication | Sign up → verify email → log in | | P0 | Core transaction | Add to cart → checkout → payment confirmation | | P1 | CRUD operations | Create item → edit → delete → verify removal | | P1 | Search and filter | Search → apply filter → verify results | | P2 | Settings and profile | Update profile → save → verify persistence | | P3 | Edge cases | Session timeout → redirect → re-auth |
P0 and P1 flows should be covered by E2E tests. P2 and P3 are optional based on project scope.
Page Object Model (POM)
Encapsulate page interactions in reusable, maintainable classes:
// tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.getByLabel('Email')
this.passwordInput = page.getByLabel('Password')
this.submitButton = page.getByRole('button', { name: 'Sign In' })
this.errorMessage = page.getByTestId('login-error')
}
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.submitButton.click()
}
async expectLoggedInUser(name: string) {
await expect(this.page.getByText(name)).toBeVisible()
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message)
}
}
// tests/pages/DashboardPage.ts
import { Page, Locator, expect } from '@playwright/test'
export class DashboardPage {
readonly page: Page
readonly welcomeHeading: Locator
readonly createItemButton: Locator
readonly itemList: Locator
constructor(page: Page) {
this.page = page
this.welcomeHeading = page.getByRole('heading', { name: /welcome/i })
this.createItemButton = page.getByRole('button', { name: 'Create Item' })
this.itemList = page.getByTestId('item-list')
}
async goto() {
await this.page.goto('/dashboard')
}
async createItem(name: string) {
await this.createItemButton.click()
await this.page.getByLabel('Item Name').fill(name)
await this.page.getByRole('button', { name: 'Save' }).click()
}
async expectItemExists(name: string) {
await expect(this.itemList.getByText(name)).toBeVisible()
}
}
// tests/e2e/auth-flow.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from '../pages/LoginPage'
import { DashboardPage } from '../pages/DashboardPage'
test.describe('Authentication Flow', () => {
test('user can log in and access dashboard', async ({ page }) => {
const loginPage = new LoginPage(page)
const dashboardPage = new DashboardPage(page)
await loginPage.goto()
await loginPage.login('alice@example.com', 'correct-password')
await dashboardPage.expectLoggedInUser('Alice')
await expect(page).toHaveURL(/\/dashboard/)
})
test('invalid credentials show error message', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('alice@example.com', 'wrong-password')
await loginPage.expectError('Invalid email or password')
await expect(page).toHaveURL(/\/login/)
})
})
Test Fixtures
Use Playwright fixtures to set up test state and avoid duplication:
// tests/fixtures/auth.ts
import { test as base, Page } from '@playwright/test'
import { LoginPage } from '../pages/LoginPage'
type AuthFixtures = {
loginPage: LoginPage
authenticatedPage: Page
}
export const test = base.extend({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page)
await use(loginPage)
},
authenticatedPage: async ({ page }, use) => {
// Auto-login before each test
await page.goto('/login')
await page.getByLabel('Email').fill('testuser@example.com')
await page.getByLabel('Password').fill('testpass123')
await page.getByRole('button', { name: 'Sign In' }).click()
await page.waitForURL(/\/dashboard/)
await use(page)
},
})
export { expect } from '@playwright/test'
// tests/e2e/item-management.spec.ts
import { test, expect } from '../fixtures/auth'
test.describe('Item Management', () => {
test('create and verify item', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')
await authenticatedPage.getByRole('button', { name: 'Create Item' }).click()
await authenticatedPage.getByLabel('Item Name').fill('Test Widget')
await authenticatedPage.getByRole('button', { name: 'Save' }).click()
await expect(authenticatedPage.getByTestId('item-list').getByText('Test Widget')).toBeVisible()
})
})
Flaky Test Prevention
Root Causes and Solutions
| Cause | Symptom | Fix | |---|---|---| | Timing issues | Element not found intermittently | Use waitFor assertions, not sleep() | | Shared state | Tests pass/fail depending on order | Isolate data per test, clean up after | | Network variability | API timeouts, slow responses | Mock external APIs, set generous timeouts | | Animation/transitions | Element visible but not clickable | Wait for animation to complete or disable in test mode | | Dynamic content | IDs/text change between runs | Use stable selectors (data-testid, roles) |
Anti-Pattern vs. Correct Pattern
// BAD: Fixed sleep — timing dependent
await page.click('button')
await page.waitForTimeout(2000) // Flaky on slow CI
await expect(page.getByText('Success')).toBeVisible()
// GOOD: Wait for the actual state change
await page.click('button')
await expect(page.getByText('Success')).toBeVisible({ timeout: 10000 })
// BAD: Brittle CSS selector
await page.click('.css-1a2b3c')
// GOOD: Semantic selector with data-testid
await page.click('[data-testid="submit-button"]')
// GOOD: Role-based selector (preferred)
await page.click('role=button[name="Submit"]')
Retry and Trace Configuration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
timeout: 30_000,
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-chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 12'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
API Mocking
Mock external APIs to ensure deterministic, fast E2E tests:
// tests/e2e/payment-flow.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Payment Flow', () => {
test('successful payment completes order', async ({ page }) => {
// Mock Stripe API
await page.route('**/api/stripe/create-payment-intent', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
clientSecret: 'pi_test_secret_123',
id: 'pi_123',
}),
})
})
// Mock order confirmation webhook
await page.route('**/api/webhooks/stripe', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify({ received: true }),
})
})
await page.goto('/checkout')
await page.fill('[data-testid="card-number"]', '4242424242424242')
await page.fill('[data-testid="card-expiry"]', '12/30')
await page.fill('[data-testid="card-cvc"]', '123')
await page.click('[data-testid="pay-button"]')
await expect(page.getByText('Payment Successful')).toBeVisible()
await expect(page).toHaveURL(/\/order-confirmation/)
})
test('failed payment shows error', async ({ page }) => {
await page.route('**/api/stripe/create-payment-intent', async (route) => {
await route.fulfill({
status: 402,
body: JSON.stringify({ error: { message: 'Card declined' } }),
})
})
await page.goto('/checkout')
await page.click('[data-testid="pay-button"]')
await expect(page.getByText('Card declined')).toBeVisible()
})
})
Visual Regression Testing
Capture and compare screenshots to detect unintended UI changes:
// tests/e2e/visual-regression.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Visual Regression', () => {
test('login page matches baseline', async ({ page }) => {
await page.goto('/login')
await expect(page).toHaveScreenshot('login-page.png', {
maxDiffPixels: 100,
fullPage: true,
})
})
test('dashboard layout is stable', async ({ page }) => {
await page.goto('/dashboard')
// Wait for all async content to load
await page.waitForSelector('[data-testid="item-list"]')
await expect(page).toHaveScreenshot('dashboard-layout.png', {
maxDiffPixels: 200,
})
})
test('mobile login page matches baseline', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 })
await page.goto('/login')
await expect(page).toHaveScreenshot('login-mobile.png', {
maxDiffPixels: 100,
fullPage: true,
})
})
})
Update baselines after intentional UI changes:
# Update visual baselines
npx playwright test --update-snapshots
# Run visual tests only
npx playwright test visual-regression --project=chromium
Cross-Browser Testing
Run the same test suite across multiple browsers:
# Install all browsers
npx playwright install
# Run all browser projects
npx playwright test --project=chromium --project=firefox --project=webkit
# Run only mobile browsers
npx playwright test --project=mobile-chrome --project=mobile-safari
# Run with HTML report
npx playwright test --reporter=html
npx playwright show-report
Browser-Specific Quirks to Watch For
| Issue | Chromium | Firefox | WebKit | |---|---|---|---| | Date input format | yyyy-MM-dd | MM/dd/yyyy | dd/MM/yyyy | | File upload behavior | Native dialog | Native dialog | May need setInputFiles | | CSS support | Full | Full | Minor gaps in older versions | | Cookie handling | Third-party blocked by default | User-configurable | ITP restrictions |
Test date inputs with explicit format handling:
// Handle date inputs across browsers
async function fillDateInput(page, selector: string, date: Date) {
const formatted = date.toISOString().split('T')[0] // yyyy-MM-dd
await page.fill(selector, formatted)
// Verify the browser accepted the value
const actual = await page.inputValue(selector)
expect(actual).toBe(formatted)
}
E2E Test Organization
project/
├── tests/
│ ├── e2e/
│ │ ├── auth-flow.spec.ts # Login, signup, password reset
│ │ ├── item-management.spec.ts # CRUD operations
│ │ ├── payment-flow.spec.ts # Checkout, Stripe mock
│ │ └── visual-regression.spec.ts # Screenshot baselines
│ ├── fixtures/
│ │ └── auth.ts # Shared authentication fixtures
│ ├── pages/
│ │ ├── LoginPage.ts # Page object for /login
│ │ └── DashboardPage.ts # Page object for /dashboard
│ └── playwright.config.ts # Browser projects, timeouts
CI/CD Integration
# .github/workflows/e2e.yml
name: E2E Tests
on:
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * *' # Daily smoke test
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --project=chromium
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Common Mistakes
| Mistake | Why It's Wrong | Correct Approach | |---|---|---| | Testing every single page | Slow, high maintenance | Cover critical flows, unit test the rest | | waitForTimeout(5000) everywhere | Flaky on variable load times | Wait for specific selectors/state | | Hardcoded credentials in tests | Security risk, not portable | Use env vars + fixtures for auth | | Tests sharing state | Order dependency, cascading failures | Each test creates its own data | | No cleanup after tests | Database grows, tests slow down | Use transaction rollback or per-test schemas | | Screenshot tests on dynamic content | Every run differs | Mock dates, random values, or use maxDiffPixels |
Best Practices
- Test critical flows only — E2E tests are expensive; unit tests are cheap
- Use Page Object Model — keeps tests readable and maintainable
- Mock external APIs — deterministic results, no network dependency
- Wait for state, not time —
expect(locator).toBeVisible()notwaitForTimeout() - Use semantic selectors —
getByRole(),getByTestId(), not CSS classes - Parallelize aggressively —
fullyParallel: true, independent test data - Capture traces on failure —
trace: 'on-first-retry'for debugging - Run daily in CI — catch regressions before they reach production
- Keep tests under 30 seconds each — slow tests indicate wrong abstraction
- Review flaky tests weekly — fix or delete, never ignore
Success Metrics
- [ ] All P0 and P1 critical flows covered by E2E tests
- [ ] Flaky test rate < 2% (failures per run)
- [ ] E2E suite completes in under 10 minutes
- [ ] Visual regression baselines updated after intentional UI changes
- [ ] Cross-browser tests pass on Chromium, Firefox, WebKit
- [ ] No hardcoded credentials or secrets in test files
- [ ] Test results visible in CI/CD pipeline with artifact upload on failure
Remember: E2E tests are your last line of automated defense. They should be reliable, fast, and focused on what matters to real users.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: noah-sheldon
- Source: noah-sheldon/ai-dev-kit
- License: MIT
- Homepage: https://noahsheldon.dev
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.