10 Cypress Best Practices for Stable and Maintainable Tests

July 28, 2025

The promise of end-to-end (E2E) testing is a powerful one: a safety net that validates user flows, catches regressions, and builds confidence before every deployment. Yet, many development teams find themselves trapped in a cycle of flaky, brittle, and time-consuming tests. The very automation designed to increase velocity becomes a bottleneck. Cypress has emerged as a leading framework to combat this, but the tool itself is only half the equation. True success lies in how you wield it. Adhering to a set of established Cypress best practices is the critical difference between a test suite that enables your team and one that hinders it. A Forbes Tech Council analysis highlights the significant ROI of quality assurance, and a robust E2E testing strategy is a cornerstone of that investment. This comprehensive guide will walk you through ten foundational best practices, complete with code examples and strategic insights, to help you build a Cypress testing suite that is not just functional, but stable, maintainable, and a genuine asset to your development lifecycle.

Foundational Practice: Embrace the Cypress Philosophy

Before diving into specific coding practices, the most crucial first step is to understand and embrace the core philosophy of Cypress. It is not simply a Selenium alternative; it operates on a fundamentally different architecture. Cypress runs in the same run-loop as your application, giving it native access to the DOM, network traffic, and everything else on the page. This architectural choice, as detailed in Cypress's official documentation, is what enables its signature features: time-travel debugging, real-time reloads, and automatic waiting.

Understanding this means accepting its trade-offs. For instance, Cypress is intentionally limited to a single browser tab and a single origin per test. These aren't oversights; they are deliberate design decisions that encourage better test design. Instead of wrestling with multiple tabs, the Cypress way encourages you to stub API responses to simulate cross-app interactions. This leads to faster, more reliable, and less flaky tests by removing external dependencies. According to MIT research on software architecture, understanding these trade-offs is key to leveraging a tool effectively. By internalizing the 'why' behind Cypress's design, you'll naturally adopt better habits and find that many of the following Cypress best practices become intuitive extensions of this core philosophy. It's about working with the framework, not against it.

1. Use `data-*` Attributes for Resilient Selectors

One of the most common sources of test flakiness is brittle selectors. When your tests rely on CSS classes (.btn-primary), element IDs (#submit), or text content (cy.contains('Submit')), they become tightly coupled to your application's styling and content. A simple design refresh or copy change can break dozens of tests, leading to maintenance headaches.

The premier Cypress best practice to combat this is to use dedicated test attributes. The Cypress team strongly recommends using data-cy, data-test, or data-testid attributes on your elements.

The Problem (Brittle Selector):

<button class="btn btn-primary action-btn" id="main-submit">Submit</button>
// This test can break if class, id, or text changes.
cy.get('#main-submit.btn-primary').click();

The Solution (Resilient Selector):

<button
  class="btn btn-primary action-btn"
  id="main-submit"
  data-cy="submit-button"
>
  Submit
</button>
// This test is decoupled from styling and content.
cy.get('[data-cy=submit-button]').click();

This practice creates a contract between your application code and your test code. As advocated by testing experts like Kent C. Dodds, these attributes make the intent of an element clear to everyone on the team—this element is important for testing. This approach is so crucial that Cypress has built-in configuration to enforce it with the selectorPriority option. By decoupling your tests from implementation details, you ensure they are resilient to UI refactoring and content updates, saving countless hours of debugging and maintenance. This aligns with broader software principles discussed in Martin Fowler's work on decoupling, which advocates for reducing dependencies between different parts of a system.

2. Isolate Tests by Stubbing Network Requests

Your application does not exist in a vacuum. It communicates with backend APIs, analytics services, and other third-party scripts. If your E2E tests make real network requests to these services, you are introducing massive sources of instability. The backend could be down, the network could be slow, or the third-party API could change, all causing your tests to fail for reasons unrelated to the frontend code you're actually trying to test.

A fundamental Cypress best practice is to take control of your application's network layer using cy.intercept(). This powerful command allows you to stub and mock network requests and responses, ensuring your tests run in a deterministic, consistent, and fast environment.

Consider a test for displaying a list of users fetched from an API.

Without Stubbing (Unreliable):

it('should display a list of users', () => {
  cy.visit('/users');
  // This test depends on the real API returning users.
  // It will be slow and can fail if the API is down.
  cy.get('[data-cy=user-list-item]').should('have.length.greaterThan', 0);
});

With cy.intercept() (Reliable and Fast):

it('should display a list of users from a stubbed response', () => {
  // Intercept the GET request and provide a fixture as the response
  cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers');

  cy.visit('/users');

  // Wait for the intercepted request to complete
  cy.wait('@getUsers');

  // Now assert against the predictable data from your fixture
  cy.get('[data-cy=user-list-item]').should('have.length', 3);
});

Using cy.intercept() offers numerous advantages. As noted in Cypress's official documentation, it allows you to test edge cases easily, such as server errors (statusCode: 500), empty states (an empty array []), or slow network conditions (delay: 1000). This practice of using test doubles is a well-established concept in software engineering, crucial for creating isolated unit and integration tests, and its value extends directly to E2E testing. It transforms your tests from being slow and unpredictable to being lightning-fast and rock-solid. Industry reports like the DORA State of DevOps Report consistently correlate reliable automated testing with elite engineering performance.

3. Master Asynchronous Behavior the Cypress Way

A common stumbling block for newcomers to Cypress is handling its asynchronous nature. Developers accustomed to async/await with Promises often try to apply the same pattern directly to Cypress commands, which leads to confusion and flaky tests.

It's critical to understand this: Cypress commands are not Promises. They are enqueued and executed serially. The cy object manages a command queue, and your test script is simply a blueprint for building that queue.

This is why you cannot assign the result of a command to a variable:

The Wrong Way:

// This will NOT work as you expect. `button` is not the element.
const button = cy.get('[data-cy=submit-button]');
button.click(); // This will throw an error

The correct Cypress best practice is to use .then() to chain commands and work with the yielded subject (the element, text, etc.). Cypress also has built-in retry-ability for most commands, which handles a lot of the asynchronicity for you. You should almost never need a cy.wait(number).

The Right Way:

cy.get('[data-cy=submit-button]').then(($button) => {
  // $button is the actual jQuery element yielded by cy.get()
  // You can work with it here
  const text = $button.text();
  expect(text).to.equal('Submit');
});

// For simple actions, you don't even need .then()
// Cypress automatically waits for the element to be actionable.
cy.get('[data-cy=submit-button]').click();

This command queue architecture is explained in detail in the Introduction to Cypress guide. Resisting the urge to add arbitrary waits (cy.wait(500)) and instead relying on Cypress's built-in mechanisms is paramount. These 'hard waits' are a primary cause of two problems: tests that are unnecessarily slow (if the app is faster than the wait) and tests that are flaky (if the app is slower). Instead, wait on specific events, like a network request completing (cy.wait('@getUsers')) or an element becoming visible (cy.get('.spinner').should('not.exist')). This approach, as supported by analysis on flaky tests, eliminates race conditions and builds a much more robust test suite.

4. Structure Tests Logically with Hooks and Patterns

As your test suite grows, its organization becomes critical. A single, massive test file is unreadable and unmaintainable. A well-structured test suite is easier to navigate, debug, and expand.

The core of this Cypress best practice is the effective use of Mocha's BDD syntax (describe(), context(), it()) and hooks (beforeEach(), afterEach(), before(), after()).

  • describe() or context(): Group tests for a specific feature or component. For example, describe('Login Page', () => { ... });. it(): Define an individual test case. The description should clearly state what it's testing. For example, it('should display an error for invalid credentials', () => { ... });. beforeEach(): Use this hook to run setup code before every test in a describe block. This is perfect for repetitive actions like visiting a page or stubbing a common API call. This adheres to the Don't Repeat Yourself (DRY) principle, a cornerstone of clean code ideologies.

Here is an example of a well-structured test file following the Arrange-Act-Assert (AAA) pattern:

describe('User Profile Settings', () => {
  beforeEach(() => {
    // ARRANGE: This runs before each 'it' block
    // 1. Stub the API call to get user data
    cy.intercept('GET', '/api/user/1', { fixture: 'user.json' }).as('getUser');
    // 2. Visit the page
    cy.visit('/settings/profile/1');
    // 3. Wait for the initial data to load
    cy.wait('@getUser');
  });

  it('should display the current user information on load', () => {
    // ASSERT: Check the initial state
    cy.get('[data-cy=username-input]').should('have.value', 'testuser');
    cy.get('[data-cy=email-input]').should('have.value', '[email protected]');
  });

  it('should allow the user to update their username', () => {
    // ARRANGE: Stub the PUT request for the update
    cy.intercept('PUT', '/api/user/1', { statusCode: 200 }).as('updateUser');

    // ACT: Perform the action
    cy.get('[data-cy=username-input]').clear().type('new-username');
    cy.get('[data-cy=save-button]').click();

    // ASSERT: Check the result
    cy.wait('@updateUser');
    cy.get('[data-cy=success-notification]').should('be.visible');
  });
});

This structure makes the tests self-documenting. A new developer can read the file and immediately understand the feature's behavior. Cypress's guide on organizing tests provides further excellent examples. This structured approach not only improves maintainability but also makes debugging faster, as failures are isolated within specific, well-defined contexts.

5. Create Custom Commands for Reusable Actions

In any reasonably complex application, you'll find yourself performing the same sequence of actions over and over again. The most common example is logging in a user. Copying and pasting the cy.visit('/login'), cy.get(...), cy.type(...), and cy.click() commands into every test that requires an authenticated user is a clear violation of the DRY principle.

This is where custom commands shine. Cypress allows you to extend its cy object with your own commands, abstracting away complex, repetitive workflows into a single, readable line.

Without a Custom Command (Repetitive):

it('should view the dashboard', () => {
  cy.visit('/login');
  cy.get('[data-cy=username]').type('testuser');
  cy.get('[data-cy=password]').type('password123');
  cy.get('[data-cy=login-button]').click();
  cy.url().should('include', '/dashboard');
});

it('should go to settings', () => {
  cy.visit('/login');
  cy.get('[data-cy=username]').type('testuser');
  cy.get('[data-cy=password]').type('password123');
  cy.get('[data-cy=login-button]').click();
  cy.get('[data-cy=settings-link]').click();
  cy.url().should('include', '/settings');
});

With a Custom Command (Clean and Maintainable): First, define the command in cypress/support/commands.js:

Cypress.Commands.add('login', (username, password) => {
  cy.visit('/login');
  cy.get('[data-cy=username]').type(username);
  cy.get('[data-cy=password]').type(password);
  cy.get('[data-cy=login-button]').click();
  cy.url().should('not.include', '/login'); // Assert login was successful
});

Now, use it in your tests:

it('should view the dashboard', () => {
  cy.login('testuser', 'password123');
  cy.visit('/dashboard');
  cy.get('[data-cy=dashboard-header]').should('be.visible');
});

This Cypress best practice dramatically improves test readability and maintainability. If your login flow changes, you only need to update the custom command in one place, not in dozens of tests. While the Page Object Model (POM) is another popular pattern for abstraction, Cypress advocates for custom commands as a more direct and less boilerplate-heavy approach for creating 'App Actions'. This approach keeps the focus on user behavior (cy.login()) rather than on the page's structure. For a deeper dive, the official documentation on custom commands is an essential resource.

6. Use `baseUrl` and Environment Variables for Portability

Hardcoding configuration values like URLs, API keys, or user credentials directly into your test files is a recipe for disaster. It makes your test suite rigid, difficult to run in different environments, and a significant security risk.

A critical Cypress best practice is to externalize your configuration.

  1. baseUrl: Set your application's base URL in your cypress.config.js file. This allows you to use cy.visit('/') or cy.visit('/login') instead of cy.visit('http://localhost:3000/login'). This simple change makes it trivial to run the exact same test suite against different environments (local, staging, production) just by changing one configuration value.

    // cypress.config.js
    const { defineConfig } = require('cypress');
    
    module.exports = defineConfig({
      e2e: {
        baseUrl: 'http://localhost:3000',
      },
    });
  2. Environment Variables: For everything else—API endpoints, sensitive credentials, or environment-specific flags—use environment variables. Cypress has first-class support for them. You can set them in your config file, in a cypress.env.json file, or via the command line. This practice is a core tenet of the Twelve-Factor App methodology, which mandates strict separation of config from code.

    // cypress.config.js
    module.exports = defineConfig({
      e2e: {
        baseUrl: 'http://localhost:3000',
      },
      env: {
        API_URL: 'http://localhost:8080/api',
        LOGIN_USER: 'testuser',
        // For secrets, prefer setting via CI environment variables
        // LOGIN_PASS: process.env.CYPRESS_LOGIN_PASS
      },
    });

    In your test, you can access these with Cypress.env():

    cy.login(Cypress.env('LOGIN_USER'), Cypress.env('LOGIN_PASS'));
    cy.request(`${Cypress.env('API_URL')}/users`);

This approach, detailed in the Cypress guide on environment variables, makes your tests portable, secure, and flexible. A report on software supply chain security underscores the dangers of hardcoded secrets, making this not just a practice for maintainability, but for security as well.

7. Test Only One Scenario Per `it()` Block

It can be tempting to write a single, long it() block that tests an entire user journey from start to finish. For example, a test named it('handles the entire shopping cart flow') might log in, search for a product, add it to the cart, go to checkout, enter payment details, and confirm the order.

While this seems efficient, it's a significant anti-pattern. If that test fails, the report will simply say 'handles the entire shopping cart flow' ... FAILED. You then have to dig through the command log to figure out which of the dozen steps failed.

A much better Cypress best practice is to adhere to the Single Responsibility Principle for your tests. Each it() block should test one specific thing.

The Anti-Pattern (Long, Opaque Test):

it('should handle the entire user profile update flow', () => {
  // Logs in
  // Navigates to profile
  // Asserts initial state
  // Changes username
  // Saves
  // Asserts success message
  // Reloads page
  // Asserts new username persisted
});

The Best Practice (Focused, Clear Tests):

describe('User Profile', () => {
  beforeEach(() => {
    cy.login('testuser', 'password123');
    cy.visit('/profile');
  });

  it('should display the correct user data on load', () => {
    // ... assertion code
  });

  it('should show an error message if username is invalid', () => {
    // ... code to enter invalid data and assert error
  });

  it('should display a success message after a valid update', () => {
    // ... code to update and assert success message
  });

  it('should persist the updated username after a page reload', () => {
    // ... code to update, reload, and assert persisted data
  });
});

When a test fails in the second example, you know exactly what broke: 'should persist the updated username...' FAILED. This pinpoint accuracy is invaluable for quick debugging. This approach, as advocated by experts in Specification by Example, leads to test suites that act as living documentation. Each it block describes a specific behavior or business rule. While it may seem like more work upfront, this granularity pays massive dividends in maintainability and debugging speed, a principle echoed in classic software engineering texts like 'Clean Code'.

8. Integrate Cypress into Your CI/CD Pipeline

Tests that only run on a developer's local machine provide a false sense of security. The true power of an automated test suite is realized when it acts as a quality gate for your entire team, running automatically on every code change. Integrating Cypress into your Continuous Integration/Continuous Deployment (CI/CD) pipeline is not just a good idea; it's an essential Cypress best practice for any serious project.

By running your tests in CI, you achieve several key goals:

  • Early Feedback: Catch regressions and bugs the moment they are introduced, not days or weeks later.
  • Consistency: Tests run in a clean, consistent environment every time, eliminating 'works on my machine' problems.
  • Quality Gate: Prevent broken code from being merged into your main branch or deployed to production.

Most modern CI/CD platforms like GitHub Actions, GitLab CI, CircleCI, and Jenkins have excellent support for Cypress. Cypress even provides official Docker images and orb/action integrations to make setup easier.

Here is a basic example of a GitHub Actions workflow to run Cypress tests:

# .github/workflows/cypress.yml
name: Cypress Tests

on: [push]

jobs:
  cypress-run:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      # Install NPM dependencies, cache them correctly
      - name: Install dependencies
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      - run: npm ci

      # Run Cypress tests headlessly
      - name: Cypress run
        uses: cypress-io/github-action@v5
        with:
          start: npm start # Command to start your dev server
          wait-on: 'http://localhost:3000' # Wait for server to be ready
        env:
          CYPRESS_API_KEY: ${{ secrets.CYPRESS_API_KEY }}

This workflow checks out the code, installs dependencies, starts the application server, and then runs the Cypress suite. Integrating with the Cypress Cloud (formerly Dashboard) provides even more benefits, like parallelization, video recording of test runs, and detailed analytics. According to a McKinsey report on Developer Velocity, top-tier companies leverage toolchains and automated testing to accelerate development and improve outcomes. Making Cypress a non-negotiable step in your CI pipeline is a direct implementation of that winning strategy.

9. Leverage the Interactive Runner and Cypress Studio

Writing tests doesn't have to be a slow, manual process of coding, saving, and re-running. Cypress provides powerful tools designed to accelerate the test development workflow. Failing to use them is leaving a significant amount of productivity on the table.

The Interactive Test Runner: This is the heart of the Cypress development experience. When you run cypress open, you get a visual interface that shows your application on one side and the command log on the other. This isn't just for viewing; it's interactive. You can click on any command in the log to see a DOM snapshot of the application at that exact moment—a feature Cypress calls 'time travel'. This makes debugging failed tests incredibly fast, as you can inspect the DOM, console, and network requests before and after the failing command. Visual tutorials and guides often showcase this as the primary reason developers love Cypress.

Cypress Studio: For an even faster start, Cypress Studio allows you to record your interactions with the application and automatically generate the corresponding test code.

  1. Start in the interactive runner.
  2. Click 'Add commands to test' on an existing it block or 'Create new test' for a new one.
  3. Interact with your application: click buttons, type in fields, etc.
  4. Cypress records these actions and generates the code.
  5. Save the test.

It's crucial to view the generated code as a starting point. As a Cypress best practice, you should always refactor the code generated by the Studio. For example, it might generate a selector based on a CSS class. You should immediately change this to your data-cy selector. Despite this need for refactoring, the Studio, as highlighted in the official documentation, can save a tremendous amount of time by scaffolding the basic structure and commands of a test, letting you focus on assertions and refinement. A TechCrunch article on AI in development discusses how such code-generation tools boost productivity, and Cypress Studio fits perfectly into this modern development paradigm.

10. Treat Your Test Suite as First-Class Code

Perhaps the most important, overarching Cypress best practice is a shift in mindset: your test code is not second-class code. It deserves the same level of care, review, and refactoring as your application code. A test suite that is neglected will inevitably become a 'slum'—a source of technical debt that is slow, flaky, and untrustworthy. When the team loses trust in the tests, they start ignoring failures, and the entire purpose of automation is lost.

Treating tests as a first-class citizen means:

  • Code Reviews: Test code should be part of your standard pull request and code review process. Are selectors resilient? Is the logic clear? Are there opportunities for custom commands?
  • Regular Refactoring: Just like application code, test code can 'rot'. As features change, some tests become obsolete or less effective. Set aside time to refactor brittle tests, improve abstractions, and speed up slow ones.
  • Deleting Tests: Don't be afraid to delete tests that are no longer providing value. A test that is testing an obsolete feature or is perpetually flaky and ignored is worse than no test at all—it's noise.
  • Performance Monitoring: Keep an eye on the overall runtime of your test suite. A slow suite can bog down your CI/CD pipeline. Use features like Cypress Cloud's parallelization to manage run times.

This principle of maintaining code quality is not new. Martin Fowler's seminal work on refactoring applies just as much to test code as it does to production code. Investing in the health of your test suite is an investment in the quality and velocity of your entire development process. A study from MIT Sloan on technical debt highlights how neglecting internal quality can have severe long-term consequences on business outcomes. Your test suite is a critical asset; treat it that way.

Cypress provides an incredibly powerful platform for building a modern, effective end-to-end testing strategy. However, the framework alone cannot guarantee success. The path to a stable, maintainable, and reliable test suite is paved with discipline and adherence to proven principles. By embracing the ten Cypress best practices outlined in this guide—from using resilient selectors and isolating tests with cy.intercept() to structuring code logically and integrating with CI/CD—you are making a conscious investment in quality. These practices are not just about writing tests; they are about building a sustainable testing culture. They transform your test suite from a fragile liability into a robust asset that accelerates development, builds team confidence, and ensures you deliver a better product to your users. Start by implementing one or two of these practices in your next test, and watch as your test suite evolves into the powerful safety net it was always meant to be.

What today's top teams are saying about Momentic:

"Momentic makes it 3x faster for our team to write and maintain end to end tests."

- Alex, CTO, GPTZero

"Works for us in prod, super great UX, and incredible velocity and delivery."

- Aditya, CTO, Best Parents

"…it was done running in 14 min, without me needing to do a thing during that time."

- Mike, Eng Manager, Runway

Increase velocity with reliable AI testing.

Run stable, dev-owned tests on every push. No QA bottlenecks.

Ship it

FAQs

Momentic tests are much more reliable than Playwright or Cypress tests because they are not affected by changes in the DOM.

Our customers often build their first tests within five minutes. It's very easy to build tests using the low-code editor. You can also record your actions and turn them into a fully working automated test.

Not even a little bit. As long as you can clearly describe what you want to test, Momentic can get it done.

Yes. You can use Momentic's CLI to run tests anywhere. We support any CI provider that can run Node.js.

Mobile and desktop support is on our roadmap, but we don't have a specific release date yet.

We currently support Chromium and Chrome browsers for tests. Safari and Firefox support is on our roadmap, but we don't have a specific release date yet.

© 2025 Momentic, Inc.
All rights reserved.