Back to All Articles
Automation

Cypress Web Testing — Complete Guide

Honnesh Muppala May 5, 2026 16 min read

What is Cypress?

Cypress is a JavaScript-based end-to-end testing framework built for the modern web. Unlike traditional tools such as Selenium that communicate with the browser via an external WebDriver protocol, Cypress runs inside the browser itself — in the same run loop as your application. This architectural difference is what gives Cypress its signature developer experience: real-time reloads, time-travel debugging, automatic waiting, and highly readable error messages.

Cypress was built from the ground up for developers and QA engineers who test web applications written in frameworks like React, Vue, Angular, and Next.js. It ships with its own test runner, assertion library (Chai), command chaining API, and an interactive GUI that shows every step of your test alongside the rendered app.

Key advantages over Selenium

From Experience at Viasat: When I introduced Cypress to the web QA pipeline at Viasat, the most immediate win was eliminating the flaky time.sleep() calls that had been plaguing our Selenium suite. Cypress's built-in auto-wait resolved the majority of our intermittent failures on the first pass, cutting our flaky-test rate from roughly 25% to under 3% within a month of migration.

Architecture Diagram

Understanding how Cypress differs architecturally from Selenium helps explain why it behaves the way it does.

Test Code
describe / it / cy.*
Cypress Runner
Node.js process
Browser
Chrome / Firefox / Edge
App Under Test
Same process — no HTTP
Cypress runs in the same browser loop as the app — no external WebDriver protocol.

Selenium, by contrast, communicates via the W3C WebDriver protocol over HTTP: your test code → WebDriver client → HTTP request → browser driver (chromedriver) → browser. Each hop introduces latency and a potential failure point. Cypress eliminates those hops entirely.

Installation & Project Structure

Cypress is an npm package. You add it to any Node.js project as a dev dependency:

# Install Cypress
npm install cypress --save-dev

# Open the Cypress interactive Test Runner
npx cypress open

# Or run headlessly from the CLI
npx cypress run

When you run npx cypress open for the first time, Cypress scaffolds the following project structure:

my-project/
├── cypress/
│   ├── e2e/                  # Your test files live here
│   │   └── login.cy.js
│   ├── fixtures/             # Static test data (JSON)
│   │   └── users.json
│   ├── support/
│   │   ├── commands.js       # Custom cy.* commands
│   │   └── e2e.js            # Global config / imports
│   └── downloads/            # Files downloaded during tests
├── cypress.config.js         # Main Cypress configuration
├── package.json
└── node_modules/

The e2e/ directory is where your test specs live. Fixtures store reusable test data. The support/ folder is where you add custom commands and global hooks.

Writing Your First Test

Cypress tests use a familiar Mocha-style describe / it structure. Every browser interaction is a Cypress command prefixed with cy.:

// cypress/e2e/login.cy.js
describe('Login Page', () => {

    beforeEach(() => {
        cy.visit('/login');
    });

    it('logs in with valid credentials', () => {
        cy.get('[data-cy="email-input"]').type('user@example.com');
        cy.get('[data-cy="password-input"]').type('SecurePass123');
        cy.get('[data-cy="login-btn"]').click();

        // Assert dashboard loads
        cy.url().should('include', '/dashboard');
        cy.get('[data-cy="welcome-message"]').should('contain', 'Welcome');
    });

    it('shows error for invalid password', () => {
        cy.get('[data-cy="email-input"]').type('user@example.com');
        cy.get('[data-cy="password-input"]').type('WrongPassword');
        cy.get('[data-cy="login-btn"]').click();

        cy.get('[data-cy="error-alert"]')
          .should('be.visible')
          .and('contain', 'Invalid email or password');
    });
});

Key commands used above:

Selectors: Finding Elements

Cypress supports any valid CSS selector, but the recommended approach is to use data-cy attributes — custom HTML attributes added specifically for testing. This decouples your tests from styling decisions and DOM structure.

/* CSS selector — works but fragile */
cy.get('.btn-primary')

/* XPath (requires plugin) — verbose and brittle */
cy.xpath('//button[@class="btn btn-primary"]')

/* data-cy attribute — recommended best practice */
cy.get('[data-cy="submit-btn"]')

/* cy.contains — useful for finding by visible text */
cy.contains('button', 'Submit')

/* Chained selectors */
cy.get('[data-cy="user-card"]').find('[data-cy="user-name"]')

/* nth element */
cy.get('[data-cy="product-item"]').eq(2)

Add data-cy to your HTML:

<button data-cy="submit-btn" type="submit">Submit</button>
<input data-cy="email-input" type="email" />
<div data-cy="error-alert" class="alert alert-danger">...</div>

Why data-cy beats CSS classes: when your designer renames .btn-primary to .btn-brand, your tests break. When they add a new div wrapper, XPath indices break. data-cy attributes are test-only contracts — they don't affect CSS or JS and should never change for non-testing reasons.

Assertions

Cypress bundles Chai, Sinon-Chai, and jQuery-Chai assertion styles. The most common pattern is chaining .should() off a query:

// Visibility
cy.get('[data-cy="modal"]').should('be.visible');
cy.get('[data-cy="spinner"]').should('not.exist');

// Text content
cy.get('[data-cy="heading"]').should('have.text', 'Dashboard');
cy.get('[data-cy="status"]').should('contain', 'Active');

// Length (count)
cy.get('[data-cy="result-item"]').should('have.length', 5);

// Attribute values
cy.get('[data-cy="link"]').should('have.attr', 'href', '/pricing');
cy.get('[data-cy="input"]').should('have.value', 'hello@example.com');

// CSS class
cy.get('[data-cy="card"]').should('have.class', 'selected');

// Multiple assertions chained
cy.get('[data-cy="submit-btn"]')
  .should('be.visible')
  .and('not.be.disabled')
  .and('have.text', 'Submit');

// Chai expect style
cy.get('[data-cy="count"]').invoke('text').then((text) => {
    expect(parseInt(text)).to.be.greaterThan(0);
});

All .should() assertions automatically retry for up to the configured defaultCommandTimeout (4000ms by default) before failing. This built-in retry removes the need for explicit waits in the vast majority of cases.

Fixtures & Test Data

Hard-coding test data in your spec files makes maintenance painful. Cypress fixtures let you store test data in JSON files and load them with cy.fixture():

// cypress/fixtures/users.json
{
  "validUser": {
    "email": "testuser@example.com",
    "password": "SecurePass123",
    "name": "Test User"
  },
  "adminUser": {
    "email": "admin@example.com",
    "password": "AdminPass456",
    "name": "Admin"
  }
}
// Using fixture in a test
describe('Login with fixture data', () => {

    it('logs in as a valid user', () => {
        cy.fixture('users').then((users) => {
            cy.visit('/login');
            cy.get('[data-cy="email-input"]').type(users.validUser.email);
            cy.get('[data-cy="password-input"]').type(users.validUser.password);
            cy.get('[data-cy="login-btn"]').click();
            cy.get('[data-cy="welcome-name"]').should('contain', users.validUser.name);
        });
    });
});

You can also alias the fixture to use it with this in multiple tests:

beforeEach(function() {
    cy.fixture('users').as('userData');
});

it('uses aliased fixture', function() {
    cy.get('[data-cy="email-input"]').type(this.userData.validUser.email);
});

Network Interception

cy.intercept() is one of Cypress's most powerful features. It lets you spy on, modify, or stub any HTTP request made by your application — without changing any application code.

// Stub a GET request to return fixture data
cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers');

cy.visit('/users');
cy.wait('@getUsers');  // Wait for the stubbed request to complete

cy.get('[data-cy="user-list-item"]').should('have.length', 2);

// Stub with inline response
cy.intercept('POST', '/api/login', {
    statusCode: 200,
    body: { token: 'fake-jwt-token', name: 'Test User' }
}).as('loginRequest');

// Simulate server error
cy.intercept('GET', '/api/products', { statusCode: 500 }).as('productsError');

// Spy without stubbing (just observe)
cy.intercept('GET', '/api/search*').as('searchRequest');
cy.get('[data-cy="search-input"]').type('laptop');
cy.wait('@searchRequest').its('request.url').should('include', 'laptop');
From Experience at Amazon: During my time at Amazon testing catalog and fulfillment services, network interception was invaluable for testing UI states that were difficult to reproduce in staging — like 429 rate-limit responses, slow network conditions, and partial API failures. By stubbing responses, we could test every error branch of the UI without needing to configure the backend to produce those conditions on demand. This cut the time to test error-handling scenarios from days of environment setup to minutes.

Custom Commands

Custom commands let you encapsulate reusable actions into a named cy. command. Define them in cypress/support/commands.js:

// cypress/support/commands.js

// Custom login command — reusable across all test files
Cypress.Commands.add('login', (email, password) => {
    cy.visit('/login');
    cy.get('[data-cy="email-input"]').type(email);
    cy.get('[data-cy="password-input"]').type(password);
    cy.get('[data-cy="login-btn"]').click();
    cy.url().should('include', '/dashboard');
});

// Login via API to skip the UI (faster for test setup)
Cypress.Commands.add('loginViaApi', (email, password) => {
    cy.request('POST', '/api/auth/login', { email, password })
      .then((resp) => {
          window.localStorage.setItem('authToken', resp.body.token);
      });
});

// Select a dropdown option by visible text
Cypress.Commands.add('selectOption', (selector, optionText) => {
    cy.get(selector).select(optionText);
});

// Usage in tests:
cy.login('user@example.com', 'SecurePass123');
cy.loginViaApi('user@example.com', 'SecurePass123');

The loginViaApi pattern is especially useful: by bypassing the login UI and going straight to the API, your logged-in test setup takes milliseconds instead of seconds. Reserve UI interaction for tests that are specifically testing the login page itself.

Configuration: cypress.config.js

All Cypress settings live in cypress.config.js at the project root:

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

module.exports = defineConfig({
    e2e: {
        baseUrl: 'http://localhost:3000',    // Used by cy.visit('/')
        viewportWidth: 1280,
        viewportHeight: 720,
        defaultCommandTimeout: 6000,         // ms before a command fails
        requestTimeout: 10000,               // ms for cy.request()
        responseTimeout: 30000,              // ms for server responses
        retries: {
            runMode: 2,                      // Retry failed tests in CI
            openMode: 0                      // No retries in interactive mode
        },
        env: {
            apiUrl: 'http://localhost:3001/api',
            adminEmail: 'admin@example.com'
        },
        specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
        video: false,                        // Disable video in dev mode
        screenshotOnRunFailure: true
    }
});

The env section stores values accessible via Cypress.env('apiUrl') in any test or command. Environment-specific values (staging URLs, credentials) can be passed at runtime using --env key=value on the CLI, keeping secrets out of the config file.

Running Headless

The npx cypress run command runs your full test suite headlessly, generating videos and screenshots:

# Run all tests headlessly (default: Electron browser)
npx cypress run

# Run with Chrome
npx cypress run --browser chrome

# Run with Firefox
npx cypress run --browser firefox

# Run a specific spec file
npx cypress run --spec "cypress/e2e/login.cy.js"

# Run multiple specs with glob
npx cypress run --spec "cypress/e2e/auth/**/*.cy.js"

# Pass environment variables
npx cypress run --env apiUrl=https://staging.api.example.com

# Run tagged tests (requires cypress-grep plugin)
npx cypress run --env grep="@smoke"

GitHub Actions CI Integration

The official Cypress GitHub Action (cypress-io/github-action@v6) handles installing dependencies, starting a dev server, and running tests:

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

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

jobs:
  cypress-run:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Run Cypress tests
        uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm start
          wait-on: 'http://localhost:3000'
          wait-on-timeout: 60
          browser: chrome
          record: false

      - name: Upload screenshots on failure
        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

The wait-on key ensures Cypress does not start running tests until the dev server is actually listening on port 3000. The if: failure() condition on the screenshots step only uploads evidence when tests fail, keeping artifact storage lean.

Tool Comparison

Feature Cypress Selenium Playwright WebdriverIO
Language JavaScript / TypeScript Java, Python, JS, C#, Ruby JS, TS, Python, Java, .NET JavaScript / TypeScript
Setup complexity Very low (1 npm install) High (driver management) Low (npm init playwright) Medium (wdio wizard)
Browser support Chrome, Firefox, Edge, Electron All major browsers Chromium, Firefox, WebKit All (via WebDriver)
Speed Very fast (in-browser) Moderate Very fast Moderate
Auto-wait Yes (built-in) No (explicit waits) Yes (built-in) Partial (waitFor* methods)
Mobile testing No native mobile Via Appium Mobile emulation only Yes (via Appium)
Network stubbing Built-in (cy.intercept) Requires proxy setup Built-in (route) Via mock plugin
Debugging Excellent (time-travel) Limited Good (trace viewer) Good
Best for JS-heavy SPAs Enterprise/multi-lang Multi-browser automation Node.js + mobile combo

Best Practices

1. Always use data-cy attributes

Never rely on CSS classes, IDs tied to JS behaviour, or XPath. Add data-cy="element-name" to any element your tests interact with. These attributes are pure test contracts — they communicate to every developer that this element is part of the test surface and must not be renamed without updating tests.

2. Never use cy.wait(milliseconds)

Hardcoded waits like cy.wait(2000) are a smell. They make tests slow in the happy path and still flaky when the server is slower than your wait time. Instead:

// Bad — arbitrary sleep
cy.wait(2000);
cy.get('[data-cy="result"]').should('be.visible');

// Good — wait for the actual condition
cy.get('[data-cy="result"]').should('be.visible');

// Good — wait for a specific network request
cy.intercept('GET', '/api/results').as('loadResults');
cy.get('[data-cy="search-btn"]').click();
cy.wait('@loadResults');
cy.get('[data-cy="result-item"]').should('have.length.gt', 0);

3. Keep each test independent

Every test must be able to run in any order and produce the same result. Do not rely on state from a previous test. Use beforeEach to set up fresh state. Use cy.loginViaApi() to authenticate without going through the login UI every time.

4. Use fixtures for test data

Keep test data in cypress/fixtures/. This makes it easy to update data once and have all tests that use that fixture pick up the change automatically.

5. Encapsulate repeated actions as custom commands

If you write the same 3-step sequence in multiple tests, turn it into a custom command in cypress/support/commands.js. Your tests become shorter, more readable, and easier to maintain.

6. Run a smoke suite on every PR

Keep a small set of high-value, fast tests tagged @smoke. Run these on every pull request (takes 2–3 minutes). Run the full regression suite nightly or before releases.

QA Career Insight: At Virtusa, we maintained a Cypress smoke suite of 28 tests covering the critical user journeys — login, search, cart, and checkout. These ran on every pull request in under 3 minutes. The full regression of 400+ tests ran nightly. This balance gave us fast feedback during development without blocking pull request merges for minutes of test execution. The key was ruthless prioritisation: only the highest-value, highest-risk flows made it into the smoke suite.

Back to Blog
From Experience — Virtusa: Leading a team of 270 testers at Virtusa, we standardised on Appium for real Android device testing and Selenium WebDriver for web regression. The biggest challenge wasn't the tooling — it was consistency across a team that size. We enforced a strict Page Object Model convention and a pre-merge locator review checklist. Within two sprints, flaky test rates dropped significantly and the team achieved a 20% efficiency gain across regression cycles. At that scale, test architecture decisions matter far more than individual test quality.