All Articles

Cypress Best Practices: Writing Reliable End-to-End Tests in 2025

Written by Jack on January 18, 2025

Article Image

Cypress has revolutionized end-to-end testing with its developer-friendly approach and powerful capabilities. However, as test suites grow, teams often encounter flaky tests, slow execution times, and maintenance nightmares. After working with Cypress across dozens of projects, I’ve compiled the essential best practices that separate amateur test suites from professional-grade automation.

Why Best Practices Matter

Writing Cypress tests is easy. Writing good Cypress tests that remain reliable, fast, and maintainable as your application scales is an art. Following these practices will help you:

  • Reduce test flakiness by 90%
  • Cut execution time in half
  • Make tests easier to maintain
  • Improve developer confidence in your test suite

Let’s dive into the practices that actually work in production.

1. Use Programmatic Authentication (Skip the UI)

The Problem: Most teams waste time testing login on every single test. If you have 100 tests and each logs in via UI, you’re wasting precious execution time on repetitive actions.

Bad Approach:

beforeEach(() => {
  cy.visit('/login');
  cy.get('[data-cy="email"]').type('user@example.com');
  cy.get('[data-cy="password"]').type('password123');
  cy.get('[data-cy="submit"]').click();
  cy.url().should('include', '/dashboard');
});

Best Practice:

// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
  cy.request({
    method: 'POST',
    url: '/api/auth/login',
    body: { email, password }
  }).then((response) => {
    window.localStorage.setItem('authToken', response.body.token);
    window.localStorage.setItem('userId', response.body.userId);
  });
});

// In your test
beforeEach(() => {
  cy.login('user@example.com', 'password123');
  cy.visit('/dashboard');
});

Benefits:

  • 10x faster: API calls complete in milliseconds vs. seconds for UI interaction
  • More reliable: No UI dependencies, animations, or network delays
  • Better isolation: Each test gets a fresh authentication state

2. Use Data Attributes for Selectors

The Problem: CSS classes and IDs change during development, breaking your tests constantly.

Bad Approach:

cy.get('.btn-primary.submit-button').click(); // Fragile!
cy.get('#user-dropdown-menu-item-3').click(); // Very fragile!

Best Practice:

// Add data attributes to your HTML
<button data-cy="submit-button" class="btn btn-primary">Submit</button>
<div data-testid="user-menu">...</div>

// Use them in tests
cy.get('[data-cy="submit-button"]').click();
cy.get('[data-testid="user-menu"]').click();

Why This Works:

  • Data attributes are intentional test markers
  • They don’t change when styling updates
  • Makes test intent crystal clear
  • Easy to find elements that have test coverage

3. Handle Asynchronous Commands Correctly

Cypress commands are asynchronous and chainable. Understanding this is crucial.

Bad Approach:

const button = cy.get('[data-cy="submit"]'); // Wrong!
button.click(); // This won't work!

let username;
cy.get('[data-cy="username"]').then(($el) => {
  username = $el.text();
});
cy.log(username); // undefined!

Best Practice:

// Option 1: Chaining
cy.get('[data-cy="submit"]')
  .should('be.visible')
  .click();

// Option 2: Using .then() for values
cy.get('[data-cy="username"]').then(($el) => {
  const username = $el.text();
  cy.log(username); // Works!
  expect(username).to.equal('JohnDoe');
});

// Option 3: Using aliases
cy.get('[data-cy="username"]').invoke('text').as('username');
cy.get('@username').should('equal', 'JohnDoe');

4. Keep Tests Independent and Isolated

The Problem: Tests that depend on each other create cascading failures and make debugging impossible.

Bad Approach:

it('should create a user', () => {
  cy.get('[data-cy="create-user"]').click();
  // Creates user...
});

it('should edit the user', () => {
  // Assumes previous test ran!
  cy.get('[data-cy="edit-user"]').click();
});

Best Practice:

beforeEach(() => {
  // Set up fresh state for each test
  cy.request('POST', '/api/users', {
    name: 'Test User',
    email: 'test@example.com'
  }).then((response) => {
    cy.wrap(response.body.id).as('userId');
  });
});

it('should edit a user', () => {
  cy.get('@userId').then((id) => {
    cy.visit(`/users/${id}/edit`);
    cy.get('[data-cy="name"]').clear().type('Updated Name');
    cy.get('[data-cy="save"]').click();
    cy.get('[data-cy="success-message"]').should('be.visible');
  });
});

afterEach(() => {
  // Clean up
  cy.get('@userId').then((id) => {
    cy.request('DELETE', `/api/users/${id}`);
  });
});

5. Avoid Arbitrary Waits (Use Built-in Retry)

Bad Approach:

cy.get('[data-cy="submit"]').click();
cy.wait(3000); // Arbitrary wait - bad!
cy.get('[data-cy="success"]').should('be.visible');

Best Practice:

// Cypress automatically retries until timeout
cy.get('[data-cy="submit"]').click();
cy.get('[data-cy="success"]', { timeout: 10000 })
  .should('be.visible');

// Wait for specific network requests
cy.intercept('POST', '/api/users').as('createUser');
cy.get('[data-cy="submit"]').click();
cy.wait('@createUser').its('response.statusCode').should('eq', 201);

Why This Works:

  • Cypress retries assertions automatically
  • Tests run as fast as possible
  • No time wasted on unnecessary waits
  • More reliable than arbitrary timeouts

6. Use cy.intercept() to Control Network Requests

Best Practice:

// Stub API responses for consistent tests
it('should handle server error gracefully', () => {
  cy.intercept('POST', '/api/users', {
    statusCode: 500,
    body: { error: 'Internal Server Error' }
  }).as('createUser');

  cy.get('[data-cy="submit"]').click();
  cy.wait('@createUser');
  cy.get('[data-cy="error-message"]')
    .should('contain', 'Something went wrong');
});

// Monitor real API calls
it('should create user successfully', () => {
  cy.intercept('POST', '/api/users').as('createUser');

  cy.get('[data-cy="name"]').type('John Doe');
  cy.get('[data-cy="submit"]').click();

  cy.wait('@createUser').then((interception) => {
    expect(interception.response.statusCode).to.equal(201);
    expect(interception.response.body).to.have.property('id');
  });
});

7. Configure Base URL

Best Practice:

// cypress.config.js
module.exports = {
  e2e: {
    baseUrl: 'http://localhost:3000',
    setupNodeEvents(on, config) {
      // implement node event listeners
    },
  },
};

// Now in tests, use relative URLs
cy.visit('/dashboard'); // Instead of full URL
cy.visit('/users/123/edit');

Best Practice:

describe('User Management', () => {
  beforeEach(() => {
    cy.login('admin@example.com', 'admin123');
  });

  describe('Creating Users', () => {
    it('should create user with valid data', () => {
      // Test implementation
    });

    it('should show validation errors', () => {
      // Test implementation
    });
  });

  describe('Editing Users', () => {
    it('should update user information', () => {
      // Test implementation
    });
  });
});

9. Create Custom Commands for Reusability

Best Practice:

// cypress/support/commands.js
Cypress.Commands.add('createUser', (userData) => {
  return cy.request('POST', '/api/users', userData)
    .then((response) => response.body);
});

Cypress.Commands.add('deleteUser', (userId) => {
  return cy.request('DELETE', `/api/users/${userId}`);
});

// Use in tests
it('should display user details', () => {
  cy.createUser({
    name: 'Jane Doe',
    email: 'jane@example.com'
  }).then((user) => {
    cy.visit(`/users/${user.id}`);
    cy.get('[data-cy="user-name"]').should('contain', 'Jane Doe');

    // Cleanup
    cy.deleteUser(user.id);
  });
});

10. Use beforeEach for Setup, Not afterEach

Why? If a test fails, afterEach might not run, leaving your database in a dirty state.

Best Practice:

describe('Shopping Cart', () => {
  beforeEach(() => {
    // Clean slate before each test
    cy.request('DELETE', '/api/cart/clear');
    cy.request('POST', '/api/cart/seed', {
      items: [
        { productId: 1, quantity: 2 },
        { productId: 2, quantity: 1 }
      ]
    });
  });

  it('should remove item from cart', () => {
    cy.visit('/cart');
    cy.get('[data-cy="remove-item-1"]').click();
    cy.get('[data-cy="cart-items"]').should('have.length', 1);
  });
});

Common Pitfalls to Avoid

  1. Don’t test third-party integrations directly - Mock them instead
  2. Don’t visit external sites - Use cy.request() or stub
  3. Don’t share state between tests - Each test should be independent
  4. Don’t use .then() when chaining is sufficient - Keep it simple
  5. Don’t ignore Cypress warnings - They’re usually pointing to real issues

Performance Tips

// Run tests in parallel
npx cypress run --parallel --record --key YOUR_KEY

// Run only smoke tests for quick feedback
npx cypress run --spec "cypress/e2e/smoke/**/*"

// Use modern browsers for faster execution
{
  "e2e": {
    "browser": "chrome",
    "chromeWebSecurity": false
  }
}

Conclusion

Great Cypress tests don’t happen by accident. They’re the result of following proven practices that prioritize:

  • Speed: Programmatic auth, API requests, smart waits
  • Reliability: Data attributes, test isolation, retry logic
  • Maintainability: Custom commands, page objects, clear organization

At Devagen, we’ve helped teams transform flaky, slow Cypress suites into reliable, fast test automation that developers actually trust. These practices are battle-tested across real-world applications.

Start implementing these today, and watch your test suite transform from a maintenance burden into a development accelerator.

Happy Testing!

Contact us

Email: hello@devagen.com Phone: +46732137903 Address: Landsvägen 17c, Sundbyberg, 17263, Sweden
Devagen® 2025. All Rights Reserved.