A

Cypress - Developer-Friendly E2E Testing

testingcypresse2eend-to-endbrowser-testingcomponent-testing

Cypress - Developer-Friendly E2E Testing

Cypress is an end-to-end testing framework built for the modern web, known for its exceptional developer experience. Unlike Selenium-based tools, Cypress runs directly in the browser alongside your application, enabling real-time reloading, time-travel debugging, and automatic waiting. If you value fast feedback during test development and debugging, Cypress delivers an unmatched interactive experience.

Cypress Architecture

Understanding how Cypress works helps explain its strengths and limitations.

Traditional E2E tools (Selenium, WebDriver) run outside the browser, sending commands over a network protocol:

Test Runner → WebDriver → Browser

Cypress runs inside the browser:

┌─────────────────────────────────────┐
│  Browser                            │
│  ┌─────────────┐  ┌──────────────┐ │
│  │   Cypress   │  │   Your App   │ │
│  │   Tests     │←→│              │ │
│  └─────────────┘  └──────────────┘ │
└─────────────────────────────────────┘

This architecture means:

  • Cypress has direct access to everything in the browser (DOM, network, storage)
  • Commands execute synchronously in the same event loop as your app
  • No network latency between test commands
  • Some limitations on multi-tab and multi-domain testing (though improving)

Installation and Setup

Create a new Cypress project:

pnpm add -D cypress
pnpm exec cypress open

The first run opens the Cypress Launchpad, which guides you through:

  • Choosing testing type (E2E or Component)
  • Creating configuration files
  • Setting up example tests

For automated setup:

pnpm add -D cypress
pnpm exec cypress install

Project Structure

├── cypress/
│   ├── e2e/                    # E2E test files
│   │   └── login.cy.ts
│   ├── fixtures/               # Test data (JSON files)
│   │   └── users.json
│   ├── support/                # Support files
│   │   ├── commands.ts         # Custom commands
│   │   └── e2e.ts              # Runs before each E2E test
│   └── downloads/              # Downloaded files during tests
├── cypress.config.ts           # Configuration
└── tsconfig.json

Configuration

// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    // Base URL for cy.visit('/')
    baseUrl: 'http://localhost:3000',

    // Test file patterns
    specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',

    // Support file (runs before each test file)
    supportFile: 'cypress/support/e2e.ts',

    // Viewport settings
    viewportWidth: 1280,
    viewportHeight: 720,

    // Timeouts
    defaultCommandTimeout: 4000,
    pageLoadTimeout: 60000,
    requestTimeout: 5000,
    responseTimeout: 30000,

    // Screenshots and videos
    screenshotOnRunFailure: true,
    video: true,
    videosFolder: 'cypress/videos',
    screenshotsFolder: 'cypress/screenshots',

    // Retry configuration
    retries: {
      runMode: 2,      // Retries in cypress run
      openMode: 0,     // Retries in cypress open
    },

    // Test isolation (Cypress 12+)
    testIsolation: true,

    // Experimental features
    experimentalRunAllSpecs: true,

    setupNodeEvents(on, config) {
      // Node event listeners (plugins)
      return config;
    },
  },

  component: {
    // Component testing configuration
    devServer: {
      framework: 'react',
      bundler: 'vite',
    },
  },
});

TypeScript Setup

Cypress has built-in TypeScript support. Create or update tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM"],
    "types": ["cypress"],
    "moduleResolution": "node",
    "strict": true
  },
  "include": ["cypress/**/*.ts", "cypress.config.ts"]
}

Writing Tests

Basic Test Structure

// cypress/e2e/home.cy.ts
describe('Homepage', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('displays the hero section', () => {
    cy.get('h1').should('contain', 'Welcome');
    cy.get('.hero-image').should('be.visible');
  });

  it('navigates to about page', () => {
    cy.contains('About').click();
    cy.url().should('include', '/about');
    cy.get('h1').should('contain', 'About Us');
  });
});

Commands and Chaining

Cypress commands are chainable and asynchronous (but you don't use async/await):

// Commands chain naturally
cy.visit('/')
  .get('input[name="email"]')
  .type('[email protected]')
  .get('input[name="password"]')
  .type('password123')
  .get('button[type="submit"]')
  .click();

// Commands yield subjects to the next command
cy.get('.items')          // yields .items element
  .find('li')             // yields all li children
  .first()                // yields first li
  .should('contain', 'First Item');

Selecting Elements

// By CSS selector
cy.get('.btn-primary');
cy.get('#login-form');
cy.get('input[name="email"]');

// By content
cy.contains('Submit');
cy.contains('button', 'Submit');        // Button containing "Submit"
cy.contains('.card', 'Product Name');   // Card containing text

// By data attribute (recommended for test stability)
cy.get('[data-cy="submit-button"]');
cy.get('[data-testid="user-profile"]');

// Traversal
cy.get('ul').find('li');                // Children
cy.get('li').parent();                  // Parent
cy.get('li').siblings();                // Siblings
cy.get('.item').first();                // First match
cy.get('.item').last();                 // Last match
cy.get('.item').eq(2);                  // Third match (0-indexed)

// Within scope
cy.get('.modal').within(() => {
  cy.get('input').type('text');
  cy.get('button').click();
});

Actions

// Click
cy.get('button').click();
cy.get('button').click({ force: true });    // Skip visibility checks
cy.get('button').dblclick();                // Double-click
cy.get('button').rightclick();              // Right-click

// Type
cy.get('input').type('Hello World');
cy.get('input').type('[email protected]{enter}');  // With special chars
cy.get('input').type('{selectall}{backspace}');   // Clear field
cy.get('input').clear().type('New value');        // Clear then type

// Special keys: {enter}, {backspace}, {del}, {esc}, {tab}, {ctrl}, {alt}, {shift}

// Select
cy.get('select').select('optionValue');
cy.get('select').select(['value1', 'value2']);    // Multi-select

// Checkbox and radio
cy.get('[type="checkbox"]').check();
cy.get('[type="checkbox"]').uncheck();
cy.get('[type="radio"]').check('optionValue');

// File upload
cy.get('input[type="file"]').selectFile('cypress/fixtures/file.pdf');
cy.get('input[type="file"]').selectFile(['file1.pdf', 'file2.pdf']);

// Scroll
cy.scrollTo('bottom');
cy.get('.container').scrollTo(0, 500);
cy.get('.item').scrollIntoView();

// Focus and blur
cy.get('input').focus();
cy.get('input').blur();

// Hover (trigger mouseover)
cy.get('.dropdown').trigger('mouseover');

Assertions

Cypress uses Chai assertions with automatic retrying:

// Should assertions (chainable)
cy.get('.title').should('be.visible');
cy.get('.title').should('not.be.visible');
cy.get('.title').should('have.text', 'Welcome');
cy.get('.title').should('contain', 'Welcome');
cy.get('.title').should('have.class', 'active');
cy.get('.title').should('have.attr', 'href', '/home');
cy.get('.title').should('have.css', 'color', 'rgb(0, 0, 0)');

// Multiple assertions (chained)
cy.get('input')
  .should('be.visible')
  .and('be.enabled')
  .and('have.value', '');

// Length assertions
cy.get('li').should('have.length', 5);
cy.get('li').should('have.length.greaterThan', 2);
cy.get('li').should('have.length.lessThan', 10);

// Existence
cy.get('.error').should('exist');
cy.get('.error').should('not.exist');

// State
cy.get('input').should('be.disabled');
cy.get('input').should('be.enabled');
cy.get('input').should('be.focused');
cy.get('[type="checkbox"]').should('be.checked');

// Value
cy.get('input').should('have.value', 'test');
cy.get('input').should('be.empty');

// URL and page
cy.url().should('include', '/dashboard');
cy.url().should('eq', 'http://localhost:3000/dashboard');
cy.title().should('eq', 'Dashboard - My App');

// Then for complex assertions
cy.get('.items').then(($items) => {
  expect($items).to.have.length(3);
  expect($items.first()).to.contain('First');
});

Aliases

Store values for later use:

// Alias an element
cy.get('table').find('tr').as('rows');
cy.get('@rows').should('have.length', 5);
cy.get('@rows').first().click();

// Alias a network request
cy.intercept('GET', '/api/users').as('getUsers');
cy.visit('/users');
cy.wait('@getUsers').its('response.statusCode').should('eq', 200);

// Alias fixture data
cy.fixture('users.json').as('usersData');
cy.get('@usersData').then((users) => {
  // Use users data
});

Network Stubbing with cy.intercept()

Intercept and mock HTTP requests:

// Basic stub
cy.intercept('GET', '/api/users', { fixture: 'users.json' });

// With status code
cy.intercept('GET', '/api/users', {
  statusCode: 200,
  body: [{ id: 1, name: 'John' }],
});

// Simulate error
cy.intercept('GET', '/api/users', {
  statusCode: 500,
  body: { error: 'Server error' },
});

// Delay response
cy.intercept('GET', '/api/users', {
  body: { users: [] },
  delay: 2000,  // 2 second delay
});

// Pattern matching
cy.intercept('GET', '/api/users/*').as('getUser');
cy.intercept({ method: 'POST', url: '/api/**' }).as('apiPost');

// Modify request
cy.intercept('POST', '/api/users', (req) => {
  req.headers['Authorization'] = 'Bearer test-token';
  req.body.timestamp = Date.now();
});

// Modify response
cy.intercept('GET', '/api/users', (req) => {
  req.reply((res) => {
    res.body.users = res.body.users.slice(0, 5);  // Limit to 5 users
  });
});

// Conditional responses
let requestCount = 0;
cy.intercept('GET', '/api/status', (req) => {
  requestCount++;
  if (requestCount === 1) {
    req.reply({ status: 'pending' });
  } else {
    req.reply({ status: 'complete' });
  }
});

// Wait for network request
cy.intercept('POST', '/api/login').as('login');
cy.get('form').submit();
cy.wait('@login').then((interception) => {
  expect(interception.request.body).to.have.property('email');
  expect(interception.response.statusCode).to.eq(200);
});

Custom Commands

Extend Cypress with reusable commands:

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>;
      getByTestId(testId: string): Chainable<JQuery<HTMLElement>>;
    }
  }
}

Cypress.Commands.add('login', (email: string, password: string) => {
  cy.session([email, password], () => {
    cy.visit('/login');
    cy.get('[data-cy="email"]').type(email);
    cy.get('[data-cy="password"]').type(password);
    cy.get('[data-cy="submit"]').click();
    cy.url().should('include', '/dashboard');
  });
});

Cypress.Commands.add('getByTestId', (testId: string) => {
  return cy.get(`[data-testid="${testId}"]`);
});

export {};
// cypress/support/e2e.ts
import './commands';

// Global hooks
beforeEach(() => {
  // Runs before every test
});

// Handle uncaught exceptions
Cypress.on('uncaught:exception', (err, runnable) => {
  // Return false to prevent test failure
  if (err.message.includes('ResizeObserver loop')) {
    return false;
  }
  return true;
});

Use custom commands in tests:

// cypress/e2e/dashboard.cy.ts
describe('Dashboard', () => {
  beforeEach(() => {
    cy.login('[email protected]', 'password123');
  });

  it('displays user data', () => {
    cy.visit('/dashboard');
    cy.getByTestId('user-name').should('contain', 'John');
  });
});

Component Testing

Cypress can test components in isolation:

pnpm add -D @cypress/react @cypress/vite-dev-server
// cypress.config.ts
export default defineConfig({
  component: {
    devServer: {
      framework: 'react',
      bundler: 'vite',
    },
    specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}',
  },
});
// src/components/Button.cy.tsx
import { Button } from './Button';

describe('Button', () => {
  it('renders with label', () => {
    cy.mount(<Button label="Click me" />);
    cy.get('button').should('have.text', 'Click me');
  });

  it('calls onClick when clicked', () => {
    const onClick = cy.stub().as('onClick');
    cy.mount(<Button label="Click me" onClick={onClick} />);
    cy.get('button').click();
    cy.get('@onClick').should('have.been.calledOnce');
  });

  it('renders different variants', () => {
    cy.mount(<Button label="Primary" variant="primary" />);
    cy.get('button').should('have.class', 'btn-primary');
  });
});

Run component tests:

pnpm exec cypress open --component
# or
pnpm exec cypress run --component

CI Setup

GitHub Actions

# .github/workflows/cypress.yml
name: Cypress Tests

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

jobs:
  cypress:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v2
        with:
          version: 8

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install

      - name: Build app
        run: pnpm build

      - name: Cypress run
        uses: cypress-io/github-action@v6
        with:
          start: pnpm preview
          wait-on: 'http://localhost:4173'
          browser: chrome

      - name: Upload screenshots
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: cypress-screenshots
          path: cypress/screenshots

      - name: Upload videos
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: cypress-videos
          path: cypress/videos

Parallelization with Cypress Cloud

For large test suites, Cypress Cloud provides parallelization:

# Record to Cypress Cloud
pnpm exec cypress run --record --key $CYPRESS_RECORD_KEY
# Parallel CI jobs
jobs:
  cypress:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        containers: [1, 2, 3, 4]

    steps:
      # ... setup steps ...

      - name: Cypress run
        uses: cypress-io/github-action@v6
        with:
          record: true
          parallel: true
          group: 'E2E Tests'
        env:
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

Time-Travel Debugging

Cypress's most distinctive feature is its time-travel debugger:

  1. Open Cypress GUI: pnpm exec cypress open
  2. Run a test: Click on a spec file
  3. Hover over commands: See DOM snapshots at each step
  4. Click on commands: Jump to that point in time
  5. Use browser DevTools: Inspect elements, console, network

Additional Debugging Tools

// Pause execution
cy.pause();

// Debug at this point
cy.debug();

// Log to command log
cy.log('Current user:', user.name);

// Take screenshot
cy.screenshot('login-page');

// Full page screenshot
cy.screenshot('full-page', { capture: 'fullPage' });

Selector Playground

In the Cypress GUI:

  1. Click the crosshair icon
  2. Hover over elements
  3. Get suggested selectors
  4. Copy to clipboard

Gotchas and Tips

Don't Use async/await

Cypress commands are not Promises—they're queued and run sequentially:

// ❌ Wrong - async/await doesn't work
it('fails', async () => {
  const text = await cy.get('h1').invoke('text');
  expect(text).to.eq('Welcome');
});

// ✅ Correct - use .then()
it('works', () => {
  cy.get('h1').invoke('text').then((text) => {
    expect(text).to.eq('Welcome');
  });
});

// ✅ Or use should() for assertions
it('also works', () => {
  cy.get('h1').should('have.text', 'Welcome');
});

Variables and Closures

// ❌ Wrong - variable is undefined when cy.visit runs
let userName;
cy.get('.user').invoke('text').then((text) => {
  userName = text;
});
cy.visit(`/users/${userName}`);  // userName is still undefined!

// ✅ Correct - use .then() or alias
cy.get('.user').invoke('text').then((userName) => {
  cy.visit(`/users/${userName}`);
});

// ✅ Or with alias
cy.get('.user').invoke('text').as('userName');
cy.get('@userName').then((userName) => {
  cy.visit(`/users/${userName}`);
});

Same-Origin Policy

Cypress enforces same-origin. Multi-domain flows need cy.origin():

it('handles OAuth redirect', () => {
  cy.visit('/login');
  cy.get('.oauth-button').click();

  // Handle different origin
  cy.origin('https://auth-provider.com', () => {
    cy.get('#email').type('[email protected]');
    cy.get('#password').type('password');
    cy.get('#login').click();
  });

  // Back on original origin
  cy.url().should('include', '/dashboard');
});

Detached DOM Elements

Elements can become detached after re-renders:

// ❌ May fail if element re-renders
cy.get('.item').then(($item) => {
  // ... some async operation
  cy.wrap($item).click();  // $item may be detached
});

// ✅ Re-query the element
cy.get('.item').as('item');
// ... operations that cause re-render
cy.get('@item').click();  // Actually re-queries with same selector

Conditional Testing

Avoid conditional logic based on DOM state when possible:

// ❌ Problematic - race condition
cy.get('body').then(($body) => {
  if ($body.find('.modal').length > 0) {
    cy.get('.modal .close').click();
  }
});

// ✅ Better - ensure known state
beforeEach(() => {
  // Reset app state via API or command
  cy.request('POST', '/api/reset');
});

Flaky Tests

Common causes and fixes:

// Problem: Animation not complete
cy.get('.animated-element').click();  // May click wrong location

// Fix: Wait for animation or disable
cy.get('.animated-element').should('be.visible').click();
// Or add { animationDistanceThreshold: 20 } to config

// Problem: Network timing
cy.get('.data').should('contain', 'Loaded');  // May timeout

// Fix: Intercept and wait
cy.intercept('GET', '/api/data').as('getData');
cy.visit('/');
cy.wait('@getData');
cy.get('.data').should('contain', 'Loaded');

Performance Tips

// cypress.config.ts
export default defineConfig({
  e2e: {
    // Disable video in CI if not needed
    video: false,

    // Only screenshot on failure
    screenshotOnRunFailure: true,

    // Reduce viewport for faster rendering
    viewportWidth: 1280,
    viewportHeight: 720,

    // Increase timeout for slow CI
    defaultCommandTimeout: 10000,
  },
});

See Also

Last updated: March 23, 2026