Cypress Best Practices
Keep Tests Independent
Every test should be able to run on its own, in any order, without depending on the outcome of a previous test. If Test B relies on Test A having created a record in the database, Test B will fail whenever Test A is skipped, filtered out, or run in a different order. Independent tests are easier to debug, can be parallelized without side effects, and produce reliable results in CI where test order is not always guaranteed.
Use beforeEach hooks to set up the state each test needs. If a test requires a logged-in user, log in during beforeEach rather than assuming a previous test already logged in. If a test needs specific data, seed that data at the beginning of the test or use API calls to create it. The small time cost of repeated setup is vastly outweighed by the reliability gained from true independence.
// Good: Each test creates its own state
describe('Shopping Cart', () => {
beforeEach(() => {
cy.loginByApi('test@example.com', 'password');
cy.visit('/cart');
});
it('adds an item to the cart', () => {
cy.visit('/products');
cy.contains('Add to Cart').first().click();
cy.get('.cart-count').should('contain', '1');
});
it('removes an item from the cart', () => {
cy.request('POST', '/api/cart/add', { productId: 1 });
cy.reload();
cy.get('[data-testid="remove-btn"]').click();
cy.get('.cart-empty').should('be.visible');
});
});
Use Stable Selectors
The selectors you use to find elements determine how resilient your tests are to UI changes. CSS class names change during redesigns, HTML structure changes during refactors, and computed IDs change across renders. The most stable selectors are dedicated test attributes that exist solely for testing purposes.
The recommended approach, endorsed by the Cypress documentation, is to use data-testid attributes:
// Most resilient: dedicated test attribute
cy.get('[data-testid="checkout-button"]').click();
// Also good: semantic attributes
cy.get('input[name="email"]').type('user@test.com');
cy.get('button[type="submit"]').click();
// Acceptable: stable IDs
cy.get('#main-navigation').should('be.visible');
// Avoid: CSS classes (change with design)
cy.get('.btn-primary-lg').click(); // Fragile
// Avoid: deep DOM paths (break with refactors)
cy.get('div.content > ul > li:nth-child(3) > a').click(); // Fragile
When working with a codebase where adding test attributes is impractical, cy.contains() provides a reasonable alternative for elements with visible text. Button labels and link text tend to be more stable than CSS classes because changing visible text requires a product decision, while changing CSS classes is a routine design activity.
Avoid Explicit Waits and Timeouts
One of the most common mistakes when moving from Selenium to Cypress is adding cy.wait(2000) calls to pause between actions. Cypress's automatic waiting handles timing automatically, and hard-coded waits slow down your test suite while still being fragile if the operation takes longer than expected on a slow CI machine.
// Bad: hard-coded wait
cy.get('[data-testid="save-btn"]').click();
cy.wait(3000); // Wastes time and still fragile
cy.get('.success').should('be.visible');
// Good: let Cypress auto-wait
cy.get('[data-testid="save-btn"]').click();
cy.get('.success').should('be.visible'); // Cypress retries until visible
When you need to wait for a specific event, wait for a network request instead of an arbitrary duration:
// Good: wait for the specific API call
cy.intercept('POST', '/api/save').as('saveRequest');
cy.get('[data-testid="save-btn"]').click();
cy.wait('@saveRequest');
cy.get('.success').should('be.visible');
The cy.wait('@alias') pattern waits for the exact network request to complete, which is both faster (no wasted time) and more reliable (no arbitrary timeout) than sleeping.
Log In Programmatically
If your application requires authentication, do not log in through the UI for every test. UI-based login is slow because it loads the login page, types credentials, submits the form, and waits for the redirect. This overhead adds up quickly when you have hundreds of tests that all need an authenticated session.
Instead, create a custom command that authenticates via the API and sets the session token directly:
Cypress.Commands.add('loginByApi', (email, password) => {
cy.request({
method: 'POST',
url: '/api/auth/login',
body: { email, password }
}).then((response) => {
window.localStorage.setItem('token', response.body.token);
});
});
Reserve UI-based login tests for your authentication test suite, where you are specifically testing the login page behavior. Every other test should use the API-based login to minimize setup time.
Control the Network Layer
Stubbing API responses with cy.intercept() gives you complete control over the data your application receives. This control is important for several reasons: it makes tests deterministic by removing dependency on live backend data, it allows testing of error states and edge cases that are hard to reproduce otherwise, and it speeds up tests by eliminating real network latency.
A well-structured test suite stubs most API calls in unit-style e2e tests and uses real API calls in a smaller set of integration tests that verify the full stack:
// Stubbed test: fast, deterministic, tests UI logic
it('displays empty state when no products exist', () => {
cy.intercept('GET', '/api/products', { body: [] });
cy.visit('/products');
cy.get('.empty-state').should('contain', 'No products found');
});
// Integration test: slower, tests full stack
it('loads real products from the API', () => {
cy.visit('/products');
cy.get('.product-card').should('have.length.greaterThan', 0);
});
Keep Tests Small and Focused
Each test should verify one specific behavior or user workflow. Long tests that check many things in sequence are harder to debug when they fail, because the failure message tells you which assertion broke but not which of the fifteen preceding actions caused the problem.
// Bad: tests too many things in one test
it('complete user workflow', () => {
cy.visit('/signup');
// ... 20 lines of signup
// ... 15 lines of profile setup
// ... 10 lines of adding a product
// ... 10 lines of checkout
// If line 45 fails, what broke?
});
// Good: separate tests for each step
it('creates a new account', () => { /* ... */ });
it('completes profile setup', () => { /* ... */ });
it('adds a product to cart', () => { /* ... */ });
it('completes checkout', () => { /* ... */ });
Smaller tests run faster because Cypress can bail on a failure immediately rather than timing out on subsequent steps. They also produce clearer CI reports where the test name tells you exactly which feature is broken.
Organize Tests by Feature
Group test files by the feature or page they cover, mirroring your application's structure. This makes it easy to find tests when a feature changes and allows running subsets of tests during focused development:
cypress/e2e/
auth/
login.cy.js
signup.cy.js
password-reset.cy.js
products/
catalog.cy.js
search.cy.js
product-details.cy.js
checkout/
cart.cy.js
payment.cy.js
order-confirmation.cy.js
During development, you can run just the tests for the feature you are working on by clicking the specific file in the Test Runner or passing a spec pattern to the CLI: npx cypress run --spec "cypress/e2e/products/**".
Optimize for CI Performance
Slow test suites create bottlenecks in deployment pipelines. Several practices keep Cypress tests fast in CI environments:
- Cache the
~/.cache/Cypressdirectory to avoid re-downloading the binary on every build. - Use API-based login instead of UI login in
beforeEachhooks. - Stub network requests where full-stack integration is not the test's focus.
- Run tests in parallel using Cypress Cloud or community splitting tools.
- Disable video recording in CI if you do not use the recordings, as encoding video adds time to every spec file.
- Set shorter default timeouts if your CI environment is fast and you want faster failure detection.
Avoid Common Anti-Patterns
Several patterns that seem convenient lead to unreliable or unmaintainable tests over time:
- Sharing state between tests: Tests that depend on a previous test's side effects break in isolation and cannot be parallelized.
- Using cy.wait() with milliseconds: Hard-coded delays are always wrong. Wait for specific events, network requests, or DOM state changes instead.
- Testing third-party sites: Do not write tests that navigate to external websites you do not control. Their content, structure, and availability are outside your control.
- Single massive spec file: Splitting tests across multiple files improves readability, allows selective execution, and enables parallelization.
- Asserting on implementation details: Test what the user sees and does, not internal state, computed CSS values, or framework-specific DOM structures.
The foundation of a reliable Cypress test suite is independent tests, stable selectors, programmatic authentication, network control, and focused test scope. Apply these practices from the beginning of your project rather than retrofitting them into a flaky test suite later.