Cypress E2E testing patterns -- selectors, cy.intercept, cy.session, cy.clock, custom commands, test isolation, and anti-patterns
98
99%
Does it follow best practices?
Impact
97%
1.25xAverage score across 4 eval scenarios
Passed
No known issues
Browser-based E2E testing with automatic waiting, time-travel debugging, and network control.
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.
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-dependentAdd test attributes to elements you test:
<button data-testid="submit-btn">Submit</button>
<input data-testid="email-input" name="email" />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');
});
});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.
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');
});
});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');
});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.
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
});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-Pattern | Correct 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 test | cy.session() in a custom command |
Hardcoded http://localhost:3000 | baseUrl in config, cy.visit('/') |
No beforeEach -- tests share state | beforeEach resets state per test |
| Asserting action succeeded without waiting for network | cy.intercept().as('alias') + cy.wait('@alias') |
data-testid or data-cy attributes on tested elementsbaseUrl configured in cypress.config.ts (not hardcoded in tests)beforeEach for test isolation -- each test resets its own statecy.intercept() for API mocking (not cy.route()).as() and awaited with cy.wait('@alias')cy.wait(ms) -- use assertions or cy.wait('@alias')cy.session() for caching authenticated statecy.clock()/cy.tick() for time-dependent features