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
Learn More
📚 More Testing Topics
Explore all 6 testing topics to build a comprehensive understanding of software testing.
View All Topics