End-to-End Testing

Test complete user workflows with Playwright and Cypress: browser automation, user interactions, and real-world scenarios

What is E2E Testing?

End-to-End (E2E) testing simulates real user scenarios by testing your entire application stack from the user interface through to the backend systems. These tests run in actual browsers, clicking buttons, filling forms, and verifying results just like a real user would.

🌐 E2E Test Scope:

E2E tests validate complete user workflows: authentication, navigation, data entry, API calls, database changes, and UI updates. They provide the highest confidence but are the slowest and most expensive to maintain.

When to Use E2E Tests

✅ Good Use Cases

  • • Critical user journeys (signup, checkout)
  • • Revenue-generating features
  • • Complex multi-step workflows
  • • Authentication and authorization
  • • Cross-browser compatibility
  • • Happy path validation

❌ Poor Use Cases

  • • Testing business logic (use unit tests)
  • • Edge cases and error handling
  • • Data validation rules
  • • Every possible user path
  • • Component rendering variations
  • • Utility functions

Playwright vs Cypress

🎭 Playwright

Modern, fast, supports multiple browsers

  • ✓ Chrome, Firefox, Safari, Edge
  • ✓ Parallel execution
  • ✓ Auto-waiting for elements
  • ✓ Network interception
  • ✓ Mobile emulation

Best for: Multi-browser support, parallel tests, modern apps

🌲 Cypress

Developer-friendly, great DX, real-time reload

  • ✓ Chrome, Firefox, Edge
  • ✓ Time-travel debugging
  • ✓ Automatic screenshots/videos
  • ✓ Great error messages
  • ✓ Easy to get started

Best for: Quick setup, developer experience, Chrome-focused

Playwright E2E Example

// playwright.config.js - Configuration
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  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 dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});
// e2e/login.spec.js - Login flow test
import { test, expect } from '@playwright/test';

test.describe('Login Flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
  });

  test('successful login redirects to dashboard', async ({ page }) => {
    // Navigate to login
    await page.click('text=Login');
    await expect(page).toHaveURL('/login');

    // Fill login form
    await page.fill('[name="email"]', 'user@example.com');
    await page.fill('[name="password"]', 'password123');
    
    // Submit form
    await page.click('button[type="submit"]');

    // Wait for navigation
    await page.waitForURL('/dashboard');
    
    // Verify logged in
    await expect(page.locator('text=Welcome back')).toBeVisible();
    await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
  });

  test('shows error on invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[name="email"]', 'wrong@example.com');
    await page.fill('[name="password"]', 'wrongpassword');
    await page.click('button[type="submit"]');

    // Should stay on login page
    await expect(page).toHaveURL('/login');
    
    // Should show error message
    await expect(page.locator('text=Invalid credentials')).toBeVisible();
  });

  test('validates required fields', async ({ page }) => {
    await page.goto('/login');

    // Try to submit empty form
    await page.click('button[type="submit"]');

    // Check for validation messages
    await expect(page.locator('text=Email is required')).toBeVisible();
    await expect(page.locator('text=Password is required')).toBeVisible();
  });

  test('remembers user after page reload', async ({ page, context }) => {
    await page.goto('/login');
    
    // Login
    await page.fill('[name="email"]', 'user@example.com');
    await page.fill('[name="password"]', 'password123');
    await page.click('button[type="submit"]');
    await page.waitForURL('/dashboard');

    // Reload page
    await page.reload();

    // Should still be logged in
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('text=Welcome back')).toBeVisible();
  });
});

E-Commerce Checkout Flow

// e2e/checkout.spec.js - Complete purchase flow
import { test, expect } from '@playwright/test';

test.describe('E-Commerce Checkout', () => {
  test('user can complete full purchase', async ({ page }) => {
    // 1. Browse products
    await page.goto('/');
    await expect(page.locator('h1')).toContainText('Shop');

    // 2. Add items to cart
    await page.click('[data-product-id="1"] button:has-text("Add to Cart")');
    await page.click('[data-product-id="3"] button:has-text("Add to Cart")');

    // Verify cart badge updates
    await expect(page.locator('[data-testid="cart-count"]')).toHaveText('2');

    // 3. View cart
    await page.click('[data-testid="cart-button"]');
    await expect(page).toHaveURL('/cart');
    
    // Verify items in cart
    await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(2);
    
    // Verify total
    const total = await page.locator('[data-testid="cart-total"]').textContent();
    expect(total).toContain('$');

    // 4. Proceed to checkout
    await page.click('text=Proceed to Checkout');
    await expect(page).toHaveURL('/checkout');

    // 5. Fill shipping information
    await page.fill('[name="fullName"]', 'John Doe');
    await page.fill('[name="email"]', 'john@example.com');
    await page.fill('[name="address"]', '123 Main St');
    await page.fill('[name="city"]', 'New York');
    await page.fill('[name="zipCode"]', '10001');
    await page.selectOption('[name="country"]', 'US');

    // 6. Continue to payment
    await page.click('text=Continue to Payment');

    // 7. Fill payment information
    await page.fill('[name="cardNumber"]', '4242424242424242');
    await page.fill('[name="cardExpiry"]', '12/25');
    await page.fill('[name="cardCvc"]', '123');
    await page.fill('[name="cardName"]', 'John Doe');

    // 8. Place order
    await page.click('text=Place Order');

    // 9. Verify order confirmation
    await expect(page).toHaveURL(/\/order-confirmation/);
    await expect(page.locator('h1')).toContainText('Thank you');
    await expect(page.locator('text=Order #')).toBeVisible();

    // Verify order details
    await expect(page.locator('text=John Doe')).toBeVisible();
    await expect(page.locator('text=123 Main St')).toBeVisible();
    await expect(page.locator('[data-testid="order-item"]')).toHaveCount(2);
  });

  test('validates shipping form', async ({ page }) => {
    await page.goto('/checkout');

    // Try to submit without filling
    await page.click('text=Continue to Payment');

    // Should show validation errors
    await expect(page.locator('text=Name is required')).toBeVisible();
    await expect(page.locator('text=Email is required')).toBeVisible();
    await expect(page.locator('text=Address is required')).toBeVisible();
  });

  test('can apply discount code', async ({ page }) => {
    // Add item and go to cart
    await page.goto('/');
    await page.click('[data-product-id="1"] button:has-text("Add to Cart")');
    await page.click('[data-testid="cart-button"]');

    // Get original total
    const originalTotal = await page.locator('[data-testid="cart-total"]').textContent();

    // Apply discount code
    await page.fill('[name="discountCode"]', 'SAVE10');
    await page.click('text=Apply');

    // Verify discount applied
    await expect(page.locator('text=Discount applied')).toBeVisible();
    
    // Verify new total is less
    const newTotal = await page.locator('[data-testid="cart-total"]').textContent();
    expect(parseFloat(newTotal.replace('$', ''))).toBeLessThan(
      parseFloat(originalTotal.replace('$', ''))
    );
  });
});

Cypress E2E Example

// cypress.config.js - Configuration
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    setupNodeEvents(on, config) {},
    viewportWidth: 1280,
    viewportHeight: 720,
    video: true,
    screenshotOnRunFailure: true,
  },
});
// cypress/e2e/todo-app.cy.js - Todo app test
describe('Todo Application', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('displays empty state initially', () => {
    cy.contains('No todos yet').should('be.visible');
  });

  it('can add new todos', () => {
    // Add first todo
    cy.get('[data-testid="todo-input"]').type('Buy milk');
    cy.get('[data-testid="add-button"]').click();

    // Verify todo appears
    cy.contains('Buy milk').should('be.visible');
    
    // Verify empty state is gone
    cy.contains('No todos yet').should('not.exist');

    // Add second todo
    cy.get('[data-testid="todo-input"]').type('Walk dog');
    cy.get('[data-testid="add-button"]').click();

    // Verify both todos
    cy.get('[data-testid="todo-item"]').should('have.length', 2);
  });

  it('can mark todos as complete', () => {
    // Add todo
    cy.get('[data-testid="todo-input"]').type('Buy milk');
    cy.get('[data-testid="add-button"]').click();

    // Mark as complete
    cy.get('[data-testid="todo-checkbox"]').check();

    // Verify completed style
    cy.contains('Buy milk')
      .should('have.css', 'text-decoration')
      .and('include', 'line-through');
  });

  it('can delete todos', () => {
    // Add two todos
    cy.get('[data-testid="todo-input"]').type('Buy milk');
    cy.get('[data-testid="add-button"]').click();
    cy.get('[data-testid="todo-input"]').type('Walk dog');
    cy.get('[data-testid="add-button"]').click();

    // Delete first todo
    cy.get('[data-testid="delete-button"]').first().click();

    // Verify only one remains
    cy.get('[data-testid="todo-item"]').should('have.length', 1);
    cy.contains('Buy milk').should('not.exist');
    cy.contains('Walk dog').should('be.visible');
  });

  it('can filter todos', () => {
    // Add completed and active todos
    cy.get('[data-testid="todo-input"]').type('Buy milk');
    cy.get('[data-testid="add-button"]').click();
    cy.get('[data-testid="todo-input"]').type('Walk dog');
    cy.get('[data-testid="add-button"]').click();
    
    cy.get('[data-testid="todo-checkbox"]').first().check();

    // Filter active
    cy.get('[data-testid="filter-active"]').click();
    cy.get('[data-testid="todo-item"]').should('have.length', 1);
    cy.contains('Walk dog').should('be.visible');
    cy.contains('Buy milk').should('not.exist');

    // Filter completed
    cy.get('[data-testid="filter-completed"]').click();
    cy.get('[data-testid="todo-item"]').should('have.length', 1);
    cy.contains('Buy milk').should('be.visible');
    cy.contains('Walk dog').should('not.exist');

    // Show all
    cy.get('[data-testid="filter-all"]').click();
    cy.get('[data-testid="todo-item"]').should('have.length', 2);
  });

  it('persists todos after refresh', () => {
    // Add todo
    cy.get('[data-testid="todo-input"]').type('Buy milk');
    cy.get('[data-testid="add-button"]').click();

    // Reload page
    cy.reload();

    // Todo should still be there
    cy.contains('Buy milk').should('be.visible');
  });
});

Advanced E2E Patterns

Custom Commands (Cypress)

// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
  cy.visit('/login');
  cy.get('[name="email"]').type(email);
  cy.get('[name="password"]').type(password);
  cy.get('button[type="submit"]').click();
  cy.url().should('include', '/dashboard');
});

Cypress.Commands.add('addToCart', (productId) => {
  cy.get('[data-product-id="' + productId + '"]')
    .find('button:contains("Add to Cart")')
    .click();
  cy.get('[data-testid="cart-count"]').should('exist');
});

// Usage in tests
describe('Shopping', () => {
  it('logged in user can checkout', () => {
    cy.login('user@example.com', 'password123');
    cy.addToCart('product-1');
    cy.addToCart('product-2');
    cy.get('[data-testid="cart-button"]').click();
    // ... continue with checkout
  });
});

Fixtures and Test Data

// cypress/fixtures/users.json
{
  "admin": {
    "email": "admin@example.com",
    "password": "admin123",
    "role": "admin"
  },
  "regularUser": {
    "email": "user@example.com",
    "password": "user123",
    "role": "user"
  }
}

// Using fixtures
describe('User Roles', () => {
  it('admin can access admin panel', () => {
    cy.fixture('users').then((users) => {
      cy.login(users.admin.email, users.admin.password);
      cy.visit('/admin');
      cy.contains('Admin Panel').should('be.visible');
    });
  });

  it('regular user cannot access admin panel', () => {
    cy.fixture('users').then((users) => {
      cy.login(users.regularUser.email, users.regularUser.password);
      cy.visit('/admin');
      cy.contains('Access Denied').should('be.visible');
    });
  });
});

API Mocking and Interception

// Playwright - Mock API responses
test('shows loading state then data', async ({ page }) => {
  // Intercept API call and mock response
  await page.route('**/api/users', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
      ]),
    });
  });

  await page.goto('/users');

  // Should show loading first
  await expect(page.locator('text=Loading...')).toBeVisible();

  // Then show data
  await expect(page.locator('text=Alice')).toBeVisible();
  await expect(page.locator('text=Bob')).toBeVisible();
});

// Cypress - Intercept and stub API
describe('API Integration', () => {
  it('handles API errors gracefully', () => {
    // Stub API to return error
    cy.intercept('GET', '/api/users', {
      statusCode: 500,
      body: { error: 'Server error' },
    }).as('getUsers');

    cy.visit('/users');
    cy.wait('@getUsers');

    // Should show error message
    cy.contains('Failed to load users').should('be.visible');
  });

  it('can retry failed requests', () => {
    let callCount = 0;
    
    // Fail first two times, succeed third time
    cy.intercept('GET', '/api/data', (req) => {
      callCount++;
      if (callCount < 3) {
        req.reply({ statusCode: 500 });
      } else {
        req.reply({ statusCode: 200, body: { data: 'success' } });
      }
    }).as('getData');

    cy.visit('/data');
    cy.get('[data-testid="retry-button"]').click();
    cy.get('[data-testid="retry-button"]').click();
    
    cy.contains('success').should('be.visible');
  });
});

Mobile and Responsive Testing

// Playwright - Test different viewports
test.describe('Responsive Design', () => {
  test('mobile menu works on small screens', async ({ page }) => {
    // Set mobile viewport
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/');

    // Mobile menu should be hidden initially
    await expect(page.locator('[data-testid="mobile-menu"]')).not.toBeVisible();

    // Click hamburger to open
    await page.click('[data-testid="hamburger"]');
    await expect(page.locator('[data-testid="mobile-menu"]')).toBeVisible();

    // Navigation should work
    await page.click('[data-testid="mobile-menu"] a:has-text("About")');
    await expect(page).toHaveURL('/about');
  });

  test('desktop navigation on large screens', async ({ page }) => {
    // Set desktop viewport
    await page.setViewportSize({ width: 1920, height: 1080 });
    await page.goto('/');

    // Desktop nav should be visible
    await expect(page.locator('[data-testid="desktop-nav"]')).toBeVisible();
    
    // Hamburger should not exist
    await expect(page.locator('[data-testid="hamburger"]')).not.toBeVisible();
  });
});

// Cypress - Mobile emulation
describe('Mobile Experience', () => {
  beforeEach(() => {
    cy.viewport('iphone-x');
  });

  it('displays mobile-optimized layout', () => {
    cy.visit('/');
    
    cy.get('[data-testid="mobile-layout"]').should('be.visible');
    cy.get('[data-testid="desktop-layout"]').should('not.be.visible');
  });
});

⚠️ E2E Testing Pitfalls

  • Slow execution: E2E tests can take minutes to run
  • Flakiness: Network issues, timing problems, animation delays
  • Expensive maintenance: UI changes break many tests
  • Hard to debug: Failures can be anywhere in the stack
  • Environment setup: Requires running full application
  • Test data management: Need consistent, clean test data

💡 E2E Testing Best Practices

  • ✓ Test only critical user journeys (happy paths)
  • ✓ Use data attributes ([data-testid]) instead of CSS selectors
  • ✓ Run E2E tests in CI/CD pipeline before deployment
  • ✓ Keep tests independent - each should set up its own state
  • ✓ Use auto-waiting features to avoid manual waits
  • ✓ Mock external services you don't control
  • ✓ Take screenshots/videos on failure for debugging
  • ✓ Parallelize tests when possible to save time
  • ✓ Focus on user behavior, not implementation details

📚 More Testing Topics

Explore all 6 testing topics to build a comprehensive understanding of software testing.

View All Topics