CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/cypress-testing

Cypress E2E testing patterns -- selectors, cy.intercept, cy.session, cy.clock, custom commands, test isolation, and anti-patterns

98

1.25x
Quality

99%

Does it follow best practices?

Impact

97%

1.25x

Average score across 4 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
cypress-testing
description:
Cypress E2E testing patterns -- commands, selectors, network stubbing, auth caching, time control, test isolation, and best practices. Use when building or reviewing E2E tests with Cypress, when setting up browser testing, or when debugging flaky Cypress tests.
keywords:
cypress, cypress e2e, cypress testing, cypress commands, cy.get, cy.intercept, cypress fixtures, cypress selectors, cypress best practices, cypress component testing, data-cy, cypress typescript, cy.session, cy.clock, cy.tick, cy.intercept, custom commands, test isolation
license:
MIT

Cypress E2E Testing

Browser-based E2E testing with automatic waiting, time-travel debugging, and network control.


Setup and Configuration

npm install -D cypress
npx cypress open  # Opens interactive mode, generates config
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',   // ALWAYS set baseUrl -- never hardcode URLs in tests
    viewportWidth: 1280,
    viewportHeight: 720,
    video: false,
    screenshotOnRunFailure: true,
    retries: { runMode: 2, openMode: 0 },
  },
});

Critical: Always configure baseUrl in cypress.config.ts. Tests use cy.visit('/') not cy.visit('http://localhost:3000/'). This enables environment switching and faster test starts.


Selector Strategy

Use data-testid (or data-cy) attributes for test selectors. These are decoupled from styling and structure, making tests resilient to refactors.

// BEST -- dedicated test attributes (stable, explicit)
cy.get('[data-testid="submit-btn"]').click();
cy.get('[data-cy="login-form"]').should('be.visible');

// GOOD -- semantic selectors when test attributes aren't available
cy.contains('button', 'Submit').click();
cy.get('input[name="email"]').type('user@test.com');

// AVOID -- brittle selectors tied to styling or DOM structure
cy.get('.btn-primary').click();                    // CSS classes change with redesigns
cy.get('#root > div > form > button').click();     // Structure changes break this
cy.get(':nth-child(3)').click();                   // Position-dependent

Add test attributes to elements you test:

<button data-testid="submit-btn">Submit</button>
<input data-testid="email-input" name="email" />

Test Isolation with beforeEach

Every test must be independent. Use beforeEach to reset state so tests can run in any order.

describe('Shopping cart', () => {
  beforeEach(() => {
    // Reset application state before each test
    cy.visit('/');
    // Seed or reset data if needed
    cy.intercept('GET', '/api/products', { fixture: 'products.json' }).as('getProducts');
    cy.wait('@getProducts');
  });

  it('adds item to cart', () => {
    cy.get('[data-testid="add-to-cart"]').first().click();
    cy.get('[data-testid="cart-count"]').should('contain', '1');
  });

  it('removes item from cart', () => {
    // This test doesn't depend on the previous test having added an item
    cy.get('[data-testid="add-to-cart"]').first().click();
    cy.get('[data-testid="remove-from-cart"]').first().click();
    cy.get('[data-testid="cart-count"]').should('contain', '0');
  });
});

Network Stubbing with cy.intercept()

Use cy.intercept() for all API mocking and network control. Never use cy.route() or cy.server() -- these are removed in Cypress 12+.

// Stub API responses
it('displays products from API', () => {
  cy.intercept('GET', '/api/products', { fixture: 'products.json' }).as('getProducts');
  cy.visit('/');
  cy.wait('@getProducts');
  cy.get('[data-testid="product-card"]').should('have.length', 3);
});

// Stub error responses
it('shows error state when API fails', () => {
  cy.intercept('GET', '/api/products', { statusCode: 500, body: { error: 'Server error' } }).as('getProductsError');
  cy.visit('/');
  cy.wait('@getProductsError');
  cy.get('[data-testid="error-message"]').should('be.visible');
});

// Wait for real API calls and assert on them
it('submits order to API', () => {
  cy.intercept('POST', '/api/orders').as('createOrder');
  // ... fill form and submit ...
  cy.wait('@createOrder').its('response.statusCode').should('eq', 201);
});

// Intercept and modify responses
it('shows empty state', () => {
  cy.intercept('GET', '/api/products', { body: [] }).as('emptyProducts');
  cy.visit('/');
  cy.wait('@emptyProducts');
  cy.get('[data-testid="empty-state"]').should('be.visible');
});

Key pattern: Always alias intercepts with .as('name') and use cy.wait('@name') before asserting on results. This ensures the network call has completed.


Authentication Caching with cy.session()

Use cy.session() to cache login state across tests. Without it, every test that needs auth repeats the full login flow, making suites slow and brittle.

// cypress/support/commands.ts
Cypress.Commands.add('login', (email: string, password: string) => {
  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-btn"]').click();
    cy.url().should('not.include', '/login');
  });
});

// Usage in tests -- session is cached after first run
describe('Dashboard', () => {
  beforeEach(() => {
    cy.login('admin@test.com', 'password123');
    cy.visit('/dashboard');
  });

  it('shows user profile', () => {
    cy.get('[data-testid="user-name"]').should('contain', 'Admin');
  });
});

Custom Commands for Reusable Flows

Extract repeated multi-step interactions into custom commands. This keeps tests readable and avoids duplication.

// cypress/support/commands.ts
Cypress.Commands.add('fillForm', (fields: Record<string, string>) => {
  Object.entries(fields).forEach(([name, value]) => {
    cy.get(`[data-testid="${name}-input"]`).clear().type(value);
  });
});

Cypress.Commands.add('submitAndWait', (alias: string) => {
  cy.get('[data-testid="submit-btn"]').click();
  cy.wait(alias);
});

// Usage
it('creates a new user', () => {
  cy.intercept('POST', '/api/users').as('createUser');
  cy.fillForm({ name: 'Jane Doe', email: 'jane@test.com' });
  cy.submitAndWait('@createUser');
  cy.contains('User created');
});

Retryability: Assertions Auto-Retry, Actions Do Not

This is the most misunderstood Cypress concept. Assertions (.should()) automatically retry until they pass or timeout. Actions (.click(), .type()) do NOT retry -- they run once.

// CORRECT -- assertion retries until element appears
cy.get('[data-testid="notification"]').should('be.visible');
cy.get('[data-testid="results"]').should('have.length.greaterThan', 0);

// WRONG -- click runs once; if element isn't ready, test fails
// Don't try to "retry" actions with arbitrary waits
cy.wait(3000);  // BAD
cy.get('[data-testid="submit-btn"]').click();

// CORRECT -- assert element is ready, THEN act
cy.get('[data-testid="submit-btn"]').should('be.visible').click();
cy.get('[data-testid="submit-btn"]').should('not.be.disabled').click();

// WRONG -- conditional logic based on DOM state
// Cypress commands are async; if/else on DOM state is unreliable
cy.get('body').then(($body) => {
  if ($body.find('[data-testid="modal"]').length) {  // ANTI-PATTERN
    cy.get('[data-testid="close-modal"]').click();
  }
});

// CORRECT -- assert the expected state directly
cy.get('[data-testid="modal"]').should('be.visible');
cy.get('[data-testid="close-modal"]').click();

Rule: Never use conditional testing (if/else on DOM state). Each test should have a single deterministic path. If different states are possible, write separate tests for each.


Time-Dependent Tests with cy.clock() and cy.tick()

For features that depend on time (debounce, polling, countdowns, token expiry), use cy.clock() and cy.tick() instead of real waits.

it('shows session expiry warning after 25 minutes', () => {
  cy.clock();
  cy.login('user@test.com', 'password');
  cy.visit('/dashboard');

  // Fast-forward 25 minutes
  cy.tick(25 * 60 * 1000);

  cy.get('[data-testid="session-warning"]').should('be.visible');
});

it('debounces search input', () => {
  cy.clock();
  cy.visit('/search');

  cy.get('[data-testid="search-input"]').type('test query');
  // Search should NOT fire immediately
  cy.get('[data-testid="search-results"]').should('not.exist');

  // Fast-forward past debounce delay
  cy.tick(500);
  cy.get('[data-testid="search-results"]').should('be.visible');
});

it('polls for updates every 30 seconds', () => {
  cy.clock();
  cy.intercept('GET', '/api/notifications').as('pollNotifications');
  cy.visit('/dashboard');
  cy.wait('@pollNotifications');  // Initial load

  cy.tick(30000);
  cy.wait('@pollNotifications');  // First poll
});

Viewport Testing

Test responsive behavior by setting viewport dimensions.

describe('Responsive navigation', () => {
  it('shows hamburger menu on mobile', () => {
    cy.viewport(375, 667);  // iPhone SE
    cy.visit('/');
    cy.get('[data-testid="hamburger-menu"]').should('be.visible');
    cy.get('[data-testid="desktop-nav"]').should('not.be.visible');
  });

  it('shows full nav on desktop', () => {
    cy.viewport(1280, 720);
    cy.visit('/');
    cy.get('[data-testid="desktop-nav"]').should('be.visible');
    cy.get('[data-testid="hamburger-menu"]').should('not.be.visible');
  });
});

// Named viewports
cy.viewport('iphone-6');
cy.viewport('macbook-15');

Anti-Patterns Summary

Anti-PatternCorrect Alternative
cy.wait(5000)cy.get('[data-testid="result"]').should('be.visible')
cy.route() / cy.server()cy.intercept()
CSS class selectors .btn-primary[data-testid="submit-btn"] or cy.contains()
if ($body.find(...).length)Separate deterministic tests per state
Repeating login in every testcy.session() in a custom command
Hardcoded http://localhost:3000baseUrl in config, cy.visit('/')
No beforeEach -- tests share statebeforeEach resets state per test
Asserting action succeeded without waiting for networkcy.intercept().as('alias') + cy.wait('@alias')

Checklist

  • data-testid or data-cy attributes on tested elements
  • baseUrl configured in cypress.config.ts (not hardcoded in tests)
  • beforeEach for test isolation -- each test resets its own state
  • cy.intercept() for API mocking (not cy.route())
  • Intercepts aliased with .as() and awaited with cy.wait('@alias')
  • No cy.wait(ms) -- use assertions or cy.wait('@alias')
  • No conditional testing (no if/else on DOM state)
  • Custom commands for repeated multi-step flows
  • cy.session() for caching authenticated state
  • cy.clock()/cy.tick() for time-dependent features
  • Screenshots on failure enabled
  • Retries configured for CI mode

Verifiers

  • cypress-selectors -- Stable selectors and core Cypress patterns
  • cypress-login-dashboard -- E2E tests for login flow and dashboard
  • cypress-ecommerce-checkout -- E2E tests for shopping cart and checkout
  • cypress-search-filtering -- E2E tests for search with debounce and filters
  • cypress-form-wizard -- E2E tests for multi-step form wizard
  • cypress-notifications-polling -- E2E tests for real-time notifications
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/cypress-testing badge