Implementing E2E Tests with Cypress

A hands-on guide to creating robust end-to-end tests with Cypress

Introduction to Cypress

Cypress is a modern JavaScript-based end-to-end testing framework designed to address the pain points developers experience with traditional testing tools. Similar to how React revolutionized UI development, Cypress has transformed E2E testing with its developer-friendly approach.

graph TD A[Cypress] --> B[Runs in Browser] A --> C[Automatic Waiting] A --> D[Real-time Reloading] A --> E[Time Travel Debugging] A --> F[Network Traffic Control] A --> G[Consistent Results] style A fill:#d1e7dd,stroke:#0f5132 style B fill:#cfe2ff,stroke:#084298 style C fill:#cfe2ff,stroke:#084298 style D fill:#cfe2ff,stroke:#084298 style E fill:#cfe2ff,stroke:#084298 style F fill:#cfe2ff,stroke:#084298 style G fill:#cfe2ff,stroke:#084298

Key Differentiators

Cypress vs. Selenium: A Developer's Perspective

Traditional WebDriver-based tools like Selenium operate outside the browser, sending commands over a network connection. This creates timing and synchronization issues that plague many E2E test suites.

Cypress, by comparison, executes directly in the browser alongside your application code. This gives it real-time access to DOM elements, network requests, and application state without the flakiness caused by timing issues.

Think of it like the difference between remotely controlling a car versus sitting in the driver's seat.

Setting Up Cypress

Installation

To get started with Cypress, you'll need to install it as a development dependency in your project:

npm install cypress --save-dev

Once installed, you can open Cypress with:

npx cypress open

The first time you run this command, Cypress will create its initial folder structure including example tests.

Project Structure

After initialization, you'll have a cypress folder with the following structure:

cypress/
├── e2e/              # Contains your test files
├── fixtures/         # Test data files (JSON, etc.)
├── support/          # Support files (commands, utilities)
│   ├── commands.js   # Custom commands
│   └── e2e.js        # Global configuration
└── downloads/        # Files downloaded during tests

Configuration

Cypress can be configured through a cypress.config.js file in your project root:

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

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    viewportWidth: 1280,
    viewportHeight: 720,
    defaultCommandTimeout: 5000,
    experimentalStudio: true,
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
  },
})

Adding Cypress Scripts to package.json

Add convenient npm scripts to run Cypress:

"scripts": {
  "cypress:open": "cypress open",
  "cypress:run": "cypress run",
  "test:e2e": "start-server-and-test start http://localhost:3000 cypress:run"
}

Testing in Different Environments

You can create different configurations for various environments:

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

module.exports = defineConfig({
  e2e: {
    // Base configuration for all environments
    defaultCommandTimeout: 5000,
    viewportWidth: 1280,
    viewportHeight: 720,
    
    // Environment-specific settings
    env: {
      // Default (development)
      baseUrl: 'http://localhost:3000',
      apiUrl: 'http://localhost:3001/api',
      
      // Staging
      staging: {
        baseUrl: 'https://staging.example.com',
        apiUrl: 'https://api-staging.example.com'
      },
      
      // Production
      production: {
        baseUrl: 'https://example.com',
        apiUrl: 'https://api.example.com'
      }
    },
    
    setupNodeEvents(on, config) {
      // Use environment variable to switch environments
      const environment = process.env.CYPRESS_ENVIRONMENT || 'development'
      
      if (environment !== 'development') {
        // Override settings with environment-specific ones
        config.baseUrl = config.env[environment].baseUrl
        config.env.apiUrl = config.env[environment].apiUrl
      }
      
      return config
    }
  }
})

Then run with: CYPRESS_ENVIRONMENT=staging npx cypress run

Writing Your First Cypress Test

Let's start with a simple test to understand the basic structure and syntax of Cypress tests.

Basic Test Structure

// cypress/e2e/home.cy.js
describe('Home Page', () => {
  beforeEach(() => {
    // Visit the homepage before each test
    cy.visit('/')
  })

  it('displays the welcome message', () => {
    // Check if the welcome message is displayed
    cy.get('h1').should('contain', 'Welcome to Our App')
  })

  it('navigates to login page when login button is clicked', () => {
    // Find and click the login button
    cy.get('a[href="/login"]').click()
    
    // Verify we've navigated to the login page
    cy.url().should('include', '/login')
    cy.get('h1').should('contain', 'Login')
  })
})

Key Cypress Commands

Cypress Chaining Syntax

Cypress uses a unique chainable API that's both powerful and readable:

cy.get('form')
  .find('input[name="email"]')
  .type('test@example.com')
  .should('have.value', 'test@example.com')
  
cy.get('form')
  .find('input[name="password"]')
  .type('password123')
  .should('have.value', 'password123')
  
cy.get('form')
  .find('button[type="submit"]')
  .click()
  
cy.url().should('include', '/dashboard')

Understanding Cypress's Automatic Waiting

One of Cypress's most powerful features is its automatic waiting. Unlike other testing tools, Cypress automatically waits for elements to exist and be actionable before interacting with them.

// No need for explicit waits like this:
// await driver.wait(until.elementLocated(By.css('.dynamic-element')), 5000);

// Cypress automatically waits up to the defaultCommandTimeout:
cy.get('.dynamic-element').click()  // Waits for element before clicking

This eliminates the need for arbitrary timeouts and sleep commands that make tests flaky.

Selectors and Element Interaction

Best Practices for Selectors

Choosing the right selectors is crucial for test stability. Here's a ranking from most to least stable:

  1. Data Attributes: cy.get('[data-testid="submit-button"]')
  2. CSS IDs: cy.get('#username')
  3. CSS Classes: cy.get('.btn-primary')
  4. Element Types with Attributes: cy.get('input[name="email"]')
  5. Text Content: cy.contains('Sign In')
  6. DOM Structure: cy.get('form > button')

Adding Data Attributes for Testing

The most maintainable approach is to add dedicated data attributes to your HTML:

<button 
  data-testid="login-button" 
  class="btn btn-primary"
>
  Sign In
</button>

// In your test:
cy.get('[data-testid="login-button"]').click()

Common Element Interactions

// Clicking elements
cy.get('[data-testid="submit-button"]').click()
cy.get('.dropdown-toggle').click()

// Typing and clearing input
cy.get('#email').type('test@example.com')
cy.get('#password').type('password123')
cy.get('#search').clear().type('new search term')

// Checkboxes and radio buttons
cy.get('#terms-checkbox').check()
cy.get('#terms-checkbox').uncheck()
cy.get('[type="radio"]').check('option1')

// Select dropdowns
cy.get('select').select('Option 1')

// Multiple elements
cy.get('ul > li').should('have.length', 5)
cy.get('ul > li').eq(2).should('contain', 'Item 3')

// Dragable elements
cy.get('.dragable').drag('.drop-target')  // Requires plugin

Working with iframes

Working with iframes requires the cypress-iframe plugin:

// Install the plugin
npm install -D cypress-iframe

// Import in support file
// cypress/support/e2e.js
import 'cypress-iframe'

// Use in tests
cy.frameLoaded('#my-iframe')  // Load the iframe
cy.iframe().find('#element-inside-iframe').should('be.visible')
cy.iframe().find('#button-inside-iframe').click()

Working with Shadow DOM

Modern web components often use Shadow DOM, which requires special handling:

// Basic Shadow DOM traversal
cy.get('my-component')
  .shadow()
  .find('.inside-shadow-dom')
  .click()

// Deeper Shadow DOM traversal
cy.get('parent-component')
  .shadow()
  .find('child-component')
  .shadow()
  .find('.deeply-nested')
  .should('be.visible')

Assertions and Expectations

Cypress builds on Chai, Sinon, and other libraries to provide powerful assertion capabilities.

Basic Assertions

// Visibility
cy.get('.element').should('be.visible')
cy.get('.hidden-element').should('not.be.visible')

// Existence
cy.get('.existing-element').should('exist')
cy.get('.non-existent-element').should('not.exist')

// Text content
cy.get('h1').should('contain', 'Welcome')
cy.get('p').should('have.text', 'Exact text match')

// Attribute values
cy.get('input').should('have.value', 'Input text')
cy.get('[data-testid="status"]').should('have.attr', 'data-status', 'active')

// CSS classes
cy.get('button').should('have.class', 'active')
cy.get('button').should('not.have.class', 'disabled')

// CSS properties
cy.get('.element').should('have.css', 'color', 'rgb(255, 0, 0)')

// Element state
cy.get('input').should('be.disabled')
cy.get('input').should('be.enabled')
cy.get('input[type="checkbox"]').should('be.checked')

// Count elements
cy.get('ul > li').should('have.length', 5)
cy.get('tr').should('have.length.at.least', 1)
cy.get('.results').should('have.length.lessThan', 10)

Chaining Assertions

cy.get('.element')
  .should('be.visible')
  .and('contain', 'Expected text')
  .and('have.class', 'active')

Common Assertion Patterns

// Verify a successful form submission
cy.get('form').submit()
cy.get('.success-message').should('be.visible')
cy.get('.error-message').should('not.exist')

// Verify correct navigation
cy.get('.nav-link').click()
cy.url().should('include', '/expected-path')
cy.get('h1').should('contain', 'Expected Page Title')

// Verify list contents 
cy.get('.user-list li')
  .should('have.length', 3)
  .first().should('contain', 'User One')
  .next().should('contain', 'User Two')
  .next().should('contain', 'User Three')

// Verify element state after interaction
cy.get('.accordion-header').click()
cy.get('.accordion-content')
  .should('be.visible')
  .and('contain', 'Accordion content')

// More complex DOM verification
cy.get('table tbody tr')
  .should('have.length.greaterThan', 0)
  .each(($row) => {
    cy.wrap($row)
      .find('td')
      .first()
      .should('not.be.empty')
  })

Advanced: Custom Assertions

You can create custom assertions for specific application needs:

// cypress/support/commands.js
Chai.Assertion.addMethod('containIgnoreCase', function(text) {
  const actualText = this._obj.text().toLowerCase()
  const expectedText = text.toLowerCase()
  
  this.assert(
    actualText.includes(expectedText),
    `expected '${actualText}' to contain '${expectedText}' (case insensitive)`,
    `expected '${actualText}' not to contain '${expectedText}' (case insensitive)`
  )
})

// Register custom Cypress command that uses the assertion
Cypress.Commands.add('shouldContainIgnoreCase', { prevSubject: true }, (subject, text) => {
  cy.wrap(subject).should(($el) => {
    new Chai.Assertion($el).containIgnoreCase(text)
  })
  return cy.wrap(subject)
})

// Use in tests
cy.get('h1').shouldContainIgnoreCase('welcome')

Testing User Authentication

Authentication is a critical part of most applications, and testing it efficiently is important.

Testing the Login Flow via UI

describe('Authentication', () => {
  it('should login successfully with valid credentials', () => {
    cy.visit('/login')
    
    cy.get('[data-testid="email-input"]').type('valid@example.com')
    cy.get('[data-testid="password-input"]').type('correctpassword')
    cy.get('[data-testid="login-button"]').click()
    
    // Verify successful login
    cy.url().should('include', '/dashboard')
    cy.get('[data-testid="user-greeting"]').should('contain', 'Welcome')
    cy.getCookie('auth_token').should('exist')
  })
  
  it('should show error with invalid credentials', () => {
    cy.visit('/login')
    
    cy.get('[data-testid="email-input"]').type('valid@example.com')
    cy.get('[data-testid="password-input"]').type('wrongpassword')
    cy.get('[data-testid="login-button"]').click()
    
    // Verify error message
    cy.get('[data-testid="login-error"]')
      .should('be.visible')
      .and('contain', 'Invalid email or password')
    
    // Verify we're still on the login page
    cy.url().should('include', '/login')
  })
  
  it('should validate form inputs', () => {
    cy.visit('/login')
    
    // Submit without entering anything
    cy.get('[data-testid="login-button"]').click()
    
    // Verify validation errors
    cy.get('[data-testid="email-error"]').should('be.visible')
    cy.get('[data-testid="password-error"]').should('be.visible')
    
    // Enter invalid email
    cy.get('[data-testid="email-input"]').type('not-an-email')
    cy.get('[data-testid="login-button"]').click()
    
    // Verify email validation error
    cy.get('[data-testid="email-error"]')
      .should('be.visible')
      .and('contain', 'valid email')
  })
})

Programmatic Login for Efficiency

For tests that require an authenticated user but aren't testing the login flow itself, use a programmatic login approach to save time:

// cypress/support/commands.js
Cypress.Commands.add('login', (email = 'test@example.com', password = 'password123') => {
  // Option 1: UI Login (slow but tests full flow)
  // cy.visit('/login')
  // cy.get('[data-testid="email-input"]').type(email)
  // cy.get('[data-testid="password-input"]').type(password)
  // cy.get('[data-testid="login-button"]').click()
  
  // Option 2: API Login (fast, skips UI)
  cy.request({
    method: 'POST',
    url: '/api/login',
    body: { email, password }
  }).then((response) => {
    expect(response.status).to.eq(200)
    expect(response.body.token).to.exist
    
    // Set the token in localStorage or as a cookie
    window.localStorage.setItem('authToken', response.body.token)
    cy.setCookie('auth_token', response.body.token)
  })
  
  // Visit the app to apply the authentication
  cy.visit('/')
})

// Use in tests
describe('Authenticated Features', () => {
  beforeEach(() => {
    cy.login()
  })
  
  it('should display user profile', () => {
    cy.visit('/profile')
    cy.get('[data-testid="profile-name"]').should('contain', 'Test User')
  })
})

Testing Authorization and Protected Routes

describe('Authorization', () => {
  it('should redirect unauthenticated users to login', () => {
    // Clear any existing auth
    cy.clearCookies()
    cy.clearLocalStorage()
    
    // Try to access protected route
    cy.visit('/admin/settings')
    
    // Verify redirect to login
    cy.url().should('include', '/login')
  })
  
  it('should deny access to unauthorized resources', () => {
    // Login as regular user
    cy.login('regular@example.com', 'password123')
    
    // Try to access admin-only route
    cy.visit('/admin/settings')
    
    // Verify access denied
    cy.get('[data-testid="access-denied"]').should('be.visible')
  })
  
  it('should allow admins to access admin features', () => {
    // Login as admin
    cy.login('admin@example.com', 'adminpass')
    
    // Access admin route
    cy.visit('/admin/settings')
    
    // Verify access granted
    cy.get('[data-testid="admin-settings"]').should('be.visible')
  })
})

Testing OAuth Flows

Testing OAuth can be challenging due to third-party redirects. Here are some approaches:

  1. Mock the OAuth Provider: Use cy.intercept() to simulate the OAuth flow
  2. Use a Test User: Create a real user on the OAuth provider for testing
  3. Programmatic Authentication: Bypass the UI flow and set tokens directly
// Example of mocking OAuth response
cy.intercept('GET', '/api/auth/callback/google*', {
  statusCode: 200,
  body: {
    token: 'fake-oauth-token',
    user: {
      id: '123',
      name: 'Test User',
      email: 'test@example.com'
    }
  }
}).as('oauthCallback')

cy.get('[data-testid="google-login"]').click()
cy.wait('@oauthCallback')
cy.url().should('include', '/dashboard')

Testing Forms and User Input

Basic Form Interaction

describe('Contact Form', () => {
  beforeEach(() => {
    cy.visit('/contact')
  })
  
  it('should submit the form with valid data', () => {
    // Fill out the form
    cy.get('#name').type('John Doe')
    cy.get('#email').type('john@example.com')
    cy.get('#subject').select('Support')
    cy.get('#message').type('This is a test message')
    cy.get('#terms').check()
    
    // Submit the form
    cy.get('form').submit()
    
    // Verify success message
    cy.get('.success-message')
      .should('be.visible')
      .and('contain', 'Thank you for your message')
  })
  
  it('should show validation errors for empty fields', () => {
    // Submit empty form
    cy.get('form').submit()
    
    // Verify validation errors
    cy.get('#name-error').should('be.visible')
    cy.get('#email-error').should('be.visible')
    cy.get('#message-error').should('be.visible')
    
    // Fill one field and verify error is removed
    cy.get('#name').type('John Doe')
    cy.get('#name-error').should('not.exist')
    
    // Other errors should still be visible
    cy.get('#email-error').should('be.visible')
  })
  
  it('should validate email format', () => {
    cy.get('#email').type('invalid-email')
    cy.get('form').submit()
    
    cy.get('#email-error')
      .should('be.visible')
      .and('contain', 'valid email')
  })
})

Working with Different Input Types

describe('Form Input Types', () => {
  beforeEach(() => {
    cy.visit('/form-demo')
  })
  
  it('interacts with various input types', () => {
    // Text input
    cy.get('#text-input').type('Hello, World!')
    
    // Number input
    cy.get('#number-input').type('42')
    
    // Date input
    cy.get('#date-input').type('2025-01-01')
    
    // Textarea
    cy.get('#textarea').type('Multiple\nlines\nof text')
    
    // Radio buttons
    cy.get('#radio-option-2').check()
    
    // Checkboxes
    cy.get('#checkbox-1').check()
    cy.get('#checkbox-3').check()
    
    // Select dropdown
    cy.get('#select-input').select('option2')
    
    // Multi-select
    cy.get('#multi-select').select(['option1', 'option3'])
    
    // Range slider (requires using invoke to set value)
    cy.get('#range-input').invoke('val', 75).trigger('change')
    
    // Color picker
    cy.get('#color-input').invoke('val', '#00ff00').trigger('change')
    
    // File upload (requires a plugin or workaround)
    cy.get('#file-input').attachFile('test-file.jpg')
  })
})

Testing Form Submissions

describe('Form Submission', () => {
  beforeEach(() => {
    cy.visit('/signup')
  })
  
  it('submits form with network request', () => {
    // Intercept the API request
    cy.intercept('POST', '/api/users').as('createUser')
    
    // Fill and submit form
    cy.get('#username').type('newuser')
    cy.get('#email').type('new@example.com')
    cy.get('#password').type('Password123')
    cy.get('#confirm-password').type('Password123')
    cy.get('#signup-button').click()
    
    // Wait for the request and verify payload
    cy.wait('@createUser').then((interception) => {
      expect(interception.request.body).to.deep.include({
        username: 'newuser',
        email: 'new@example.com'
      })
      
      // Password should be included but not verified here
      expect(interception.request.body).to.have.property('password')
    })
    
    // Verify UI reflects successful submission
    cy.url().should('include', '/welcome')
    cy.get('.welcome-message').should('contain', 'newuser')
  })
  
  it('handles submission errors', () => {
    // Intercept and mock a server error
    cy.intercept('POST', '/api/users', {
      statusCode: 400,
      body: {
        error: 'Email already exists'
      }
    }).as('failedCreate')
    
    // Fill and submit form
    cy.get('#username').type('newuser')
    cy.get('#email').type('existing@example.com')
    cy.get('#password').type('Password123')
    cy.get('#confirm-password').type('Password123')
    cy.get('#signup-button').click()
    
    // Verify error handling
    cy.wait('@failedCreate')
    cy.get('.error-message')
      .should('be.visible')
      .and('contain', 'Email already exists')
    
    // Form should not be cleared on error
    cy.get('#username').should('have.value', 'newuser')
    cy.get('#email').should('have.value', 'existing@example.com')
  })
})

Testing File Uploads

File uploads require special handling in Cypress. The recommended approach is to use the cypress-file-upload plugin:

// Install the plugin
npm install --save-dev cypress-file-upload

// Import in your support file
// cypress/support/e2e.js
import 'cypress-file-upload';

// Use in your tests
describe('File Upload', () => {
  it('uploads a file', () => {
    cy.visit('/upload')
    
    // Add test file to fixtures folder first
    cy.get('input[type="file"]').attachFile('example.pdf')
    cy.get('button[type="submit"]').click()
    
    cy.get('.upload-success').should('be.visible')
    cy.get('.file-name').should('contain', 'example.pdf')
  })
  
  it('uploads multiple files', () => {
    cy.visit('/upload')
    
    cy.get('input[type="file"][multiple]').attachFile([
      'example1.pdf', 
      'example2.jpg'
    ])
    cy.get('button[type="submit"]').click()
    
    cy.get('.upload-success').should('be.visible')
    cy.get('.file-count').should('contain', '2')
  })
})

Network Requests and Mocking

Cypress provides powerful tools for testing API interactions and network requests.

Intercepting Network Requests

describe('Network Requests', () => {
  beforeEach(() => {
    cy.visit('/products')
  })
  
  it('loads products from API', () => {
    // Intercept GET request to products API
    cy.intercept('GET', '/api/products*').as('getProducts')
    
    // Wait for the request to complete
    cy.wait('@getProducts')
    
    // Verify products are displayed
    cy.get('.product-card').should('have.length.greaterThan', 0)
  })
  
  it('shows loading state while fetching data', () => {
    // Intercept but delay the response
    cy.intercept('GET', '/api/products*', (req) => {
      req.reply((res) => {
        // Delay the response by 1 second
        res.delay = 1000
        return res
      })
    }).as('delayedProducts')
    
    // Reload the page
    cy.reload()
    
    // Verify loading state is shown
    cy.get('.loading-spinner').should('be.visible')
    
    // Wait for request to finish
    cy.wait('@delayedProducts')
    
    // Verify loading state is hidden
    cy.get('.loading-spinner').should('not.exist')
    
    // Verify products are displayed
    cy.get('.product-card').should('have.length.greaterThan', 0)
  })
})

Mocking API Responses

describe('API Mocking', () => {
  it('displays product data from mock API', () => {
    // Create mock product data
    const mockProducts = [
      { id: 1, name: 'Mock Product 1', price: 19.99, image: 'mock1.jpg' },
      { id: 2, name: 'Mock Product 2', price: 29.99, image: 'mock2.jpg' },
      { id: 3, name: 'Mock Product 3', price: 39.99, image: 'mock3.jpg' }
    ]
    
    // Intercept the API request and return mock data
    cy.intercept('GET', '/api/products*', {
      statusCode: 200,
      body: mockProducts
    }).as('getMockProducts')
    
    // Visit the products page
    cy.visit('/products')
    
    // Wait for the mocked request
    cy.wait('@getMockProducts')
    
    // Verify mock data is displayed
    cy.get('.product-card').should('have.length', 3)
    cy.get('.product-card').first().should('contain', 'Mock Product 1')
    cy.get('.product-card').first().should('contain', '$19.99')
  })
  
  it('handles empty product list', () => {
    // Mock empty product list
    cy.intercept('GET', '/api/products*', {
      statusCode: 200,
      body: []
    }).as('getEmptyProducts')
    
    // Visit the products page
    cy.visit('/products')
    
    // Wait for the mocked request
    cy.wait('@getEmptyProducts')
    
    // Verify empty state is displayed
    cy.get('.product-card').should('not.exist')
    cy.get('.empty-state').should('be.visible')
    cy.get('.empty-state').should('contain', 'No products found')
  })
  
  it('handles API error states', () => {
    // Mock server error
    cy.intercept('GET', '/api/products*', {
      statusCode: 500,
      body: {
        error: 'Internal server error'
      }
    }).as('getProductsError')
    
    // Visit the products page
    cy.visit('/products')
    
    // Wait for the error response
    cy.wait('@getProductsError')
    
    // Verify error state is displayed
    cy.get('.error-message').should('be.visible')
    cy.get('.error-message').should('contain', 'Failed to load products')
    
    // Retry button should be visible
    cy.get('.retry-button').should('be.visible')
    
    // Test retry functionality
    cy.intercept('GET', '/api/products*', {
      statusCode: 200,
      body: [{ id: 1, name: 'Product after retry', price: 19.99 }]
    }).as('retryProducts')
    
    cy.get('.retry-button').click()
    cy.wait('@retryProducts')
    
    // Verify products loaded after retry
    cy.get('.product-card').should('have.length', 1)
    cy.get('.error-message').should('not.exist')
  })
})

Testing Form Submissions with Network Requests

describe('Form with API Interaction', () => {
  beforeEach(() => {
    cy.visit('/checkout')
  })
  
  it('submits order and shows confirmation', () => {
    // Mock a successful order submission
    cy.intercept('POST', '/api/orders', {
      statusCode: 201,
      body: {
        id: 'order-123',
        status: 'confirmed',
        total: 99.99
      }
    }).as('createOrder')
    
    // Fill out the checkout form
    cy.get('#name').type('Test User')
    cy.get('#address').type('123 Test St')
    cy.get('#city').type('Test City')
    cy.get('#zip').type('12345')
    cy.get('#payment-card').type('4242424242424242')
    cy.get('#payment-expiry').type('1225')
    cy.get('#payment-cvc').type('123')
    
    // Submit the form
    cy.get('#checkout-button').click()
    
    // Wait for the API request and verify the payload
    cy.wait('@createOrder').then((interception) => {
      // Verify request payload
      expect(interception.request.body).to.have.property('name', 'Test User')
      expect(interception.request.body).to.have.property('address', '123 Test St')
      
      // Verify payment details are included but don't check actual values for security
      expect(interception.request.body).to.have.property('payment')
    })
    
    // Verify confirmation page
    cy.url().should('include', '/confirmation')
    cy.get('.order-id').should('contain', 'order-123')
    cy.get('.order-status').should('contain', 'confirmed')
    cy.get('.order-total').should('contain', '99.99')
  })
  
  it('handles validation errors from server', () => {
    // Mock a validation error response
    cy.intercept('POST', '/api/orders', {
      statusCode: 400,
      body: {
        errors: {
          name: 'Name is required',
          payment: 'Invalid card number'
        }
      }
    }).as('orderValidationError')
    
    // Fill out form with some invalid data
    cy.get('#address').type('123 Test St')
    cy.get('#city').type('Test City')
    cy.get('#zip').type('12345')
    
    // Submit the form
    cy.get('#checkout-button').click()
    
    // Wait for the error response
    cy.wait('@orderValidationError')
    
    // Verify error messages are displayed
    cy.get('#name-error').should('contain', 'Name is required')
    cy.get('#payment-error').should('contain', 'Invalid card number')
    
    // Verify we're still on the checkout page
    cy.url().should('include', '/checkout')
  })
  
  it('handles network failures gracefully', () => {
    // Mock a network failure
    cy.intercept('POST', '/api/orders', {
      forceNetworkError: true
    }).as('networkError')
    
    // Fill out the checkout form
    cy.get('#name').type('Test User')
    cy.get('#address').type('123 Test St')
    cy.get('#city').type('Test City')
    cy.get('#zip').type('12345')
    cy.get('#payment-card').type('4242424242424242')
    cy.get('#payment-expiry').type('1225')
    cy.get('#payment-cvc').type('123')
    
    // Submit the form
    cy.get('#checkout-button').click()
    
    // Wait for the network error
    cy.wait('@networkError')
    
    // Verify error message is displayed
    cy.get('.network-error')
      .should('be.visible')
      .and('contain', 'connection')
    
    // Verify retry option is available
    cy.get('.retry-button').should('be.visible')
  })
})

Advanced: Stubbing Complex API Behaviors

You can simulate more complex API behaviors by using dynamic responses:

describe('Shopping Cart', () => {
  beforeEach(() => {
    // Start with an empty cart
    cy.intercept('GET', '/api/cart', {
      statusCode: 200,
      body: { items: [], total: 0 }
    }).as('getEmptyCart')
    
    cy.visit('/products')
  })
  
  it('adds and removes items from cart', () => {
    // Keep track of cart items for dynamic responses
    let cartItems = []
    
    // Mock add to cart endpoint
    cy.intercept('POST', '/api/cart/items', (req) => {
      const newItem = req.body
      cartItems.push(newItem)
      
      req.reply({
        statusCode: 200,
        body: {
          items: cartItems,
          total: cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
        }
      })
    }).as('addToCart')
    
    // Mock remove from cart endpoint
    cy.intercept('DELETE', '/api/cart/items/*', (req) => {
      const itemId = req.url.split('/').pop()
      cartItems = cartItems.filter(item => item.id !== itemId)
      
      req.reply({
        statusCode: 200,
        body: {
          items: cartItems,
          total: cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
        }
      })
    }).as('removeFromCart')
    
    // Add first product to cart
    cy.get('.product-card').first().find('.product-name').invoke('text').as('firstProductName')
    cy.get('.product-card').first().find('.add-to-cart').click()
    
    // Wait for the request and verify cart updated
    cy.wait('@addToCart')
    cy.get('.cart-count').should('contain', '1')
    
    // Go to cart page
    cy.get('.cart-icon').click()
    cy.url().should('include', '/cart')
    
    // Verify product is in cart
    cy.get('@firstProductName').then((productName) => {
      cy.get('.cart-item-name').should('contain', productName)
    })
    
    // Remove item from cart
    cy.get('.remove-item').click()
    
    // Wait for the request and verify cart is empty
    cy.wait('@removeFromCart')
    cy.get('.cart-item').should('not.exist')
    cy.get('.empty-cart-message').should('be.visible')
  })
})

Managing Test Data and State

Proper test data management is essential for reliable and maintainable E2E tests.

Using Fixtures for Test Data

// cypress/fixtures/users.json
{
  "admin": {
    "id": "1",
    "email": "admin@example.com",
    "password": "adminpass",
    "name": "Admin User",
    "role": "admin"
  },
  "customer": {
    "id": "2",
    "email": "customer@example.com",
    "password": "customerpass",
    "name": "Test Customer",
    "role": "customer"
  }
}

// cypress/fixtures/products.json
[
  {
    "id": "prod-1",
    "name": "Test Product 1",
    "price": 19.99,
    "description": "This is a test product",
    "image": "test1.jpg",
    "category": "electronics"
  },
  {
    "id": "prod-2",
    "name": "Test Product 2",
    "price": 29.99,
    "description": "Another test product",
    "image": "test2.jpg",
    "category": "clothing"
  }
]

// Using fixtures in tests
describe('Product Management', () => {
  let adminUser;
  let testProducts;
  
  before(() => {
    // Load test data from fixtures
    cy.fixture('users').then((users) => {
      adminUser = users.admin;
    });
    
    cy.fixture('products').then((products) => {
      testProducts = products;
    });
  });
  
  beforeEach(() => {
    // Login as admin
    cy.login(adminUser.email, adminUser.password);
    
    // Mock products API using fixture data
    cy.intercept('GET', '/api/products', {
      statusCode: 200,
      body: testProducts
    }).as('getProducts');
    
    cy.visit('/admin/products');
    cy.wait('@getProducts');
  });
  
  it('displays product list', () => {
    cy.get('.product-row').should('have.length', testProducts.length);
    
    // Check first product details
    cy.get('.product-row').first().within(() => {
      cy.get('.product-name').should('contain', testProducts[0].name);
      cy.get('.product-price').should('contain', testProducts[0].price);
    });
  });
})

Data Setup Using Custom Commands

// cypress/support/commands.js
Cypress.Commands.add('createTestProduct', (overrides = {}) => {
  // Default product data
  const defaultProduct = {
    name: `Test Product ${Date.now()}`,
    price: 19.99,
    description: 'This is an automatically created test product',
    category: 'test',
    inventory: 100
  };
  
  // Merge defaults with overrides
  const product = { ...defaultProduct, ...overrides };
  
  // Create product via API
  return cy.request({
    method: 'POST',
    url: '/api/products',
    headers: {
      'Authorization': `Bearer ${Cypress.env('adminToken')}`
    },
    body: product
  }).then((response) => {
    expect(response.status).to.eq(201);
    return response.body; // Return created product
  });
});

Cypress.Commands.add('deleteTestProduct', (productId) => {
  return cy.request({
    method: 'DELETE',
    url: `/api/products/${productId}`,
    headers: {
      'Authorization': `Bearer ${Cypress.env('adminToken')}`
    }
  }).then((response) => {
    expect(response.status).to.eq(204);
  });
});

// Using data setup commands in tests
describe('Product Detail', () => {
  let testProduct;
  
  before(() => {
    // Set admin token (could come from a login command)
    Cypress.env('adminToken', 'your-admin-token');
    
    // Create a test product
    cy.createTestProduct().then((product) => {
      testProduct = product;
    });
  });
  
  after(() => {
    // Clean up test product
    if (testProduct && testProduct.id) {
      cy.deleteTestProduct(testProduct.id);
    }
  });
  
  it('displays product details correctly', () => {
    cy.visit(`/products/${testProduct.id}`);
    
    cy.get('.product-name').should('contain', testProduct.name);
    cy.get('.product-price').should('contain', testProduct.price);
    cy.get('.product-description').should('contain', testProduct.description);
  });
})

Advanced: Database Seeding via Tasks

For more complex data needs, you can use Node.js tasks to seed the database directly:

// cypress.config.js
const { defineConfig } = require('cypress');
const { Pool } = require('pg'); // PostgreSQL client
const { MongoClient } = require('mongodb'); // MongoDB client

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      // Database tasks
      on('task', {
        // PostgreSQL database seeding
        async seedPostgresDatabase({ sql, params }) {
          const pool = new Pool({
            connectionString: config.env.TEST_DATABASE_URL
          });
          
          try {
            const result = await pool.query(sql, params);
            await pool.end();
            return result.rows;
          } catch (error) {
            console.error('Database task error:', error);
            throw error;
          }
        },
        
        // MongoDB database seeding
        async seedMongoDatabase({ collection, documents }) {
          const client = new MongoClient(config.env.MONGO_URI);
          
          try {
            await client.connect();
            const db = client.db('test-db');
            const result = await db.collection(collection).insertMany(documents);
            await client.close();
            return result.insertedIds;
          } catch (error) {
            console.error('MongoDB task error:', error);
            throw error;
          }
        },
        
        // Clean up test data
        async cleanTestData({ collection }) {
          const client = new MongoClient(config.env.MONGO_URI);
          
          try {
            await client.connect();
            const db = client.db('test-db');
            await db.collection(collection).deleteMany({ 
              isTestData: true 
            });
            await client.close();
            return null;
          } catch (error) {
            console.error('Clean up error:', error);
            throw error;
          }
        }
      });
    }
  }
});

// Using database seeding in tests
describe('Order History', () => {
  const testUser = {
    _id: `user_${Date.now()}`,
    email: `test_${Date.now()}@example.com`,
    name: 'Test User',
    isTestData: true
  };
  
  const testOrders = [
    {
      _id: `order_${Date.now()}_1`,
      userId: testUser._id,
      total: 99.99,
      status: 'completed',
      items: [{ name: 'Test Product', price: 99.99, quantity: 1 }],
      createdAt: new Date(Date.now() - 86400000), // 1 day ago
      isTestData: true
    },
    {
      _id: `order_${Date.now()}_2`,
      userId: testUser._id,
      total: 149.98,
      status: 'processing',
      items: [{ name: 'Another Product', price: 74.99, quantity: 2 }],
      createdAt: new Date(),
      isTestData: true
    }
  ];
  
  before(() => {
    // Seed database with test user
    cy.task('seedMongoDatabase', {
      collection: 'users',
      documents: [testUser]
    });
    
    // Seed database with test orders
    cy.task('seedMongoDatabase', {
      collection: 'orders',
      documents: testOrders
    });
    
    // Create auth token for the test user
    cy.request('POST', '/api/test-auth', {
      userId: testUser._id
    }).then((response) => {
      Cypress.env('testUserToken', response.body.token);
    });
  });
  
  after(() => {
    // Clean up test data
    cy.task('cleanTestData', { collection: 'users' });
    cy.task('cleanTestData', { collection: 'orders' });
  });
  
  beforeEach(() => {
    // Set auth token before each test
    cy.visit('/orders', {
      onBeforeLoad: (win) => {
        win.localStorage.setItem('authToken', Cypress.env('testUserToken'));
      }
    });
  });
  
  it('displays order history for user', () => {
    cy.get('.order-item').should('have.length', 2);
    
    // Verify order details
    cy.get('.order-item').first().within(() => {
      cy.get('.order-status').should('contain', 'processing');
      cy.get('.order-total').should('contain', '149.98');
    });
    
    cy.get('.order-item').last().within(() => {
      cy.get('.order-status').should('contain', 'completed');
      cy.get('.order-total').should('contain', '99.99');
    });
  });
})

Test Isolation Best Practices

Properly isolating tests improves reliability and prevents interdependencies:

  • Reset Application State: Clear localStorage, cookies, and other state between tests
  • Use Unique Test Data: Include timestamps or random values to prevent collisions
  • Clean Up After Tests: Remove test data created during tests
  • Mock External Dependencies: Control the behavior of external services
  • Avoid Shared State Between Tests: Don't rely on state from previous tests
// Example of test isolation setup
beforeEach(() => {
  // Clear application state
  cy.clearLocalStorage();
  cy.clearCookies();
  
  // Reset API state
  cy.request({
    method: 'POST',
    url: '/api/testing/reset',
    failOnStatusCode: false
  });
  
  // Create fresh test data with unique identifiers
  const timestamp = Date.now();
  const randomId = Math.random().toString(36).substring(2);
  const testData = {
    id: `test_${randomId}`,
    name: `Test Item ${timestamp}`,
    // ...other properties
  };
  
  // Save for use in test
  cy.wrap(testData).as('testData');
});

Advanced Cypress Techniques

Custom Commands for Common Operations

// cypress/support/commands.js
// Navigation command
Cypress.Commands.add('navigateTo', (page) => {
  const pages = {
    home: '/',
    products: '/products',
    cart: '/cart',
    checkout: '/checkout',
    account: '/account',
    admin: '/admin'
  };
  
  if (!pages[page]) {
    throw new Error(`Unknown page: ${page}`);
  }
  
  return cy.visit(pages[page]);
});

// Table verification command
Cypress.Commands.add('verifyTableData', (selector, expectedData) => {
  cy.get(selector).find('tbody tr').should('have.length', expectedData.length);
  
  expectedData.forEach((rowData, rowIndex) => {
    const row = cy.get(selector).find('tbody tr').eq(rowIndex);
    
    Object.entries(rowData).forEach(([key, value]) => {
      if (key.startsWith('_')) return; // Skip fields starting with underscore
      
      row.find(`td.${key}, td[data-field="${key}"]`).should('contain', value);
    });
  });
});

// Verification command with retry logic
Cypress.Commands.add('verifyTextWithRetry', (selector, expectedText, options = {}) => {
  const { timeout = 10000, interval = 1000 } = options;
  
  const startTime = Date.now();
  const checkText = () => {
    return cy.get(selector).invoke('text').then((text) => {
      if (text.includes(expectedText)) {
        return true;
      }
      
      const elapsed = Date.now() - startTime;
      if (elapsed > timeout) {
        throw new Error(`Text "${expectedText}" not found in "${text}" after ${timeout}ms`);
      }
      
      cy.wait(interval);
      return checkText();
    });
  };
  
  return checkText().then(() => {
    return cy.get(selector).should('contain', expectedText);
  });
});

// Using custom commands
describe('Product Listing', () => {
  beforeEach(() => {
    cy.navigateTo('products');
  });
  
  it('displays product data in table', () => {
    const expectedProducts = [
      { name: 'Product 1', price: '$19.99', category: 'Electronics' },
      { name: 'Product 2', price: '$29.99', category: 'Clothing' }
    ];
    
    cy.verifyTableData('.products-table', expectedProducts);
  });
  
  it('updates inventory count after purchase', () => {
    // Add product to cart and checkout
    cy.get('.product-card').first().find('.add-to-cart').click();
    cy.navigateTo('cart');
    cy.get('.checkout-button').click();
    
    // Complete checkout process...
    
    // Verify inventory updated (using retry logic for eventual consistency)
    cy.navigateTo('products');
    cy.verifyTextWithRetry('.in-stock-count', 'In stock: 99', { 
      timeout: 15000,
      interval: 1000 
    });
  });
});

Working with iframes and Shadow DOM

// Testing payment form in iframe
describe('Payment Processing', () => {
  beforeEach(() => {
    cy.visit('/checkout');
    cy.get('#payment-form').should('be.visible');
  });
  
  it('processes payment through iframe', () => {
    // Load the payment iframe
    cy.frameLoaded('#payment-iframe');
    
    // Interact with elements inside the iframe
    cy.iframe('#payment-iframe').within(() => {
      cy.get('#card-number').type('4242424242424242');
      cy.get('#card-expiry').type('1225');
      cy.get('#card-cvc').type('123');
      cy.get('#card-name').type('Test User');
      cy.get('#submit-button').click();
    });
    
    // Verify payment success outside the iframe
    cy.get('.payment-success').should('be.visible');
  });
});

// Testing Shadow DOM components
describe('Custom Web Components', () => {
  beforeEach(() => {
    cy.visit('/components');
  });
  
  it('interacts with custom rating component', () => {
    // Get the custom element with Shadow DOM
    cy.get('custom-rating')
      .shadow() // Access Shadow DOM
      .find('.star') // Find elements inside Shadow DOM
      .eq(3) // Select the fourth star (zero-based)
      .click();
    
    // Verify the rating was updated
    cy.get('custom-rating')
      .invoke('attr', 'value')
      .should('eq', '4');
    
    // Access nested Shadow DOM
    cy.get('custom-form')
      .shadow()
      .find('custom-input')
      .shadow()
      .find('input')
      .type('Test Value');
    
    // Verify the value
    cy.get('custom-form')
      .shadow()
      .find('custom-input')
      .invoke('attr', 'value')
      .should('eq', 'Test Value');
  });
});

Visual Testing and Screenshots

// Basic visual testing with screenshots
describe('Visual Regression', () => {
  beforeEach(() => {
    cy.viewport(1280, 720); // Consistent viewport size
    cy.visit('/products');
  });
  
  it('product listing matches visual baseline', () => {
    // Wait for all images to load
    cy.get('img').should(($imgs) => {
      const promises = Array.from($imgs).map((img) => {
        return new Promise((resolve) => {
          if (img.complete) {
            resolve();
          } else {
            img.addEventListener('load', resolve);
            img.addEventListener('error', resolve); // Handle broken images
          }
        });
      });
      return Promise.all(promises);
    });
    
    // Take screenshot for comparison
    cy.screenshot('product-listing', { capture: 'viewport' });
  });
  
  it('takes component-specific screenshots', () => {
    // Capture specific component
    cy.get('.featured-products')
      .should('be.visible')
      .screenshot('featured-products-section');
      
    // Capture another component
    cy.get('.product-filters')
      .should('be.visible')
      .screenshot('product-filters');
  });
});

// Using Percy for visual testing (requires setup)
describe('Percy Visual Testing', () => {
  it('captures homepage for visual review', () => {
    cy.visit('/');
    cy.percySnapshot('Home Page');
  });
  
  it('captures product listing with different viewport sizes', () => {
    cy.visit('/products');
    
    // Desktop view
    cy.viewport(1280, 800);
    cy.percySnapshot('Product Listing - Desktop');
    
    // Tablet view
    cy.viewport(768, 1024);
    cy.percySnapshot('Product Listing - Tablet');
    
    // Mobile view
    cy.viewport(375, 667);
    cy.percySnapshot('Product Listing - Mobile');
  });
  
  it('captures application states', () => {
    cy.visit('/products');
    
    // Default state
    cy.percySnapshot('Products - Default');
    
    // With filters applied
    cy.get('[data-testid="category-filter"]').select('Electronics');
    cy.get('[data-testid="price-filter"]').invoke('val', 50).trigger('change');
    cy.get('[data-testid="apply-filters"]').click();
    
    cy.percySnapshot('Products - Filtered');
    
    // Empty state
    cy.get('[data-testid="category-filter"]').select('Non-existent Category');
    cy.get('[data-testid="apply-filters"]').click();
    
    cy.get('[data-testid="empty-results"]').should('be.visible');
    cy.percySnapshot('Products - Empty Results');
  });
});

Testing Accessibility with Cypress

Integrating accessibility testing into your E2E tests helps ensure your application is usable by everyone.

// Install cypress-axe
npm install --save-dev cypress-axe axe-core

// Import in support file
// cypress/support/e2e.js
import 'cypress-axe'

// Use in tests
describe('Accessibility Testing', () => {
  beforeEach(() => {
    cy.visit('/')
    cy.injectAxe() // Inject axe-core into the page
  })
  
  it('has no detectable accessibility violations on home page', () => {
    cy.checkA11y() // Test the entire page
  })
  
  it('has no accessibility violations in specific component', () => {
    cy.checkA11y('.navigation-menu') // Test specific component
  })
  
  it('can exclude specific elements from testing', () => {
    cy.checkA11y({
      exclude: ['.third-party-widget', '.legacy-component']
    })
  })
  
  it('tests pages with dynamic content', () => {
    // Test initial state
    cy.checkA11y()
    
    // Interact with page
    cy.get('.accordion-header').first().click()
    
    // Test expanded state
    cy.checkA11y('.accordion-panel')
    
    // Open modal
    cy.get('.open-modal-button').click()
    
    // Test modal for accessibility
    cy.get('.modal').should('be.visible')
    cy.checkA11y('.modal', {
      rules: {
        // Exclude specific rule for modals
        'focus-trap': { enabled: false }
      }
    })
  })
});

Organizing and Structuring Cypress Tests

Page Object Pattern

Using the Page Object Pattern helps organize test code and reduce duplication.

// cypress/support/pages/LoginPage.js
class LoginPage {
  // Selectors
  get emailInput() { return '[data-testid="email-input"]' }
  get passwordInput() { return '[data-testid="password-input"]' }
  get loginButton() { return '[data-testid="login-button"]' }
  get errorMessage() { return '[data-testid="login-error"]' }
  
  // Actions
  visit() {
    cy.visit('/login')
    return this
  }
  
  typeEmail(email) {
    cy.get(this.emailInput).clear().type(email)
    return this
  }
  
  typePassword(password) {
    cy.get(this.passwordInput).clear().type(password)
    return this
  }
  
  clickLogin() {
    cy.get(this.loginButton).click()
    return this
  }
  
  // Combined actions
  login(email, password) {
    this.typeEmail(email)
    this.typePassword(password)
    this.clickLogin()
    return this
  }
  
  // Assertions
  shouldShowError(message) {
    cy.get(this.errorMessage)
      .should('be.visible')
      .and('contain', message)
    return this
  }
  
  shouldNavigateToDashboard() {
    cy.url().should('include', '/dashboard')
    cy.get('[data-testid="dashboard-header"]').should('be.visible')
    return this
  }
}

export default new LoginPage()

// cypress/support/pages/DashboardPage.js
class DashboardPage {
  // Selectors
  get header() { return '[data-testid="dashboard-header"]' }
  get userGreeting() { return '[data-testid="user-greeting"]' }
  get notificationBell() { return '[data-testid="notification-bell"]' }
  get notificationCount() { return '[data-testid="notification-count"]' }
  get userMenu() { return '[data-testid="user-menu"]' }
  get logoutButton() { return '[data-testid="logout-button"]' }
  
  // Actions
  visit() {
    cy.visit('/dashboard')
    return this
  }
  
  clickNotificationBell() {
    cy.get(this.notificationBell).click()
    return this
  }
  
  openUserMenu() {
    cy.get(this.userMenu).click()
    return this
  }
  
  logout() {
    this.openUserMenu()
    cy.get(this.logoutButton).click()
    return this
  }
  
  // Assertions
  shouldGreetUser(name) {
    cy.get(this.userGreeting).should('contain', name)
    return this
  }
  
  shouldShowNotifications(count) {
    if (count > 0) {
      cy.get(this.notificationCount).should('contain', count)
    } else {
      cy.get(this.notificationCount).should('not.exist')
    }
    return this
  }
}

export default new DashboardPage()

// Using Page Objects in tests
import LoginPage from '../support/pages/LoginPage'
import DashboardPage from '../support/pages/DashboardPage'

describe('Authentication Flow', () => {
  it('logs in with valid credentials', () => {
    LoginPage
      .visit()
      .login('test@example.com', 'password123')
      .shouldNavigateToDashboard()
    
    DashboardPage
      .shouldGreetUser('Test User')
      .shouldShowNotifications(2)
  })
  
  it('shows error with invalid credentials', () => {
    LoginPage
      .visit()
      .login('test@example.com', 'wrongpassword')
      .shouldShowError('Invalid email or password')
  })
  
  it('logs out successfully', () => {
    // Login first
    LoginPage
      .visit()
      .login('test@example.com', 'password123')
    
    // Then log out
    DashboardPage
      .logout()
    
    // Verify back at login page
    cy.url().should('include', '/login')
  })
})

Test Organization with Folders and Files

cypress/
├── e2e/
│   ├── auth/
│   │   ├── login.cy.js
│   │   ├── register.cy.js
│   │   ├── password-reset.cy.js
│   │   └── logout.cy.js
│   ├── products/
│   │   ├── browsing.cy.js
│   │   ├── filtering.cy.js
│   │   ├── details.cy.js
│   │   └── admin.cy.js
│   ├── cart/
│   │   ├── add-to-cart.cy.js
│   │   ├── update-quantities.cy.js
│   │   └── remove-items.cy.js
│   ├── checkout/
│   │   ├── shipping-info.cy.js
│   │   ├── payment.cy.js
│   │   └── order-confirmation.cy.js
│   └── account/
│       ├── profile.cy.js
│       ├── orders.cy.js
│       └── preferences.cy.js
├── fixtures/
│   ├── users.json
│   ├── products.json
│   └── orders.json
├── support/
│   ├── commands.js
│   ├── e2e.js
│   └── pages/
│       ├── LoginPage.js
│       ├── RegisterPage.js
│       ├── ProductsPage.js
│       ├── ProductDetailPage.js
│       ├── CartPage.js
│       ├── CheckoutPage.js
│       └── AccountPage.js
├── plugins/
│   └── index.js
└── screenshots/
    └── (automatically generated during runs)

Running Specific Test Groups

// Run all tests
npx cypress run

// Run tests in a specific folder
npx cypress run --spec "cypress/e2e/auth/**/*.cy.js"

// Run a single test file
npx cypress run --spec "cypress/e2e/checkout/payment.cy.js"

// Run tests by tag (requires cypress-grep plugin)
npx cypress run --env grep="@smoke"

Tagging Tests for Different Purposes

// Install cypress-grep
npm install -D cypress-grep

// Import in support/e2e.js
import 'cypress-grep'

// Use tags in tests
describe('Checkout Process', { tags: ['@smoke', '@critical'] }, () => {
  it('completes checkout with valid payment', { tags: '@payment' }, () => {
    // Test code
  })
  
  it('validates required fields', { tags: '@validation' }, () => {
    // Test code
  })
})

// Run tests with specific tags
npx cypress run --env grep="@smoke"
npx cypress run --env grep="@critical+@payment" // AND condition
npx cypress run --env grep="@smoke+@payment,@critical" // OR condition

Test Strategy Organization

A comprehensive test strategy might include different types of tests:

  • Smoke Tests (@smoke): Quick tests that verify core functionality
  • Critical Path Tests (@critical): Tests of essential user flows
  • Regression Tests (@regression): Tests for areas prone to breaking
  • Feature Tests (@feature): Tests organized by feature area
  • Visual Tests (@visual): Tests focused on UI appearance
  • Performance Tests (@performance): Tests for response times and efficiency

Tags enable running different test suites for different purposes, like running smoke tests on every commit and full regression suites nightly.

Cypress in CI/CD Pipelines

GitHub Actions Integration

// .github/workflows/cypress.yml
name: E2E Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

jobs:
  cypress-run:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        # Run tests across multiple browsers
        browser: [ chrome, firefox, edge ]
    
    services:
      # Start MongoDB for the test database
      mongodb:
        image: mongo:5.0
        ports:
          - 27017:27017
    
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 16
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build frontend
        run: npm run build
      
      - name: Start backend server
        run: |
          npm run seed:test-db
          npm run start:server &
        env:
          NODE_ENV: test
          DATABASE_URL: mongodb://localhost:27017/test-db
          PORT: 3001
      
      - name: Start frontend
        run: |
          npm run start &
        env:
          REACT_APP_API_URL: http://localhost:3001/api
          PORT: 3000
      
      - name: Wait for servers to start
        run: |
          npx wait-on http://localhost:3000
          npx wait-on http://localhost:3001/api/health
      
      - name: Cypress run
        uses: cypress-io/github-action@v5
        with:
          browser: ${{ matrix.browser }}
          record: true # Requires Cypress Dashboard
          parallel: true # Parallelize tests
          group: 'UI Tests - ${{ matrix.browser }}'
          spec: cypress/e2e/**/*.cy.js
        env:
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          CYPRESS_BASE_URL: http://localhost:3000
          CYPRESS_API_URL: http://localhost:3001/api
      
      - name: Upload screenshots
        uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: cypress-screenshots-${{ matrix.browser }}
          path: cypress/screenshots
          if-no-files-found: ignore
      
      - name: Upload videos
        uses: actions/upload-artifact@v3
        if: always()
        with:
          name: cypress-videos-${{ matrix.browser }}
          path: cypress/videos
          if-no-files-found: ignore

CircleCI Integration

// .circleci/config.yml
version: 2.1
orbs:
  cypress: cypress-io/cypress@2
workflows:
  build-and-test:
    jobs:
      - cypress/install
      - cypress/run:
          requires:
            - cypress/install
          start: npm start
          wait-on: 'http://localhost:3000'
          store_artifacts: true
          post-steps:
            - store_test_results:
                path: cypress/results
          parallel: true
          parallelism: 4
          group: 'all tests'
          browser: chrome

Optimizing Cypress in CI

// cypress.config.js optimized for CI
const { defineConfig } = require('cypress')

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    
    // CI-specific settings
    videoCompression: 15,
    video: process.env.CI ? true : false,
    screenshotOnRunFailure: true,
    
    // Retry failed tests in CI
    retries: {
      runMode: 2,
      openMode: 0
    },
    
    // Reduced viewportWidth for CI to avoid issues with small VMs
    viewportWidth: 1280,
    viewportHeight: 720,
    
    // Faster performance
    numTestsKeptInMemory: 10,
    experimentalMemoryManagement: true,
    
    // Organize test results for CI
    reporter: process.env.CI ? 'junit' : 'spec',
    reporterOptions: {
      junit: {
        outputFile: 'cypress/results/results.xml'
      }
    }
  }
})

Splitting Tests for Parallel Execution

// package.json scripts for test splitting
{
  "scripts": {
    "cy:smoke": "cypress run --spec cypress/e2e/smoke/**/*.cy.js",
    "cy:auth": "cypress run --spec cypress/e2e/auth/**/*.cy.js",
    "cy:products": "cypress run --spec cypress/e2e/products/**/*.cy.js",
    "cy:cart": "cypress run --spec cypress/e2e/cart/**/*.cy.js",
    "cy:checkout": "cypress run --spec cypress/e2e/checkout/**/*.cy.js",
    "cy:account": "cypress run --spec cypress/e2e/account/**/*.cy.js",
    "cy:all-parallel": "npm-run-all --parallel --continue-on-error cy:auth cy:products cy:cart cy:checkout cy:account"
  }
}

CI/CD Strategy for Different Environments

A complete E2E testing strategy might look like this:

  • Commit Stage: Run smoke tests (@smoke) on every commit
  • PR Stage: Run critical path tests (@critical) on pull requests
  • Development Deployment: Run regression tests (@regression) after deploying to dev
  • Staging Deployment: Run full E2E suite in parallel across browsers before promoting to production
  • Production Deployment: Run smoke tests against production to verify deployment
  • Scheduled Tests: Run full E2E suite nightly against production to detect issues

This approach balances testing thoroughness with performance, ensuring critical issues are caught early while still providing comprehensive coverage.

Debugging Cypress Tests

Using Cypress's Time Travel Debugging

Cypress's native time travel debugging is one of its most powerful features.

Debugging with console.log and cy.log

// Using console.log for debugging
it('debugs with console.log', () => {
  cy.visit('/products')
  
  cy.get('.product-card').then(($elements) => {
    console.log('Number of products:', $elements.length)
    console.log('First product:', $elements.first().text())
  })
  
  // Print values during test execution
  cy.get('.product-price').invoke('text').then((text) => {
    console.log('Product price:', text)
  })
})

// Using cy.log for in-test messages
it('debugs with cy.log', () => {
  cy.log('Starting test')
  
  cy.visit('/products')
  cy.log('Page loaded')
  
  cy.get('.product-card').each(($el, index) => {
    // This will appear in the Cypress command log
    cy.log(`Product ${index}: ${$el.find('.product-name').text()}`)
  })
})

Using .debug() Command

it('debugs with .debug()', () => {
  cy.visit('/products')
  
  // This will pause execution and open DevTools
  cy.get('.product-card').first().debug()
  
  // Continue execution by clicking "Resume" in Cypress
  cy.get('.product-price').should('be.visible')
})

Handling and Debugging Async Issues

// Common async debugging pattern
it('handles async operations correctly', () => {
  cy.visit('/data-loading-page')
  
  // Bad approach: Wait for arbitrary time
  // cy.wait(5000) // Not recommended
  
  // Good approach: Wait for specific condition
  cy.get('.loading-indicator').should('not.exist')
  cy.get('.data-table').should('be.visible')
  
  // Debugging async issues
  cy.get('.data-table').then(($table) => {
    // Log the state when element is found
    console.log('Table found:', $table.html())
    
    const rowCount = $table.find('tr').length
    cy.log(`Found ${rowCount} rows in table`)
    
    if (rowCount === 0) {
      // Take a screenshot for debugging empty tables
      cy.screenshot('empty-table-debug')
    }
  })
  
  // Continue with test
  cy.get('.data-table tr').should('have.length.greaterThan', 0)
})

Troubleshooting Common Issues

// Element not found troubleshooting
it('troubleshoots element not found', () => {
  cy.visit('/products')
  
  // Is the element in the DOM at all?
  cy.document().then((doc) => {
    const element = doc.querySelector('.product-details')
    cy.log(`Element exists in DOM: ${!!element}`)
    
    if (element) {
      cy.log(`Element visibility: ${window.getComputedStyle(element).display}`)
      cy.log(`Element position: ${JSON.stringify({
        top: element.getBoundingClientRect().top,
        left: element.getBoundingClientRect().left
      })}`)
    }
  })
  
  // Try a more relaxed selector
  cy.get('body').find('.product-details').should('exist')
  
  // Force interaction if element is being covered
  cy.get('.product-details').click({ force: true })
})

// Debugging form interaction issues
it('troubleshoots form interactions', () => {
  cy.visit('/contact')
  
  // Check if element is disabled
  cy.get('#email').then(($input) => {
    cy.log(`Input disabled: ${$input.prop('disabled')}`)
    cy.log(`Input readonly: ${$input.prop('readonly')}`)
  })
  
  // Try typing with specific options
  cy.get('#email').type('test@example.com', { delay: 100, force: true })
  
  // Check for overlays that might block interaction
  cy.document().then((doc) => {
    const overlays = doc.querySelectorAll('.modal, .overlay, .popup')
    cy.log(`Found ${overlays.length} potential overlays`)
    
    if (overlays.length > 0) {
      cy.log('Overlay z-index values:')
      Array.from(overlays).forEach((el) => {
        cy.log(`${el.className}: ${window.getComputedStyle(el).zIndex}`)
      })
    }
  })
})

Debugging Real-World Flaky Tests

Common causes of flaky tests and solutions:

  1. Race conditions: Wait for specific elements or conditions, not arbitrary times
  2. Network timing issues: Use cy.intercept() with cy.wait() to wait for specific network requests
  3. Animation interference: Disable animations in test environment or wait for animations to complete
  4. Selector problems: Use more stable selectors like data attributes
  5. Test isolation issues: Ensure tests don't depend on state from previous tests
// Handling a typical animation issue
cy.visit('/dashboard')

// Wait for page transition animation to complete
cy.get('.page-transition').should('not.have.class', 'animating')

// Wait for async data loading
cy.intercept('GET', '/api/dashboard-data').as('dashboardData')
cy.wait('@dashboardData')

// Now interact with the page
cy.get('.dashboard-card').first().click()

// Wait for modal animation to complete
cy.get('.modal')
  .should('be.visible')
  .and('not.have.class', 'modal-animating')

Practice Activities

Activity 1: Basic Cypress Test Suite

Create a basic Cypress test suite for a to-do application:

  1. Set up Cypress in a new or existing project
  2. Create tests for adding, completing, and deleting to-do items
  3. Test validation (empty to-dos, maximum length, etc.)
  4. Test persistence (to-dos remain after page refresh)
  5. Run your tests and debug any issues

Sample app URL: https://example.cypress.io/todo

Activity 2: E-commerce User Journey

Create an end-to-end test for a complete e-commerce user journey:

  1. User registration or login
  2. Browsing products and applying filters
  3. Adding items to cart
  4. Checkout process (shipping, payment)
  5. Order confirmation

Use mock data and API interception to simulate server responses. Apply Page Object Pattern to organize your tests.

Activity 3: Implement API Mocking

Create tests that use Cypress's cy.intercept() to mock API responses:

  1. Mock a successful API response
  2. Mock an error response to test error handling
  3. Mock a slow response to test loading states
  4. Mock an empty data response to test empty states
  5. Verify the UI updates correctly for each scenario

This will help you understand how to test different application states without relying on actual backend responses.

Further Reading