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');
8. Group Related Tests with describe()
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
- Don’t test third-party integrations directly - Mock them instead
- Don’t visit external sites - Use
cy.request()or stub - Don’t share state between tests - Each test should be independent
- Don’t use
.then()when chaining is sufficient - Keep it simple - 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!