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
- Review the patterns and examples below
- Apply the relevant patterns to your implementation
- 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-Pattern | Problem | Solution |
|---|---|---|
Using cy.wait(5000) | Arbitrary waits cause flakiness | Use cy.intercept() and cy.wait('@alias') |
| Hardcoded CSS selectors | Tests break on UI refactoring | Use data-testid attributes |
| Testing implementation details | Brittle tests coupled to code | Test user-visible behavior only |
| Shared state between tests | One test failure cascades | Use beforeEach() to reset state |
| No retry logic in CI | Intermittent failures block PRs | Configure 2-3 retries for flaky tests |
| Testing directly in production | Data pollution, accidental changes | Use dedicated test environment |
| One giant test file | Hard to debug, slow to run | Split into logical feature files |
| No visual regression testing | UI bugs slip through | Use 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