Skip to main content

E2E Testing Skill

E2E Testing Skill

When to Use This Skill

Use this skill when implementing e2e testing patterns in your codebase.

How to Use This Skill

  1. Review the patterns and examples below
  2. Apply the relevant patterns to your implementation
  3. Follow the best practices outlined in this skill

Comprehensive end-to-end testing patterns for reliable, maintainable browser automation with Playwright and Cypress.

Playwright

Project Setup

# Initialize Playwright
npm init playwright@latest

# Project structure
tests/
├── e2e/
│ ├── auth.spec.ts
│ ├── checkout.spec.ts
│ └── fixtures/
│ └── auth.fixture.ts
├── pages/
│ ├── base.page.ts
│ ├── login.page.ts
│ └── dashboard.page.ts
└── playwright.config.ts

Configuration

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

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: [
['html'],
['junit', { outputFile: 'results.xml' }]
],
use: {
baseURL: process.env.BASE_URL || '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'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
})

Page Object Model

// pages/base.page.ts
import { Page, Locator } from '@playwright/test'

export abstract class BasePage {
constructor(protected page: Page) {}

async navigate(path: string = '') {
await this.page.goto(path)
}

async waitForLoad() {
await this.page.waitForLoadState('networkidle')
}

protected getByTestId(testId: string): Locator {
return this.page.getByTestId(testId)
}
}

// pages/login.page.ts
import { Page, expect } from '@playwright/test'
import { BasePage } from './base.page'

export class LoginPage extends BasePage {
private readonly emailInput = this.page.getByLabel('Email')
private readonly passwordInput = this.page.getByLabel('Password')
private readonly submitButton = this.page.getByRole('button', { name: 'Sign in' })
private readonly errorMessage = this.getByTestId('error-message')

constructor(page: Page) {
super(page)
}

async navigate() {
await super.navigate('/login')
}

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

async expectError(message: string) {
await expect(this.errorMessage).toContainText(message)
}

async expectLoggedIn() {
await expect(this.page).toHaveURL(/dashboard/)
}
}

// pages/dashboard.page.ts
import { Page, expect } from '@playwright/test'
import { BasePage } from './base.page'

export class DashboardPage extends BasePage {
private readonly welcomeMessage = this.getByTestId('welcome-message')
private readonly logoutButton = this.page.getByRole('button', { name: 'Logout' })

async expectWelcome(name: string) {
await expect(this.welcomeMessage).toContainText(`Welcome, ${name}`)
}

async logout() {
await this.logoutButton.click()
await expect(this.page).toHaveURL(/login/)
}
}

Fixtures

// fixtures/auth.fixture.ts
import { test as base, expect } from '@playwright/test'
import { LoginPage } from '../pages/login.page'
import { DashboardPage } from '../pages/dashboard.page'

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

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

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

authenticatedPage: async ({ page, context }, use) => {
// Set authentication state
await context.addCookies([{
name: 'session',
value: 'test-session-token',
domain: 'localhost',
path: '/'
}])

const dashboard = new DashboardPage(page)
await dashboard.navigate('/dashboard')
await use(dashboard)
}
})

export { expect }

Test Examples

// tests/e2e/auth.spec.ts
import { test, expect } from '../fixtures/auth.fixture'

test.describe('Authentication', () => {
test('successful login redirects to dashboard', async ({ loginPage, dashboardPage }) => {
await loginPage.login('user@example.com', 'password123')
await dashboardPage.expectWelcome('User')
})

test('invalid credentials show error', async ({ loginPage }) => {
await loginPage.login('user@example.com', 'wrongpassword')
await loginPage.expectError('Invalid credentials')
})

test('logout returns to login page', async ({ authenticatedPage }) => {
await authenticatedPage.logout()
})
})

// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Checkout Flow', () => {
test.beforeEach(async ({ page }) => {
// Setup: Add item to cart
await page.goto('/products/1')
await page.getByRole('button', { name: 'Add to Cart' }).click()
})

test('complete purchase flow', async ({ page }) => {
// Go to cart
await page.getByRole('link', { name: 'Cart' }).click()
await expect(page.getByTestId('cart-count')).toHaveText('1')

// Proceed to checkout
await page.getByRole('button', { name: 'Checkout' }).click()

// Fill shipping info
await page.getByLabel('Address').fill('123 Main St')
await page.getByLabel('City').fill('New York')
await page.getByRole('button', { name: 'Continue' }).click()

// Fill payment
await page.getByLabel('Card Number').fill('4242424242424242')
await page.getByRole('button', { name: 'Pay Now' }).click()

// Verify success
await expect(page.getByText('Order Confirmed')).toBeVisible()
})
})

Cypress

Project Setup

npm install cypress --save-dev
npx cypress open

Configuration

// cypress.config.ts
import { defineConfig } from 'cypress'

export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
retries: {
runMode: 2,
openMode: 0
},
setupNodeEvents(on, config) {
// Plugins
}
}
})

Page Objects (Cypress)

// cypress/support/pages/LoginPage.ts
export class LoginPage {
visit() {
cy.visit('/login')
}

fillEmail(email: string) {
cy.get('[data-testid="email-input"]').type(email)
}

fillPassword(password: string) {
cy.get('[data-testid="password-input"]').type(password)
}

submit() {
cy.get('[data-testid="submit-button"]').click()
}

login(email: string, password: string) {
this.fillEmail(email)
this.fillPassword(password)
this.submit()
}

expectError(message: string) {
cy.get('[data-testid="error-message"]').should('contain', message)
}
}

// cypress/support/pages/index.ts
export { LoginPage } from './LoginPage'
export { DashboardPage } from './DashboardPage'

Custom Commands

// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>
getByTestId(testId: string): Chainable<JQuery<HTMLElement>>
}
}
}

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

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

Test Examples (Cypress)

// cypress/e2e/auth.cy.ts
import { LoginPage } from '../support/pages'

describe('Authentication', () => {
const loginPage = new LoginPage()

beforeEach(() => {
loginPage.visit()
})

it('should login successfully', () => {
loginPage.login('user@example.com', 'password123')
cy.url().should('include', '/dashboard')
cy.getByTestId('welcome-message').should('contain', 'Welcome')
})

it('should show error for invalid credentials', () => {
loginPage.login('user@example.com', 'wrongpassword')
loginPage.expectError('Invalid credentials')
})
})

// cypress/e2e/api-testing.cy.ts
describe('API Testing', () => {
it('intercepts and mocks API response', () => {
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [{ id: 1, name: 'Test User' }]
}).as('getUsers')

cy.visit('/users')
cy.wait('@getUsers')
cy.getByTestId('user-list').should('contain', 'Test User')
})

it('waits for real API response', () => {
cy.intercept('POST', '/api/orders').as('createOrder')

cy.visit('/checkout')
cy.getByTestId('submit-order').click()

cy.wait('@createOrder').its('response.statusCode').should('eq', 201)
})
})

Flaky Test Handling

Retry Strategies

// Playwright
test('flaky network test', async ({ page }) => {
// Retry only this test
test.info().annotations.push({ type: 'issue', description: 'Network timing' })

await page.goto('/dashboard')
await expect(page.getByTestId('data')).toBeVisible({ timeout: 10000 })
})

// Cypress
it('handles async data', { retries: 3 }, () => {
cy.visit('/dashboard')
cy.getByTestId('data', { timeout: 10000 }).should('be.visible')
})

Waiting Strategies

// Playwright - Wait for network
await page.waitForResponse(response =>
response.url().includes('/api/data') && response.status() === 200
)

// Playwright - Wait for element state
await expect(page.getByTestId('loading')).not.toBeVisible()
await expect(page.getByTestId('content')).toBeVisible()

// Cypress - Wait for request
cy.intercept('GET', '/api/data').as('getData')
cy.visit('/dashboard')
cy.wait('@getData')

Isolation

// Playwright - Isolated browser context per test
test.describe('Feature', () => {
test.beforeEach(async ({ context }) => {
// Fresh context for each test
await context.clearCookies()
})
})

// Cypress - Clear state
beforeEach(() => {
cy.clearCookies()
cy.clearLocalStorage()
cy.window().then(win => win.sessionStorage.clear())
})

CI Integration

GitHub Actions

name: E2E Tests

on: [push, pull_request]

jobs:
playwright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

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

- name: Install dependencies
run: npm ci

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

- name: Run Playwright tests
run: npx playwright test

- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/

cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm start
wait-on: 'http://localhost:3000'

- uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots

Usage Examples

Setup E2E Test Suite

Apply e2e-testing skill to configure Playwright with page object model for e-commerce application

Handle Flaky Tests

Apply e2e-testing skill to identify and fix flaky tests with proper waiting strategies and isolation

CI Pipeline Integration

Apply e2e-testing skill to setup GitHub Actions workflow with parallel test execution and artifact uploads

Success Output

When successful, this skill MUST output:

✅ SKILL COMPLETE: e2e-testing

Completed:
- [x] Playwright/Cypress configuration created
- [x] Page Object Model implemented
- [x] Test fixtures created for authentication
- [x] E2E test suite written and passing
- [x] Waiting strategies implemented (no flakiness)
- [x] CI/CD integration configured
- [x] Test reports generated
- [x] Screenshots/videos captured on failure

Test Results:
- Total tests: {COUNT}
- Passing: {PASS_COUNT}
- Failing: {FAIL_COUNT}
- Flaky: {FLAKY_COUNT}
- Duration: {DURATION}s

Outputs:
- tests/e2e/*.spec.ts created
- pages/*.page.ts implemented
- playwright.config.ts / cypress.config.ts configured
- .github/workflows/e2e.yml created
- Test reports: playwright-report/ or cypress/reports/

Completion Checklist

Before marking this skill as complete, verify:

  • Playwright or Cypress installed and configured
  • Base URL configured for test environment
  • Page Object Model classes created
  • Test fixtures created for common scenarios
  • All critical user flows have E2E tests
  • Tests use proper waiting strategies (not arbitrary sleeps)
  • Tests run in isolated browser contexts
  • Authentication state managed with sessions/fixtures
  • Test data cleanup implemented
  • Screenshots captured on test failure
  • CI workflow configured and passing
  • Test retry logic configured (2-3 retries in CI)
  • Test reports accessible from CI artifacts

Failure Indicators

This skill has FAILED if:

  • ❌ Tests fail intermittently (flaky >10% of runs)
  • ❌ Tests use waitForTimeout() instead of smart waiting
  • ❌ Tests leave dirty state affecting other tests
  • ❌ Browser crashes or timeouts occur frequently
  • ❌ Tests don't run in CI (only work locally)
  • ❌ No screenshots/videos captured on failure
  • ❌ Page object selectors hardcoded (not using test IDs)
  • ❌ Tests take >5 minutes to run (too slow)
  • ❌ Tests pollute production database
  • ❌ Authentication state shared between tests (cross-contamination)

When NOT to Use

Do NOT use this skill when:

  • Unit testing suffices - Use Jest/Vitest for isolated component logic
  • API-only testing - Use Postman/Insomnia for backend endpoint testing
  • Performance testing - Use k6/Artillery for load testing
  • Quick smoke tests - E2E setup overhead too high for simple checks
  • No browser needed - Testing CLI tools or server-side code
  • Unstable UI - UI changing rapidly, E2E tests will break constantly
  • Early prototyping - Wait until UI stabilizes before writing E2E tests

Anti-Patterns (Avoid)

Anti-PatternProblemSolution
Using cy.wait(5000)Arbitrary waits cause flakinessUse cy.intercept() and cy.wait('@alias')
Hardcoded CSS selectorsTests break on UI refactoringUse data-testid attributes
Testing implementation detailsBrittle tests coupled to codeTest user-visible behavior only
Shared state between testsOne test failure cascadesUse beforeEach() to reset state
No retry logic in CIIntermittent failures block PRsConfigure 2-3 retries for flaky tests
Testing directly in productionData pollution, accidental changesUse dedicated test environment
One giant test fileHard to debug, slow to runSplit into logical feature files
No visual regression testingUI bugs slip throughUse Percy/Chromatic for screenshots

Principles

This skill embodies:

  • #3 Keep It Simple - Page Object Model reduces duplication
  • #5 Eliminate Ambiguity - Test IDs make selectors unambiguous
  • #8 No Assumptions - Smart waiting verifies state before assertions
  • #11 Quality is Non-Negotiable - E2E tests catch integration bugs
  • #13 Observability is Essential - Screenshots/videos aid debugging
  • #14 Resilience by Design - Retry logic handles transient failures

Full Standard: CODITECT-STANDARD-AUTOMATION.md