| name | e2e-testing-standards |
| category | testing |
| difficulty | intermediate |
| estimated_time | 45 minutes |
| prerequisites | Basic JavaScript/TypeScript, Understanding of web applications, Familiarity with testing concepts |
| learning_outcomes | Implement robust E2E tests with Playwright or Cypress, Apply Page Object Model pattern effectively, Create maintainable test suites, Prevent flaky tests, Integrate E2E testing into CI/CD pipelines |
| related_skills | unit-testing, integration-testing, accessibility-testing |
| tags | e2e-testing, playwright, cypress, automation, page-object-model |
| description | Playwright |
E2E Testing Standards
End-to-end testing validates complete user workflows across the entire application stack, simulating real user interactions to ensure system reliability.
Level 1: Quick Reference
Framework Comparison
Playwright
- Multi-browser (Chromium, Firefox, WebKit)
- Auto-waiting built-in
- Network interception
- Multiple contexts/pages
- Best for: Complex scenarios, cross-browser testing
Cypress
- Chromium-based (Firefox experimental)
- Time-travel debugging
- Real-time reloads
- Simpler API
- Best for: Rapid development, visual debugging
Page Object Model Pattern
// Page Object
class LoginPage {
constructor(private page: Page) {}
async login(username: string, password: string) {
await this.page.fill('[data-testid="username"]', username);
await this.page.fill('[data-testid="password"]', password);
await this.page.click('[data-testid="login-button"]');
}
}
// Test
test('user can login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('user@example.com', 'password');
await expect(page).toHaveURL('/dashboard');
});
Essential Checklist
Selectors (Priority Order)
- Use
data-testidattributes first - Use accessibility roles/labels second
- Avoid CSS classes and IDs
- Never use XPath unless absolutely necessary
Waits
- Rely on framework auto-waiting (Playwright/Cypress)
- Use explicit waits for specific conditions
- Avoid hard-coded
sleep()orwait(ms) - Wait for network idle when needed
Assertions
- Assert on meaningful user-visible state
- Check URL changes for navigation
- Verify element visibility/presence
- Validate text content, not structure
Test Data
- Use test-specific data (not production)
- Clean up after tests (database, files)
- Avoid dependencies between tests
- Use API calls for setup when possible
Flakiness Prevention
- Never use fixed delays
- Handle async operations properly
- Mock external dependencies
- Run tests in isolation
Level 2: Implementation Guide
Test Framework Selection
Playwright Setup
Installation
npm init playwright@latest
# Select: TypeScript, tests folder, GitHub Actions
Project Structure
tests/
├── e2e/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── registration.spec.ts
│ ├── checkout/
│ │ └── purchase-flow.spec.ts
│ └── fixtures/
│ └── test-data.ts
├── page-objects/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── BasePage.ts
└── playwright.config.ts
Configuration (see config/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: 'test-results/junit.xml' }]
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run start',
port: 3000,
reuseExistingServer: !process.env.CI,
},
});
Cypress Setup
Installation
npm install --save-dev cypress
npx cypress open # First-time setup wizard
Project Structure
cypress/
├── e2e/
│ ├── auth/
│ │ ├── login.cy.ts
│ │ └── registration.cy.ts
│ └── checkout/
│ └── purchase-flow.cy.ts
├── support/
│ ├── commands.ts
│ ├── e2e.ts
│ └── page-objects/
│ ├── LoginPage.ts
│ └── DashboardPage.ts
├── fixtures/
│ └── users.json
└── cypress.config.ts
Configuration (see config/cypress.config.ts)
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
retries: {
runMode: 2,
openMode: 0,
},
});
Page Object Model Implementation
Base Page Pattern
// BasePage.ts
import { Page } from '@playwright/test';
export abstract class BasePage {
constructor(protected page: Page) {}
async navigate(path: string = '') {
await this.page.goto(path);
}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
async getPageTitle(): Promise<string> {
return await this.page.title();
}
async takeScreenshot(name: string) {
await this.page.screenshot({
path: `screenshots/${name}.png`,
fullPage: true
});
}
}
Feature-Specific Page Objects
// LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
// Locators
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
super(page);
this.usernameInput = page.getByTestId('username-input');
this.passwordInput = page.getByTestId('password-input');
this.loginButton = page.getByRole('button', { name: 'Log in' });
this.errorMessage = page.getByTestId('error-message');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async loginWithEnter(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.passwordInput.press('Enter');
}
async getErrorText(): Promise<string> {
return await this.errorMessage.textContent() || '';
}
async isErrorVisible(): Promise<boolean> {
return await this.errorMessage.isVisible();
}
}
Complete example in templates/page-object.ts
Locator Strategies
Priority Order
1. Test IDs (Recommended)
// HTML
<button data-testid="submit-button">Submit</button>
// Playwright
await page.getByTestId('submit-button').click();
// Cypress
cy.get('[data-testid="submit-button"]').click();
2. Accessibility Attributes
// By role
await page.getByRole('button', { name: 'Submit' }).click();
cy.findByRole('button', { name: 'Submit' }).click();
// By label
await page.getByLabel('Email address').fill('user@example.com');
cy.findByLabelText('Email address').type('user@example.com');
// By placeholder
await page.getByPlaceholder('Enter your email').fill('user@example.com');
3. Text Content
// Exact text
await page.getByText('Welcome back').click();
cy.contains('Welcome back').click();
// Partial text
await page.getByText(/Welcome/i).click();
cy.contains(/Welcome/i).click();
4. CSS Selectors (Last Resort)
// Use only when above options aren't available
await page.locator('.submit-btn.primary').click();
cy.get('.submit-btn.primary').click();
Locator Best Practices
// ✅ Good: Resilient to UI changes
await page.getByRole('navigation').getByRole('link', { name: 'Products' });
// ❌ Bad: Brittle, breaks with styling changes
await page.locator('#nav > ul > li:nth-child(2) > a');
// ✅ Good: Semantic and accessible
await page.getByLabel('Search').fill('laptops');
// ❌ Bad: Depends on implementation details
await page.locator('input[name="q"][type="text"]').fill('laptops');
Waits and Timing
Auto-Waiting (Playwright/Cypress)
Both frameworks automatically wait for elements to be:
- Present in DOM
- Visible
- Enabled (for interactions)
- Stable (not animating)
// No explicit wait needed - framework handles it
await page.click('button'); // Waits for button to be clickable
cy.get('button').click(); // Waits for button to be clickable
Explicit Waits
Wait for Element State
// Playwright
await page.waitForSelector('[data-testid="results"]', { state: 'visible' });
await page.waitForSelector('[data-testid="loader"]', { state: 'hidden' });
// Cypress
cy.get('[data-testid="results"]').should('be.visible');
cy.get('[data-testid="loader"]').should('not.exist');
Wait for Network
// Playwright - Wait for API response
await page.waitForResponse(
response => response.url().includes('/api/users') && response.status() === 200
);
// Cypress - Intercept and wait
cy.intercept('GET', '/api/users').as('getUsers');
cy.wait('@getUsers');
Wait for Navigation
// Playwright
await Promise.all([
page.waitForNavigation(),
page.click('a[href="/dashboard"]')
]);
// Cypress
cy.get('a[href="/dashboard"]').click();
cy.url().should('include', '/dashboard');
Custom Conditions
// Playwright - Wait for custom condition
await page.waitForFunction(() => {
return document.querySelectorAll('.item').length > 5;
});
// Cypress - Wait for custom condition
cy.get('.item').should('have.length.greaterThan', 5);
Anti-Patterns
// ❌ NEVER: Hard-coded delays
await page.waitForTimeout(5000); // Brittle and slow
cy.wait(5000); // Brittle and slow
// ✅ BETTER: Wait for specific condition
await page.waitForSelector('[data-testid="loaded"]');
cy.get('[data-testid="loaded"]').should('be.visible');
// ❌ NEVER: Polling loops
while (!(await page.isVisible('.result'))) {
await page.waitForTimeout(100);
}
// ✅ BETTER: Built-in waiting
await page.waitForSelector('.result', { state: 'visible', timeout: 10000 });
Visual Regression Testing
Playwright Visual Comparisons
import { test, expect } from '@playwright/test';
test('homepage visual regression', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100, // Allow minor differences
threshold: 0.2, // 20% pixel difference threshold
});
});
test('component visual regression', async ({ page }) => {
await page.goto('/products');
const productCard = page.locator('[data-testid="product-card"]').first();
await expect(productCard).toHaveScreenshot('product-card.png');
});
Update Baselines
# Generate new baseline screenshots
npx playwright test --update-snapshots
# Update specific test
npx playwright test homepage.spec.ts --update-snapshots
Percy Integration (Cypress/Playwright)
// Install: npm install --save-dev @percy/playwright
import percySnapshot from '@percy/playwright';
test('visual test with Percy', async ({ page }) => {
await page.goto('/');
await percySnapshot(page, 'Homepage');
});
# Run with Percy
PERCY_TOKEN=your_token npx percy exec -- playwright test
Cross-Browser Testing
Playwright Multi-Browser
// playwright.config.ts
export default defineConfig({
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'] },
},
],
});
Run Specific Browsers
# All browsers
npx playwright test
# Specific browser
npx playwright test --project=firefox
# Multiple browsers
npx playwright test --project=chromium --project=webkit
Browser-Specific Tests
test('feature only available in chromium', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', 'Chromium-only feature');
// Test chromium-specific feature
await page.evaluate(() => {
// Use chromium-specific API
});
});
CI/CD Integration
GitHub Actions
# .github/workflows/e2e-tests.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Run E2E tests
run: npx playwright test --project=${{ matrix.browser }}
env:
BASE_URL: http://localhost:3000
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
retention-days: 30
Docker Integration
# Dockerfile.e2e
FROM mcr.microsoft.com/playwright:v1.40.0-focal
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npx", "playwright", "test"]
# docker-compose.e2e.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=test
e2e-tests:
build:
dockerfile: Dockerfile.e2e
depends_on:
- app
environment:
- BASE_URL=http://app:3000
volumes:
- ./test-results:/app/test-results
Complete script in scripts/run-e2e-tests.sh
Test Data Management
Fixtures and Factories
// fixtures/test-data.ts
export const testUsers = {
admin: {
email: 'admin@test.com',
password: 'Admin123!',
role: 'admin',
},
user: {
email: 'user@test.com',
password: 'User123!',
role: 'user',
},
};
export const testProducts = [
{ id: 1, name: 'Laptop', price: 999.99 },
{ id: 2, name: 'Mouse', price: 29.99 },
];
API Setup/Teardown
// Use API for data setup (faster than UI)
import { test as base } from '@playwright/test';
type TestFixtures = {
authenticatedUser: { token: string; userId: string };
};
export const test = base.extend<TestFixtures>({
authenticatedUser: async ({ request }, use) => {
// Setup: Create user via API
const response = await request.post('/api/auth/register', {
data: {
email: `test-${Date.now()}@example.com`,
password: 'Test123!',
},
});
const { token, userId } = await response.json();
// Provide to test
await use({ token, userId });
// Teardown: Delete user via API
await request.delete(`/api/users/${userId}`, {
headers: { Authorization: `Bearer ${token}` },
});
},
});
// Usage
test('user can update profile', async ({ page, authenticatedUser }) => {
// Set authentication token
await page.goto('/');
await page.evaluate((token) => {
localStorage.setItem('auth_token', token);
}, authenticatedUser.token);
await page.goto('/profile');
// ... test continues
});
Database Seeding
// helpers/database.ts
import { PrismaClient } from '@prisma/client';
export class TestDatabase {
private prisma = new PrismaClient();
async seed() {
await this.prisma.user.createMany({
data: testUsers,
});
await this.prisma.product.createMany({
data: testProducts,
});
}
async reset() {
await this.prisma.user.deleteMany();
await this.prisma.product.deleteMany();
}
async cleanup() {
await this.reset();
await this.prisma.$disconnect();
}
}
// In test
test.beforeEach(async () => {
const db = new TestDatabase();
await db.seed();
});
test.afterEach(async () => {
const db = new TestDatabase();
await db.cleanup();
});
Flaky Test Prevention
Common Causes and Solutions
1. Race Conditions
// ❌ Flaky: Might click before element is ready
await page.goto('/dashboard');
await page.click('[data-testid="menu-button"]');
// ✅ Stable: Wait for specific state
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.getByTestId('menu-button').click(); // Auto-waits
2. Timing Issues
// ❌ Flaky: Hardcoded wait
await page.fill('[data-testid="search"]', 'query');
await page.waitForTimeout(2000); // Arbitrary delay
await page.click('[data-testid="first-result"]');
// ✅ Stable: Wait for specific element
await page.fill('[data-testid="search"]', 'query');
await page.waitForSelector('[data-testid="first-result"]', { state: 'visible' });
await page.click('[data-testid="first-result"]');
3. Test Interdependence
// ❌ Flaky: Tests share state
test('create item', async ({ page }) => {
await page.goto('/items');
await page.click('[data-testid="add-item"]');
// Creates item in shared database
});
test('list items', async ({ page }) => {
await page.goto('/items');
await expect(page.getByTestId('item-list')).toContainText('New item');
// Depends on previous test!
});
// ✅ Stable: Each test is independent
test('create item', async ({ page }) => {
await createItemViaAPI(); // Setup
await page.goto('/items');
await expect(page.getByTestId('item-list')).toContainText('New item');
await deleteItemViaAPI(); // Teardown
});
4. Animation Interference
// ❌ Flaky: Click during animation
await page.click('[data-testid="modal-button"]');
await page.click('[data-testid="modal-close"]'); // Might miss if modal is animating
// ✅ Stable: Wait for stable state
await page.click('[data-testid="modal-button"]');
await page.waitForSelector('[data-testid="modal"]', { state: 'visible' });
await page.getByTestId('modal-close').click(); // Playwright waits for stability
5. Network Instability
// ❌ Flaky: Depends on real API
test('loads user data', async ({ page }) => {
await page.goto('/profile');
await expect(page.getByTestId('username')).toContainText('John');
});
// ✅ Stable: Mock API responses
test('loads user data', async ({ page }) => {
await page.route('/api/user', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ name: 'John', email: 'john@example.com' }),
});
});
await page.goto('/profile');
await expect(page.getByTestId('username')).toContainText('John');
});
Complete anti-flakiness guide in resources/e2e-best-practices.md
Advanced Patterns
Authentication State Reuse
// auth.setup.ts - Run once, save state
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="username"]', process.env.TEST_USER!);
await page.fill('[data-testid="password"]', process.env.TEST_PASSWORD!);
await page.click('[data-testid="login-button"]');
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'auth-state.json' });
});
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'auth-state.json', // Reuse auth state
},
dependencies: ['setup'], // Run setup first
},
],
});
Parallel Execution with Test Sharding
# Split tests across 4 machines
npx playwright test --shard=1/4 # Machine 1
npx playwright test --shard=2/4 # Machine 2
npx playwright test --shard=3/4 # Machine 3
npx playwright test --shard=4/4 # Machine 4
Network Mocking
test('handles API error gracefully', async ({ page }) => {
// Mock failed API response
await page.route('/api/products', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
await page.goto('/products');
await expect(page.getByTestId('error-message'))
.toContainText('Failed to load products');
});
Accessibility Testing Integration
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage should not have accessibility violations', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
Level 3: Deep Dive Resources
Official Documentation
- Playwright: https://playwright.dev/docs/intro
- Best Practices: https://playwright.dev/docs/best-practices
- API Reference: https://playwright.dev/docs/api/class-playwright
- Cypress: https://docs.cypress.io/guides/overview/why-cypress
- Best Practices: https://docs.cypress.io/guides/references/best-practices
- API Reference: https://docs.cypress.io/api/table-of-contents
Books and Courses
- "Playwright: The Definitive Guide" by Debbie O'Brien
- "End-to-End Web Testing with Cypress" by Waweru Mwaura
- Test Automation University (free courses)
Testing Patterns
- Martin Fowler's Testing Pyramid: https://martinfowler.com/articles/practical-test-pyramid.html
- Page Object Model: https://playwright.dev/docs/pom
- Test Flakiness: https://playwright.dev/docs/test-use-options#automatic-retries
Tools and Extensions
- Percy (Visual Testing): https://percy.io/
- Axe (Accessibility): https://github.com/dequelabs/axe-core-npm
- Faker.js (Test Data): https://fakerjs.dev/
- MSW (API Mocking): https://mswjs.io/
Community Resources
- Playwright Discord: https://aka.ms/playwright/discord
- Cypress Discord: https://discord.com/invite/cypress
- Ministry of Testing: https://www.ministryoftesting.com/
- Reddit r/QualityAssurance: https://reddit.com/r/QualityAssurance
CI/CD Integration Guides
- GitHub Actions: https://playwright.dev/docs/ci-intro
- GitLab CI: https://docs.gitlab.com/ee/ci/testing/end_to_end/
- Jenkins: https://playwright.dev/docs/ci#jenkins
- CircleCI: https://circleci.com/docs/browser-testing/
Examples
Basic Usage
// TODO: Add basic example for e2e-testing
// This example demonstrates core functionality
Advanced Usage
// TODO: Add advanced example for e2e-testing
// This example shows production-ready patterns
Integration Example
// TODO: Add integration example showing how e2e-testing
// works with other systems and services
See examples/e2e-testing/ for complete working examples.
Integration Points
This skill integrates with:
Upstream Dependencies
- Tools: Common development tools and frameworks
- Prerequisites: Basic understanding of general concepts
Downstream Consumers
- Applications: Production systems requiring e2e-testing functionality
- CI/CD Pipelines: Automated testing and deployment workflows
- Monitoring Systems: Observability and logging platforms
Related Skills
- See other skills in this category
Common Integration Patterns
- Development Workflow: How this skill fits into daily development
- Production Deployment: Integration with production systems
- Monitoring & Alerting: Observability integration points
Common Pitfalls
Pitfall 1: Insufficient Testing
Problem: Not testing edge cases and error conditions leads to production bugs
Solution: Implement comprehensive test coverage including:
- Happy path scenarios
- Error handling and edge cases
- Integration points with external systems
Prevention: Enforce minimum code coverage (80%+) in CI/CD pipeline
Pitfall 2: Hardcoded Configuration
Problem: Hardcoding values makes applications inflexible and environment-dependent
Solution: Use environment variables and configuration management:
- Separate config from code
- Use environment-specific configuration files
- Never commit secrets to version control
Prevention: Use tools like dotenv, config validators, and secret scanners
Pitfall 3: Ignoring Security Best Practices
Problem: Security vulnerabilities from not following established security patterns
Solution: Follow security guidelines:
- Input validation and sanitization
- Proper authentication and authorization
- Encrypted data transmission (TLS/SSL)
- Regular security audits and updates
Prevention: Use security linters, SAST tools, and regular dependency updates
Best Practices:
- Follow established patterns and conventions for e2e-testing
- Keep dependencies up to date and scan for vulnerabilities
- Write comprehensive documentation and inline comments
- Use linting and formatting tools consistently
- Implement proper error handling and logging
- Regular code reviews and pair programming
- Monitor production metrics and set up alerts
Bundled Resources
See accompanying files:
config/playwright.config.ts- Production-ready Playwright configurationconfig/cypress.config.ts- Production-ready Cypress configurationtemplates/page-object.ts- Complete Page Object Model templatetemplates/test-template.spec.ts- Full test suite templatescripts/run-e2e-tests.sh- CI/CD test execution scriptresources/e2e-best-practices.md- Comprehensive anti-flakiness guide