Cypress Testing: Modern E2E Testing for Web Apps

Cypress Testing: Modern E2E Testing for Web Apps

Cypress is a JavaScript E2E testing framework built specifically for the modern web. It runs inside the browser, giving you real-time visibility into your tests as they execute. This guide covers installation, writing your first tests, handling authentication, CI integration, and when to choose Cypress over Playwright or Selenium.

Key Takeaways

Cypress runs in the browser. Unlike Selenium and Playwright, Cypress executes your test code inside the browser process. This gives you direct access to the DOM, application state, and network layer — and eliminates most timing issues.

No async/await required. Cypress uses a command queue. Commands are chained and execute sequentially. You write cy.get('.button').click(), not await page.click('.button'). This makes tests more readable.

cy.intercept() is powerful. You can stub any network request, return fake data, or assert on requests. This lets you test error states, loading states, and edge cases without a real backend.

Use cy.session() for authentication. Save login state once per test run, not once per test. cy.session() caches and restores cookies/localStorage automatically.

Cypress is Chromium-only for E2E. Firefox support exists but is experimental. If you need cross-browser E2E (especially Safari/WebKit), use Playwright instead.

What Is Cypress?

Cypress is an open-source end-to-end testing framework for web applications. Released in 2015, it took a different approach from Selenium: instead of using WebDriver to control a browser from outside, Cypress runs directly inside the browser alongside your application.

This architecture means:

  • No network latency between test commands and browser
  • Direct access to application code (window, document, store)
  • Automatic waiting — no sleep() or explicit waits needed
  • Real-time screenshots and videos of test execution

Cypress is the most popular E2E testing tool for JavaScript frontend applications, particularly React, Vue, and Angular apps.

What Cypress Is Good For

  • Single-page applications (React, Vue, Angular, Svelte)
  • Forms, modals, navigation flows
  • API testing (Cypress can test REST APIs too)
  • Component testing (Cypress has a component testing mode)
  • Teams that want a visual, interactive test runner

What Cypress Is Not Good For

  • Multi-tab testing (Cypress can't control multiple tabs)
  • Cross-origin iframes (same-origin policy restrictions)
  • Non-Chromium browsers in production (WebKit/Safari)
  • Mobile app testing

Installing Cypress

npm install --save-dev cypress

Open the Cypress app for the first time:

npx cypress open

This launches the Cypress Launchpad, which guides you through:

  1. Choosing E2E Testing or Component Testing
  2. Selecting a browser
  3. Creating your first spec file

For a headless CI run:

npx cypress run

Your First Test

Cypress creates a cypress/ folder with this structure:

cypress/
  e2e/         # Your test files
  fixtures/    # Static test data
  support/     # Shared commands and hooks
cypress.config.js

Create cypress/e2e/homepage.cy.js:

describe('Homepage', () => {
  beforeEach(() => {
    cy.visit('https://example.com')
  })

  it('displays the main heading', () => {
    cy.get('h1').should('be.visible')
    cy.get('h1').should('contain.text', 'Example Domain')
  })

  it('has a working link', () => {
    cy.get('a').first().click()
    cy.url().should('not.equal', 'https://example.com/')
  })
})

Run it:

npx cypress run --spec cypress/e2e/homepage.cy.js

Core Concepts

The Command Queue

Cypress commands don't execute immediately. They're added to a queue and run sequentially:

cy.visit('/login')                    // 1. Navigate
cy.get('[name="email"]').type('...')  // 2. Find input, type
cy.get('[name="password"]').type('...') // 3. Find input, type
cy.get('button[type="submit"]').click() // 4. Click submit
cy.url().should('include', '/dashboard') // 5. Assert URL changed

Each command automatically waits for the previous one to complete.

Automatic Waiting

Cypress retries commands until they succeed or timeout (default: 4 seconds). You don't write explicit waits:

// This works even if the button appears after a network request
cy.get('.success-message').should('be.visible')

// Cypress will retry cy.get() until the element exists and is visible
// No need for: await page.waitForSelector('.success-message')

Subject Chaining

Commands pass their subject to the next command:

cy.get('form')           // Subject: the form element
  .find('input')         // Subject: inputs inside the form
  .first()               // Subject: first input
  .type('hello')         // Acts on the first input

Selectors

Best Practices

Prefer selectors that don't break when the UI changes:

// Best: data-cy attributes — explicit, stable
cy.get('[data-cy="submit-button"]').click()
cy.get('[data-testid="email-input"]').type('user@example.com')

// Good: semantic HTML
cy.get('button[type="submit"]').click()
cy.get('input[name="email"]').type('user@example.com')

// Avoid: brittle CSS classes
cy.get('.btn-primary.rounded-lg.mt-4').click()  // breaks with style changes

// Avoid: XPath (verbose, slow)
cy.xpath('//button[@type="submit"]').click()

Querying Within Elements

// Find within a specific container
cy.get('.product-card').first().within(() => {
  cy.get('.price').should('contain', '$')
  cy.get('button').click()
})

// Find by text content
cy.contains('Add to Cart').click()
cy.contains('button', 'Submit').click()  // button containing "Submit"

// Find by position
cy.get('li').first()
cy.get('li').last()
cy.get('li').eq(2)  // 3rd item (0-indexed)

Assertions

// Visibility
cy.get('.modal').should('be.visible')
cy.get('.modal').should('not.exist')

// Text
cy.get('h1').should('have.text', 'Welcome back')
cy.get('.status').should('contain', 'Active')

// Value
cy.get('input[name="email"]').should('have.value', 'user@example.com')

// Attributes
cy.get('a').should('have.attr', 'href', '/about')
cy.get('button').should('be.disabled')
cy.get('button').should('not.be.disabled')

// CSS
cy.get('.alert').should('have.class', 'alert-success')

// Count
cy.get('li').should('have.length', 5)
cy.get('li').should('have.length.greaterThan', 3)

// URL
cy.url().should('include', '/dashboard')
cy.url().should('eq', 'https://app.example.com/dashboard')

// Chaining multiple assertions
cy.get('.user-card')
  .should('be.visible')
  .and('contain', 'John Doe')
  .and('not.have.class', 'inactive')

Actions

// Click
cy.get('button').click()
cy.get('button').dblclick()
cy.get('button').rightclick()
cy.get('button').click({ force: true })  // Force click (even if hidden)

// Type
cy.get('input').type('Hello, World!')
cy.get('input').type('{enter}')          // Press Enter
cy.get('input').type('{ctrl+a}{del}')    // Select all, delete
cy.get('input').clear().type('New text') // Clear then type

// Forms
cy.get('select').select('Option 2')
cy.get('select').select(['A', 'B'])  // Multi-select
cy.get('[type="checkbox"]').check()
cy.get('[type="checkbox"]').uncheck()
cy.get('[type="radio"]').check('option-value')

// Drag
cy.get('.draggable').drag('.droppable')  // Requires cypress-drag-drop

// File upload
cy.get('input[type="file"]').selectFile('path/to/file.pdf')
cy.get('input[type="file"]').selectFile('path/to/file.pdf', { action: 'drag-drop' })

// Scroll
cy.scrollTo('bottom')
cy.get('.container').scrollTo(0, 500)
cy.get('.lazy-element').scrollIntoView()

Authentication

cy.session() — The Right Way

// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
  cy.session(
    [email, password],  // Cache key
    () => {
      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')
    },
    {
      validate() {
        // Validate the session is still active
        cy.getCookie('session_token').should('exist')
      }
    }
  )
})
// In your tests
beforeEach(() => {
  cy.login('user@example.com', 'password123')
})

it('shows dashboard', () => {
  cy.visit('/dashboard')
  cy.get('h1').should('contain', 'Dashboard')
})

cy.session() runs the login function once, caches the session (cookies, localStorage), and restores it for subsequent tests. If the session becomes invalid, it re-runs the login automatically.

Direct API Authentication (Faster)

Cypress.Commands.add('loginViaAPI', (email, password) => {
  cy.request({
    method: 'POST',
    url: '/api/auth/login',
    body: { email, password }
  }).then(response => {
    window.localStorage.setItem('token', response.body.token)
  })
})

This skips the login UI entirely — much faster.

Intercepting Network Requests

cy.intercept() is one of Cypress's most powerful features.

Stubbing Responses

// Return fake data
cy.intercept('GET', '/api/users', {
  statusCode: 200,
  body: [
    { id: 1, name: 'Alice', email: 'alice@example.com' }
  ]
}).as('getUsers')

cy.visit('/users')
cy.wait('@getUsers')  // Wait for the intercepted request
cy.get('.user-row').should('have.length', 1)
cy.get('.user-row').should('contain', 'Alice')

Simulating Errors

cy.intercept('POST', '/api/checkout', {
  statusCode: 500,
  body: { error: 'Payment gateway unavailable' }
})

cy.get('button[data-cy="place-order"]').click()
cy.get('[data-cy="error-message"]')
  .should('be.visible')
  .and('contain', 'Something went wrong')

Asserting on Requests

cy.intercept('POST', '/api/login').as('loginRequest')

cy.get('[name="email"]').type('user@example.com')
cy.get('[name="password"]').type('password123')
cy.get('button[type="submit"]').click()

cy.wait('@loginRequest').then(interception => {
  expect(interception.request.body).to.deep.equal({
    email: 'user@example.com',
    password: 'password123'
  })
  expect(interception.response.statusCode).to.equal(200)
})

Delaying Responses (Test Loading States)

cy.intercept('GET', '/api/data', req => {
  req.reply(res => {
    res.delay(2000)  // 2 second delay
    res.send({ data: [] })
  })
})

cy.visit('/dashboard')
cy.get('[data-cy="loading-spinner"]').should('be.visible')
cy.get('[data-cy="loading-spinner"]').should('not.exist')  // Gone after load

Custom Commands

Add reusable commands to cypress/support/commands.js:

// Fill a form field
Cypress.Commands.add('fillField', (label, value) => {
  cy.contains('label', label)
    .then($label => {
      const inputId = $label.attr('for')
      cy.get(`#${inputId}`).clear().type(value)
    })
})

// Check accessibility
Cypress.Commands.add('checkA11y', (context, options) => {
  cy.injectAxe()
  cy.checkA11y(context, options)
})
// Use in tests
cy.fillField('Email', 'user@example.com')
cy.fillField('Password', 'password123')

Fixtures

Store static test data in cypress/fixtures/:

// cypress/fixtures/user.json
{
  "email": "test@example.com",
  "password": "password123",
  "name": "Test User"
}
// cypress/fixtures/products.json
[
  { "id": 1, "name": "Widget A", "price": 9.99 },
  { "id": 2, "name": "Widget B", "price": 19.99 }
]
// Load fixture in tests
cy.fixture('user').then(user => {
  cy.get('[name="email"]').type(user.email)
  cy.get('[name="password"]').type(user.password)
})

// Stub API with fixture
cy.intercept('GET', '/api/products', { fixture: 'products' })

Configuration

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

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    viewportWidth: 1280,
    viewportHeight: 720,
    defaultCommandTimeout: 4000,    // Wait up to 4s for commands
    requestTimeout: 10000,          // Wait up to 10s for requests
    responseTimeout: 30000,
    video: false,                   // Disable video in CI for speed
    screenshotOnRunFailure: true,
    retries: {
      runMode: 1,    // Retry once in CI
      openMode: 0    // No retries locally
    },
    env: {
      apiUrl: 'http://localhost:3001/api'
    }
  }
})
// Access env variables in tests
cy.request(Cypress.env('apiUrl') + '/users')

CI/CD Integration

GitHub Actions

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

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

jobs:
  cypress:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Start application
        run: npm run dev &
        # Or use cypress built-in: start-server-and-test package

      - name: Run Cypress tests
        uses: cypress-io/github-action@v6
        with:
          wait-on: 'http://localhost:3000'
          wait-on-timeout: 60

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

Parallel Execution with Cypress Cloud

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

Cypress Cloud (free tier available) distributes tests across machines and provides a dashboard with test history, screenshots, and videos.

Component Testing

Cypress also supports component testing — test React/Vue components in isolation without a full browser session:

// cypress/component/Button.cy.jsx
import Button from '../../src/components/Button'

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

  it('shows loading state', () => {
    cy.mount(<Button loading>Submit</Button>)
    cy.get('button').should('be.disabled')
    cy.get('.spinner').should('be.visible')
  })
})

Run component tests:

npx cypress run --component

Debugging

Open Mode (Interactive)

npx cypress open

The Cypress Test Runner shows:

  • Every command and its result
  • Before/after screenshots for each step
  • Network requests
  • Console errors

.debug() and .pause()

cy.get('.element').debug()   // Pauses, logs element to console
cy.get('.element').pause()   // Pauses test, waits for you to continue

cy.log()

cy.log('About to click the submit button')
cy.get('button').click()

Screenshots

cy.screenshot('before-login')
// or automatically on failure (configured in cypress.config.js)

Cypress vs Playwright

Feature Cypress Playwright
Architecture Runs inside browser Runs outside browser
Browsers Chrome, Edge, Firefox* Chrome, Firefox, WebKit
Safari/WebKit No Yes
Multi-tab No Yes
Cross-origin Limited Yes
API Chainable, synchronous-style Async/await
Learning curve Low Medium
Interactive runner Excellent Good (UI mode)
Component testing Yes (Cypress CT) Yes (experimental)
Best for Web SPAs, beginners Complex flows, cross-browser

*Firefox support in Cypress is experimental.

Choose Cypress when:

  • Building a React/Vue/Angular SPA
  • Your team is new to E2E testing
  • You want an excellent interactive debugging experience
  • No Safari/multi-tab requirements

Choose Playwright when:

  • You need Safari/WebKit testing
  • You need multi-tab, multi-origin flows
  • You want Python, Java, or C# support
  • You're testing complex auth flows across domains

Common Patterns

Test Data Cleanup

beforeEach(() => {
  // Reset via API before each test
  cy.request('DELETE', '/api/test/reset')
})

Conditional Testing (Use Sparingly)

cy.get('body').then($body => {
  if ($body.find('.cookie-banner').length) {
    cy.get('[data-cy="accept-cookies"]').click()
  }
})

Retry-ability

// This retries until all 5 items appear
cy.get('.item').should('have.length', 5)

// This retries until the text changes
cy.get('.status').should('have.text', 'Complete')

Getting Started Checklist

  • Install: npm install --save-dev cypress
  • Create cypress.config.js with your baseUrl
  • Write first test for your most critical user flow
  • Add data-cy attributes to key elements in your app
  • Set up cy.session() for authentication
  • Add cy.intercept() stubs for unreliable external APIs
  • Add GitHub Actions workflow

Testing manually every deploy? HelpMeTest turns your user flows into automated Cypress-compatible tests — no code required.

Read more