Cypress tests can feel like walking a tightrope over a pit of snakes, especially when they start failing intermittently for reasons that are impossible to pin down. The culprit isn’t usually your test logic, but rather the inherent asynchronous nature of the web and the flaky selectors you might be using to interact with it.

The Problem: When Tests Fail for No Apparent Reason

The core issue is that your Cypress tests are trying to interact with elements on a web page that haven’t finished rendering or updating yet. The browser is a dynamic beast, and your tests need to be patient and precise. When a test fails, it’s often because Cypress tried to click a button that wasn’t visible, type into an input that hadn’t appeared, or assert on text that was still loading. This leads to false negatives, where your code is fine, but your test suite claims it’s broken, eroding confidence and wasting developer time.

Solution 1: Embrace Cypress Retries

Cypress has a built-in retry mechanism that’s your first line of defense against flakiness. By default, Cypress retries commands that fail due to elements not being in the DOM or not being visible, up to 10 times with a 100ms delay between retries. This is often enough to bridge the gap for minor timing issues.

Diagnosis: You’ll see errors like CypressError: Timed out retrying: cy.click() failed because the element: <button class="btn btn-primary">Submit</button> did not become visible after the previous command.

Common Causes & Fixes:

  1. Network Latency/Slow API Responses: The element your test is waiting for is dependent on data fetched from a slow API.

    • Diagnosis: Check your Cypress dashboard or browser’s network tab for slow requests.
    • Fix: Stub or mock your API responses. For example, in your cypress/support/commands.js:
      Cypress.Commands.add('stubApiData', () => {
        cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers');
      });
      
      Then, in your test:
      it('should display user list', () => {
        cy.stubApiData();
        cy.visit('/users');
        cy.wait('@getUsers'); // Wait for the stubbed API call
        cy.get('.user-list').should('be.visible');
      });
      
      Why it works: Mocking the API response provides the data instantly, ensuring the UI elements dependent on it are available immediately, bypassing network delays.
  2. Animations or Transitions: UI elements might be present but not yet visible due to CSS animations (e.g., fading in, sliding in).

    • Diagnosis: Observe the page load in your Cypress test runner. Do elements animate into view?
    • Fix: Disable animations in your test environment. You can do this via CSS or by overriding browser settings. For CSS:
      /* In your app's CSS, conditionally applied for Cypress */
      body.cy-test-runner * {
        transition: none !important;
        animation: none !important;
      }
      
      Or in your cypress/support/e2e.js:
      beforeEach(() => {
        cy.window().then(win => {
          win.document.body.classList.add('cy-test-runner');
        });
      });
      
      Why it works: By removing transitions and animations, elements appear instantly, making them available for interaction sooner.
  3. Client-Side Rendering Delays: JavaScript frameworks (React, Vue, Angular) might take time to render components and attach event listeners after the initial HTML is parsed.

    • Diagnosis: Look for cy.get() or cy.contains() failing on elements that should be there based on the HTML.
    • Fix: Use cy.wait() with a specific alias if you know a particular network request triggers the rendering, or use cy.get('selector', { timeout: 10000 }) to extend the default retry timeout for specific commands if stubbing isn’t feasible.
      it('loads dynamic content', () => {
        cy.visit('/');
        // Wait for a specific element that signals the app is ready
        cy.get('.app-ready-indicator', { timeout: 15000 }).should('exist');
        cy.get('h1').should('contain', 'Welcome');
      });
      
      Why it works: Explicitly waiting for a known indicator of readiness or increasing the timeout gives the JavaScript sufficient time to execute and render the DOM.
  4. Third-Party Scripts/Iframes: External scripts or content loaded in iframes can introduce unpredictable delays.

    • Diagnosis: Check the network tab for requests to third-party domains. See if elements are inside an iframe.
    • Fix: If the iframe content is critical and you control it, ensure it loads quickly. If it’s external and non-critical, consider ignoring it or stubbing its behavior. For iframes:
      it('interacts with iframe content', () => {
        cy.visit('/page-with-iframe');
        cy.get('iframe[src="/my-widget"]').within(() => {
          cy.get('button').click();
        });
      });
      
      Why it works: Cypress can directly interact with iframe content once the iframe itself has loaded and its contents are accessible.
  5. Race Conditions in Test Setup: If your beforeEach or before blocks perform actions that affect the DOM, they might not complete before your test case starts, leading to timing issues.

    • Diagnosis: Step through your beforeEach and test case in the Cypress Test Runner. See if the DOM is in the expected state at the start of the test.
    • Fix: Ensure all setup actions in beforeEach are complete and stable before the test begins. If beforeEach involves async operations, use cy.wrap() or cy.then() to chain them correctly.
      beforeEach(() => {
        cy.login('testuser', 'password'); // Assuming login returns a promise or uses cy commands
        cy.url().should('include', '/dashboard'); // Ensure login is complete
      });
      
      Why it works: The should('include', '/dashboard') assertion acts as a synchronization point, ensuring the login process (and any associated DOM changes) has fully completed before the test proceeds.

Solution 2: Robust Selectors

Even with retries, if your selectors are too brittle, they’ll break. A selector is brittle if it relies on dynamically generated IDs, specific class names that might change, or the exact text content that could be localized or altered.

Diagnosis: Your tests fail consistently on the same element, even after retries, and the element visually appears correct. The error message might be cy.get() failed because the element: <div class="generated-id-abc123">...</div> could not be found.

Common Causes & Fixes:

  1. Dynamically Generated IDs/Classes: id="user-12345" or class="button-primary-xyz" that change on every page load or build.

    • Fix: Use data-* attributes. These are attributes specifically intended for testing and should not change based on styling or dynamic content.
      <!-- Instead of -->
      <button class="btn btn-danger mt-2">Delete</button>
      
      <!-- Use -->
      <button data-cy="delete-button" class="btn btn-danger mt-2">Delete</button>
      
      In your Cypress test:
      cy.get('[data-cy="delete-button"]').click();
      
      Why it works: data-* attributes are stable, application-defined hooks that are unlikely to be altered by CSS or JavaScript logic unrelated to their functional identity.
  2. Overly Specific CSS Selectors: Relying on a long chain of parent/child selectors (e.g., div > div > span.title).

    • Fix: Target the element directly using a unique attribute or a more general, stable class.
      // Instead of
      cy.get('div.container > div.content > span.product-title').should('contain', 'Awesome Gadget');
      
      // Use a data-cy attribute on the title itself
      cy.get('[data-cy="product-title"]').should('contain', 'Awesome Gadget');
      // Or if a stable class exists
      cy.get('span.product-title').should('contain', 'Awesome Gadget');
      
      Why it works: Direct targeting reduces the fragility of the selector. If any intermediate element changes, the overly specific selector breaks, whereas a direct, stable selector remains valid.
  3. Relying on Text Content: Using cy.contains('Submit') when the text might change due to localization or minor wording updates.

    • Fix: Prefer selecting by data-* attribute and then asserting on text if necessary, or use data-* attributes that imply the element’s purpose.
      // Instead of
      cy.contains('Save Changes').click();
      
      // Use a data-cy attribute for the button
      cy.get('[data-cy="save-button"]').click();
      // Then, if you need to assert the text:
      cy.get('[data-cy="save-button"]').should('have.text', 'Save Changes');
      
      Why it works: Separating the selection mechanism from the text content makes the test resilient to text variations while still allowing for text verification.
  4. Implicit DOM Structure: Selecting elements based on their position relative to others (e.g., the third <tr> in a table).

    • Fix: Add data-* attributes to the specific elements you need to interact with.
      // Instead of
      cy.get('table tbody tr').eq(2).find('td').eq(1).should('contain', 'Special Value');
      
      // Add a data-cy attribute to the cell
      cy.get('table tbody tr').eq(2).find('[data-cy="special-value-cell"]').should('contain', 'Special Value');
      
      Why it works: Explicitly identifying the target element with a data-* attribute removes the dependency on its ordinal position, which is highly susceptible to changes in table structure.

By combining Cypress’s retry capabilities with a disciplined approach to selector strategy, you can transform your flaky tests into a reliable foundation for your application’s quality.

The next hurdle you’ll likely face is managing test data effectively across different test scenarios.

Want structured learning?

Take the full Cypress course →