JavaScript Cypress: E2E Testing Framework

Cypress has fundamentally changed how teams approach end-to-end testing. Unlike Selenium-based tools that operate outside the browser via WebDriver protocols, Cypress runs directly inside the...

Key Insights

  • Cypress runs directly in the browser alongside your application, eliminating the network latency and synchronization headaches that plague Selenium-based testing frameworks.
  • Automatic waiting and retry mechanisms handle most async operations without explicit waits, but understanding when to use cy.intercept() for network control is essential for reliable tests.
  • The biggest ROI comes from investing in custom commands and data-testid selectors early—they transform brittle, unmaintainable tests into a robust regression safety net.

Introduction to Cypress

Cypress has fundamentally changed how teams approach end-to-end testing. Unlike Selenium-based tools that operate outside the browser via WebDriver protocols, Cypress runs directly inside the browser. This architectural decision eliminates an entire class of problems: network latency between test commands, synchronization issues, and the infamous “element not found” errors that plague traditional E2E frameworks.

The framework automatically waits for elements to appear, animations to complete, and network requests to finish. You don’t write sleep(2000) or explicit wait conditions—Cypress handles this intelligently with built-in retry logic.

When should you choose Cypress? It excels for single-origin web applications where you need fast, reliable feedback. If you’re testing across multiple domains, need Safari support, or require mobile device testing, consider Playwright instead. Cypress owns its niche: JavaScript/TypeScript web apps where developer experience matters.

Setting Up Your First Cypress Project

Installation is straightforward. Add Cypress to an existing project or start fresh:

npm init -y
npm install cypress --save-dev
npx cypress open

The first run creates the default folder structure:

cypress/
├── e2e/           # Your test files live here
├── fixtures/      # Static test data (JSON files)
├── support/       # Custom commands and global config
   ├── commands.js
   └── e2e.js
└── downloads/     # Files downloaded during tests
cypress.config.js  # Main configuration file

Configure Cypress for your application in cypress.config.js:

const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    viewportWidth: 1280,
    viewportHeight: 720,
    defaultCommandTimeout: 10000,
    requestTimeout: 10000,
    video: false, // Enable in CI for debugging failures
    screenshotOnRunFailure: true,
    setupNodeEvents(on, config) {
      // Register plugins here
      return config;
    },
  },
});

Create your first test file at cypress/e2e/smoke.cy.js:

describe('Application Smoke Test', () => {
  it('loads the homepage successfully', () => {
    cy.visit('/');
    cy.get('h1').should('be.visible');
  });
});

Run it with npx cypress open for the interactive runner or npx cypress run for headless execution.

Writing Your First E2E Test

Cypress commands chain together fluently. Each command yields a subject that the next command operates on. Here’s a complete login flow test:

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

  it('allows a user to log in with valid credentials', () => {
    cy.get('[data-testid="email-input"]')
      .type('user@example.com');
    
    cy.get('[data-testid="password-input"]')
      .type('securePassword123');
    
    cy.get('[data-testid="login-button"]')
      .click();

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

  it('displays an error for invalid credentials', () => {
    cy.get('[data-testid="email-input"]')
      .type('wrong@example.com');
    
    cy.get('[data-testid="password-input"]')
      .type('wrongPassword');
    
    cy.get('[data-testid="login-button"]')
      .click();

    cy.get('[data-testid="error-message"]')
      .should('be.visible')
      .and('contain', 'Invalid credentials');
    
    cy.url().should('include', '/login');
  });
});

Notice there are no explicit waits. Cypress automatically retries cy.get() until the element appears (up to defaultCommandTimeout) and retries assertions until they pass or timeout.

Working with Selectors and Best Practices

CSS selectors tied to styling break when designers refactor. Class names like .btn-primary or structural selectors like div > form > input:first-child are maintenance nightmares.

Use data-testid attributes instead:

<!-- In your application code -->
<button data-testid="submit-order" class="btn btn-primary">
  Place Order
</button>
// In your test
cy.get('[data-testid="submit-order"]').click();

This creates a contract between your tests and your application that survives CSS refactoring.

For repeated operations, create custom commands in cypress/support/commands.js:

Cypress.Commands.add('login', (email, password) => {
  cy.session([email, password], () => {
    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();
    cy.url().should('include', '/dashboard');
  });
});

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

Cypress.Commands.add('shouldBeAccessible', () => {
  // Integrate with cypress-axe for accessibility testing
  cy.injectAxe();
  cy.checkA11y();
});

Now your tests become more readable:

describe('Order Flow', () => {
  beforeEach(() => {
    cy.login('user@example.com', 'password123');
    cy.visit('/products');
  });

  it('allows adding items to cart', () => {
    cy.getByTestId('product-card').first().click();
    cy.getByTestId('add-to-cart').click();
    cy.getByTestId('cart-count').should('contain', '1');
  });
});

The cy.session() wrapper caches the login state, dramatically speeding up test suites by avoiding repeated login flows.

Handling Async Operations and Network Requests

While Cypress handles most waiting automatically, network requests require explicit control for reliable tests. Use cy.intercept() to stub API responses:

describe('Product Listing', () => {
  it('displays products from the API', () => {
    cy.intercept('GET', '/api/products', {
      fixture: 'products.json'
    }).as('getProducts');

    cy.visit('/products');
    cy.wait('@getProducts');

    cy.getByTestId('product-card').should('have.length', 3);
  });

  it('handles loading states', () => {
    cy.intercept('GET', '/api/products', {
      fixture: 'products.json',
      delay: 1000
    }).as('getProducts');

    cy.visit('/products');
    cy.getByTestId('loading-spinner').should('be.visible');
    cy.wait('@getProducts');
    cy.getByTestId('loading-spinner').should('not.exist');
  });

  it('displays error state when API fails', () => {
    cy.intercept('GET', '/api/products', {
      statusCode: 500,
      body: { error: 'Internal server error' }
    }).as('getProducts');

    cy.visit('/products');
    cy.wait('@getProducts');

    cy.getByTestId('error-message')
      .should('be.visible')
      .and('contain', 'Failed to load products');
    
    cy.getByTestId('retry-button').should('be.visible');
  });
});

Create the fixture file at cypress/fixtures/products.json:

[
  { "id": 1, "name": "Widget", "price": 29.99 },
  { "id": 2, "name": "Gadget", "price": 49.99 },
  { "id": 3, "name": "Doohickey", "price": 19.99 }
]

This approach gives you complete control over test data and lets you verify edge cases that are difficult to reproduce with a real backend.

CI/CD Integration and Reporting

Running Cypress in CI requires headless mode and proper caching. Here’s a GitHub Actions workflow:

name: E2E Tests

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

jobs:
  cypress:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Start application
        run: npm run start &
        env:
          NODE_ENV: test

      - name: Wait for app
        run: npx wait-on http://localhost:3000

      - name: Run Cypress tests
        uses: cypress-io/github-action@v6
        with:
          wait-on: 'http://localhost:3000'
          browser: chrome
          record: true
        env:
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

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

For larger teams, Cypress Cloud (formerly Dashboard) provides test analytics, parallelization, and flake detection. The record: true flag sends results to the cloud service.

Common Pitfalls and Debugging Tips

Flaky tests usually stem from three causes: timing issues, test isolation failures, or external dependencies.

For timing issues, avoid arbitrary waits. Instead of cy.wait(2000), wait for specific conditions:

// Bad
cy.wait(2000);
cy.get('.modal').click();

// Good
cy.get('.modal').should('be.visible').click();

// Even better for animations
cy.get('.modal')
  .should('be.visible')
  .and('not.have.class', 'animating')
  .click();

For test isolation, ensure each test cleans up after itself:

beforeEach(() => {
  cy.clearLocalStorage();
  cy.clearCookies();
  // Reset database state via API
  cy.request('POST', '/api/test/reset');
});

Never test external sites you don’t control. If your app integrates with Stripe, mock the Stripe API—don’t navigate to stripe.com.

Avoid conditional testing. Tests should be deterministic:

// Anti-pattern: conditional logic in tests
cy.get('body').then(($body) => {
  if ($body.find('.popup').length) {
    cy.get('.popup .close').click();
  }
});

// Better: control the state explicitly
cy.intercept('GET', '/api/popups', { body: [] }); // No popups
cy.visit('/');

Use Cypress’s time-travel debugger by clicking on any command in the test runner. You’ll see the exact DOM state at that moment, including before/after snapshots for commands that modify the page.

The investment in proper E2E testing pays compound interest. Start with critical user flows—login, checkout, core features—and expand coverage as your test infrastructure matures. A small suite of reliable tests beats a large suite of flaky ones every time.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.