Bhavik Bamania

Programming

Why Your Cypress Tests Break: Selectors and Test Design Best Practices

Bhavik Bamania

15 min read

Share On:

Copy linkShare on FacebookShare on LinkedInShare on ThreadsShare on X
why-your-cypress-tests-break-selectors-and-test-design-best-practices
Why Your Cypress Tests Break: Selectors and Test Design Best Practices

Why do Cypress tests break when the app works fine? Learn to write stable tests with data-cy selectors, retry-ability, and smarter waiting, and finally fix those flaky tests, step by step.

Introduction

You write a test. It passes. A week later, a designer renames a CSS class, and suddenly ten tests turn red, even though the app works perfectly. Or worse, a test passes on your laptop but fails randomly on the build server. Sound familiar? In this article, we'll learn why tests break like this, and how to write tests that stay green for the right reasons.

This article is part 5 of our Cypress Tutorial Series, designed to help beginners master Cypress step by step. If you're new here, we recommend reading the previous articles in order:

  1. A Beginner's Guide to Cypress: End-to-End Testing Made Easy
  2. Understanding Cypress Basics: Core Features and Syntax Explained
  3. Cypress Stubs, Spies, and Clocks: Take Control of Your Tests
  4. Mocking Network Requests in Cypress with cy.intercept()

We'll keep building on the same LoginForm demo project from the previous parts.

Why Do Tests Break?

A test should fail for only one reason: the feature is broken. In practice, tests break for many other reasons too. Almost all of them come down to two problems:

  • Fragile selectors: the test finds elements using details that change often, like CSS classes or exact styling.
  • Bad timing: the test checks something before the app is ready, or waits a fixed amount of time and hopes for the best.

Tests that fail for the wrong reasons are dangerous. After a few false alarms, people stop trusting them and start ignoring red results. By the end of this article, you'll know how to avoid both problems.

A Fragile Test in Action

The best way to understand fragile selectors is to watch one break. Let's do it on purpose.

Step 1: Add Some Styling Classes

In real projects, buttons usually have CSS classes for styling. Open src/components/LoginForm.tsx and add a className to both buttons:

<button type="submit" className="btn btn-primary">
  {isLoading ? 'Logging in...' : 'Login'}
</button>
<button type="button" id="reset" className="btn btn-secondary" onClick={handleReset}>
  Reset
</button>

Step 2: Write a Test Using the Class

Create a new file at cypress/e2e/selectors.cy.ts and add this test:

describe('Fragile Selectors', () => {
  it('should log in (fragile version)', () => {
    cy.intercept('POST', '/api/login', { fixture: 'login-success.json' }).as('login');
    cy.visit('/');

    cy.get('input').first().type('testuser'); // Fragile: depends on order
    cy.get('input').eq(1).type('password123'); // Fragile: depends on order
    cy.get('.btn-primary').click(); // Fragile: depends on a styling class

    cy.contains('Logged in as testuser').should('be.visible');
  });
});

Run it. It passes. Everything looks fine.

Step 3: Break It Without Breaking the App

Now imagine your team moves to a new design system, and btn-primary becomes button--primary. Change the class in LoginForm.tsx:

<button type="submit" className="btn button--primary">

Run the test again. It fails with an error saying Cypress couldn't find .btn-primary. Open the app in the browser and try logging in yourself. It works perfectly. The app is fine. Only the test is broken.

Now try one more change. Add an email field above the username field:

<input id="email" type="email" placeholder="Email" />

Run the test again. Surprise: it still passes! But look closely at the runner. cy.get('input').first() typed "testuser" into the email field, and "password123" went into the username field. The test passes only because our fake server always answers "testuser". This is even worse than a failing test. It's a test that lies to you.

Remove the email field and change the class back to btn-primary before moving on.

What Went Wrong?

Our test depended on things that exist for other reasons:

  • CSS classes exist for styling. Designers change them freely.
  • Element order exists for layout. It changes whenever someone adds a field.

Neither of these has anything to do with what the test is checking. What we need is a selector that exists only for testing, so nobody changes it by accident.

Using data-cy Attributes

The fix is simple: give each important element a special attribute that exists only for tests. The Cypress team recommends data-cy. You'll also see data-test and data-testid in other projects. They all work the same way.

<button data-cy="login-button">Login</button>

In Cypress, you select it with an attribute selector:

cy.get('[data-cy="login-button"]').click();

Why does this work so well?

  • It has one job. Nobody adds or renames a data-cy attribute for styling or layout reasons.
  • It's a clear signal. When a developer sees data-cy, they know a test depends on it and won't remove it casually.
  • It describes meaning, not position. login-button stays correct even if the button moves, changes color, or gets new classes.

data-cy or data-testid?

If your project only uses Cypress, pick data-cy. If your team also uses React Testing Library with Jest or Vitest, data-testid is a good choice, because Testing Library supports it out of the box with getByTestId(). That way, both tools share one attribute.

The most important rule is to pick one and use it everywhere. Mixing data-cy, data-test, and data-testid in one project only creates confusion. In this series, we'll use data-cy.

Step 1: Add data-cy Attributes to the LoginForm

Open src/components/LoginForm.tsx and update the return block. Only the JSX changes. Everything above it stays the same.

  return (
    <form data-cy="login-form" onSubmit={handleSubmit}>
      <input
        id="username"
        data-cy="username-input"
        type="text"
        placeholder="Username"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      <input
        id="password"
        data-cy="password-input"
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit" className="btn btn-primary" data-cy="login-button">
        {isLoading ? 'Logging in...' : 'Login'}
      </button>
      <button
        type="button"
        id="reset"
        className="btn btn-secondary"
        data-cy="reset-button"
        onClick={handleReset}
      >
        Reset
      </button>
      {message && (
        <p id="message" data-cy="success-message">
          {message}
        </p>
      )}
      {error && (
        <p id="error" data-cy="error-message">
          {error}
        </p>
      )}
    </form>
  );

Note: We kept the old id attributes, so the tests from parts 2, 3, and 4 still pass. In your own projects, you can move those tests to data-cy one file at a time.

Step 2: Name Your Attributes Consistently

Good names make tests readable. A simple pattern is what it is + what kind of element it is:

  • username-input, password-input
  • login-button, reset-button
  • success-message, error-message

Use lowercase words joined with hyphens, and avoid names that describe looks, like green-button or top-input.

Step 3: Refactor the Fragile Test

Now let's rewrite our fragile test with data-cy. Replace the contents of cypress/e2e/selectors.cy.ts:

describe('Robust Selectors', () => {
  it('should log in', () => {
    cy.intercept('POST', '/api/login', { fixture: 'login-success.json' }).as('login');
    cy.visit('/');

    cy.get('[data-cy="username-input"]').type('testuser');
    cy.get('[data-cy="password-input"]').type('password123');
    cy.get('[data-cy="login-button"]').click();

    cy.wait('@login');
    cy.get('[data-cy="success-message"]').should('have.text', 'Logged in as testuser');
  });
});

Now repeat the experiment from earlier. Rename the class to button--primary and add the email field. Run the test. It still passes, and this time for the right reason. The test finds exactly the right elements, no matter how the design or layout changes. Undo both changes when you're done.

Which Selector Should I Use?

Here's every common selector type, from worst to best:

Selector

Example

Verdict

Why

Element order

cy.get('input').eq(1)

Never

Breaks when any element is added or moved

Tag name

cy.get('button')

Never

Matches every button on the page

CSS class

cy.get('.btn-primary')

Avoid

Changes with styling and design updates

ID

cy.get('#username')

Sometimes

Fairly stable, but often used by CSS or JavaScript too

Text content

cy.contains('Login')

Sometimes

Good when the text itself matters, breaks when wording changes

data-cy

cy.get('[data-cy="login-button"]')

Best

Exists only for tests, so nobody changes it by accident

When Is cy.contains() Fine?

cy.contains() is still useful. Ask yourself one question: if this text changed, should the test fail?

  • For an error message like "Invalid username or password", yes. The exact wording is part of the feature, so checking the text makes sense.
  • For a button labeled "Login", probably not. If marketing changes it to "Sign in", the feature still works, and the test shouldn't break.

A great pattern is to combine both. Find the element with data-cy, then check its text with an assertion:

cy.get('[data-cy="error-message"]').should('have.text', 'Invalid username or password');

If the text changes, you get a clear error that says exactly what's different, instead of a vague "element not found".

Tip: Not sure which selector to use? Open the Cypress runner and click the Selector Playground icon (the crosshair at the top of the app preview). Then click any element in your app. Cypress suggests a selector, and it prefers data-cy when one exists.

Understanding Retry-ability

Good selectors solve the first problem. Now let's look at timing. To write stable tests, you need to understand one of Cypress's best features: retry-ability.

How Cypress Retries

Web apps don't update instantly. A message appears after a server replies. A list loads after a second. If Cypress checked everything only once, most tests would fail.

So Cypress keeps trying. When you write this:

cy.get('[data-cy="success-message"]').should('have.text', 'Logged in as testuser');

Cypress doesn't just look once. It checks again and again, about every few milliseconds, until either the assertion passes or 4 seconds go by. Only then does it fail the test.

Think of it like waiting for a friend at a cafe. You don't look at the door once and leave. You keep glancing at it until they arrive, or until you've waited long enough to give up.

Step 1: See Retry-ability in Action

Add this test to cypress/e2e/selectors.cy.ts, inside the describe block:

  it('should wait for a slow server automatically', () => {
    cy.intercept('POST', '/api/login', {
      fixture: 'login-success.json',
      delay: 2000, // The server takes 2 seconds
    }).as('login');
    cy.visit('/');

    cy.get('[data-cy="username-input"]').type('testuser');
    cy.get('[data-cy="password-input"]').type('password123');
    cy.get('[data-cy="login-button"]').click();

    // No waiting needed: Cypress retries until the message appears
    cy.get('[data-cy="success-message"]').should('have.text', 'Logged in as testuser');
  });

Run it. The test passes, even though the message only appears after 2 seconds. We never told Cypress to wait. It retried on its own.

Step 2: Hit the Timeout

Now change the delay to 6000 (6 seconds) and run the test again. This time it fails, because 6 seconds is longer than the default 4-second limit.

If an element genuinely takes longer, give that one command a longer timeout:

cy.get('[data-cy="success-message"]', { timeout: 10000 }).should(
  'have.text',
  'Logged in as testuser'
);

Run it again, and it passes. Change the delay back to 2000 when you're done.

Tip: Only increase the timeout for the specific commands that need it. Raising the global timeout for every command hides real slowness in your app and makes failing tests take longer to report.

What Does and Doesn't Retry?

This is where many beginners get stuck, so read this part carefully:

  • Queries retry. Commands that find elements, like cy.get(), .find(), and cy.contains(), keep looking until they find a match.
  • Assertions retry. .should() keeps re-running the query before it, until the assertion passes.
  • Actions don't retry. Commands like .click() and .type() wait for the element to be ready, but they run only once.
  • .then() doesn't retry. The code inside it runs exactly once.

Step 3: Avoid the .then() Trap

That last point causes a lot of flaky tests. Look at this example:

// BAD: the check inside .then() runs only once
cy.get('[data-cy="login-button"]').then(($button) => {
  expect($button.text()).to.equal('Logging in...');
});

cy.get() retries until the button exists. But the button exists from the start, so .then() runs immediately. If the text hasn't changed yet at that exact moment, the test fails. Run it 10 times, and it might pass 7 and fail 3. That's a flaky test.

Here's the fix:

// GOOD: .should() retries until the text matches
cy.get('[data-cy="login-button"]').should('have.text', 'Logging in...');

If you need several custom checks, pass a function to .should(). Cypress retries the whole function until every check inside passes:

// GOOD: the whole callback is retried
cy.get('[data-cy="login-button"]').should(($button) => {
  expect($button.text()).to.equal('Logging in...');
  expect($button).to.have.class('btn-primary');
});

Note: Use .then() when you need to do something with a value, like save it for later. Use .should() when you want to check something. That one rule will save you from most timing problems.

Stop Using cy.wait(ms)

When a test fails because something wasn't ready, the quickest "fix" is tempting:

cy.get('[data-cy="login-button"]').click();
cy.wait(3000); // Wait 3 seconds, just to be safe
cy.get('[data-cy="success-message"]').should('be.visible');

This is one of the most common mistakes in Cypress. It causes two problems at once:

  • It's too slow when the app is fast. If the server replies in 200 milliseconds, you still waste 2.8 seconds. Add that up across 200 tests, and your test suite takes minutes longer than it should.
  • It's too short when the app is slow. On a busy build server, the reply might take 3.5 seconds. Your test fails anyway, and now it fails only sometimes, which is the worst kind of failure.

The number you pick is always a guess. It's either too long or too short, never just right.

What to Use Instead

Instead of waiting for time, wait for the thing you actually care about:

You're waiting for...

Don't write

Write this instead

An element to appear

cy.wait(3000)

cy.get('[data-cy="success-message"]').should('be.visible')

An element to disappear

cy.wait(3000)

cy.get('[data-cy="error-message"]').should('not.exist')

Text to change

cy.wait(2000)

cy.get('[data-cy="login-button"]').should('have.text', 'Login')

A network request to finish

cy.wait(5000)

cy.wait('@login') (from part 4)

A timer in your app

cy.wait(3000)

cy.clock() + cy.tick(3000) (from part 3)

Each of these finishes the moment the condition is true. Fast apps get fast tests, and slow apps still get reliable ones.

Step 1: Refactor a Test with cy.wait(ms)

Here's a test full of fixed waits:

// BAD: three guesses
it('should show an error for invalid credentials', () => {
  cy.intercept('POST', '/api/login', {
    statusCode: 401,
    body: { message: 'Invalid username or password' },
  });
  cy.visit('/');
  cy.wait(1000);
  cy.get('[data-cy="username-input"]').type('testuser');
  cy.get('[data-cy="password-input"]').type('wrong');
  cy.get('[data-cy="login-button"]').click();
  cy.wait(2000);
  cy.get('[data-cy="error-message"]').should('be.visible');
});

And here's the same test without a single guess:

// GOOD: waits for real events only
it('should show an error for invalid credentials', () => {
  cy.intercept('POST', '/api/login', {
    statusCode: 401,
    body: { message: 'Invalid username or password' },
  }).as('login');
  cy.visit('/'); // cy.visit already waits for the page to load

  cy.get('[data-cy="username-input"]').type('testuser');
  cy.get('[data-cy="password-input"]').type('wrong');
  cy.get('[data-cy="login-button"]').click();

  cy.wait('@login'); // Wait for the request, not for time
  cy.get('[data-cy="error-message"]').should('have.text', 'Invalid username or password');
});

Notice we didn't need a wait after cy.visit() at all. cy.visit() waits for the page to load, and cy.get() retries until the input appears.

Note: Is cy.wait(ms) ever okay? Very rarely. A common example is a third-party widget that gives you nothing to wait on. If you do use it, leave a comment explaining why, so the next person doesn't copy it everywhere.

Fixing Flaky Tests

A flaky test is a test that sometimes passes and sometimes fails, without any change to the code. Flaky tests are worse than no tests at all, because they teach your team to ignore red results.

The good news is that flaky tests almost always come from a short list of causes.

The Usual Suspects

Cause

Example

Fix

Fixed waits

cy.wait(2000) before an assertion

Wait for an element, request, or timer instead

Checking inside .then()

expect() inside .then() on changing content

Use .should() so the check retries

Real network calls

Tests depend on a live, busy API

Mock responses with cy.intercept() (part 4)

Tests that depend on each other

Test 2 only works if test 1 ran first

Make every test set up its own state

Fragile selectors

.eq(2) picks a different element after a layout change

Use data-cy attributes

Real timers and dates

A test only passes before midnight

Freeze time with cy.clock() (part 3)

Step 1: Keep Tests Independent

This one deserves an example. Look at these two tests:

// BAD: test 2 depends on test 1
describe('Dependent tests', () => {
  it('types the username', () => {
    cy.visit('/');
    cy.get('[data-cy="username-input"]').type('testuser');
  });

  it('checks the username', () => {
    // Fails: Cypress starts each test with a fresh, blank page
    cy.get('[data-cy="username-input"]').should('have.value', 'testuser');
  });
});

As we learned in part 2, Cypress resets the page between tests, so test 2 has nothing to check. Even if this passed somehow, running test 2 on its own, or in a different order, would break it.

The fix is to make each test complete on its own. Put shared setup in beforeEach:

// GOOD: each test sets up everything it needs
describe('Independent tests', () => {
  beforeEach(() => {
    cy.visit('/');
    cy.get('[data-cy="username-input"]').type('testuser');
  });

  it('shows the typed username', () => {
    cy.get('[data-cy="username-input"]').should('have.value', 'testuser');
  });

  it('clears the username on reset', () => {
    cy.window().then((win) => {
      cy.stub(win, 'confirm').returns(true);
    });
    cy.get('[data-cy="reset-button"]').click();
    cy.get('[data-cy="username-input"]').should('have.value', '');
  });
});

Tip: A quick way to check independence is to add .only to a single test, like it.only(...). Cypress then runs just that test. If it fails alone but passes with the others, it depends on another test. Remember to remove .only afterwards.

Step 2: Use Test Retries as a Safety Net

Cypress can automatically re-run a failed test before reporting it as failed. Turn it on in cypress.config.ts:

import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:5173',
  },
  retries: {
    runMode: 2, // Retry up to 2 times in headless runs (cypress run)
    openMode: 0, // No retries while you're developing (cypress open)
  },
});

We keep openMode at 0 on purpose. While you're writing tests, you want to see every failure immediately.

Note: Retries are a safety net, not a fix. If a test only passes on its second try, it's still flaky. Find the cause from the table above and fix it. Otherwise, retries just hide the problem until it gets bigger.

Practical Example: The Complete Test File

Combining everything, here's the full cypress/e2e/selectors.cy.ts. It uses data-cy everywhere, never waits for a fixed time, and every test stands on its own:

describe('Robust Selectors', () => {
  it('should log in', () => {
    cy.intercept('POST', '/api/login', { fixture: 'login-success.json' }).as('login');
    cy.visit('/');

    cy.get('[data-cy="username-input"]').type('testuser');
    cy.get('[data-cy="password-input"]').type('password123');
    cy.get('[data-cy="login-button"]').click();

    cy.wait('@login');
    cy.get('[data-cy="success-message"]').should('have.text', 'Logged in as testuser');
  });

  it('should wait for a slow server automatically', () => {
    cy.intercept('POST', '/api/login', {
      fixture: 'login-success.json',
      delay: 2000,
    }).as('login');
    cy.visit('/');

    cy.get('[data-cy="username-input"]').type('testuser');
    cy.get('[data-cy="password-input"]').type('password123');
    cy.get('[data-cy="login-button"]').click();

    cy.get('[data-cy="login-button"]').should('have.text', 'Logging in...');
    cy.get('[data-cy="success-message"]').should('have.text', 'Logged in as testuser');
    cy.get('[data-cy="login-button"]').should('have.text', 'Login');
  });

  it('should show an error for invalid credentials', () => {
    cy.intercept('POST', '/api/login', {
      statusCode: 401,
      body: { message: 'Invalid username or password' },
    }).as('login');
    cy.visit('/');

    cy.get('[data-cy="username-input"]').type('testuser');
    cy.get('[data-cy="password-input"]').type('wrong');
    cy.get('[data-cy="login-button"]').click();

    cy.wait('@login');
    cy.get('[data-cy="error-message"]').should('have.text', 'Invalid username or password');
    cy.get('[data-cy="success-message"]').should('not.exist');
  });
});

describe('Independent tests', () => {
  beforeEach(() => {
    cy.visit('/');
    cy.get('[data-cy="username-input"]').type('testuser');
  });

  it('shows the typed username', () => {
    cy.get('[data-cy="username-input"]').should('have.value', 'testuser');
  });

  it('clears the username on reset', () => {
    cy.window().then((win) => {
      cy.stub(win, 'confirm').returns(true);
    });
    cy.get('[data-cy="reset-button"]').click();
    cy.get('[data-cy="username-input"]').should('have.value', '');
  });
});

Run it, and you should see all five tests pass. Run the files from parts 2, 3, and 4 too. Because we kept the id attributes, they should all still be green.

Your Stable Test Checklist

Before you commit a new test, run through this list:

  • Every element is selected with a data-cy attribute, or with cy.contains() only when the text matters.
  • No cy.wait() with a number anywhere.
  • Every check uses .should(), not expect() inside .then().
  • Every network request the test depends on is mocked with cy.intercept().
  • The test passes when run alone with it.only.
  • The test fails when you break the feature on purpose (the habit from part 3).

Conclusion

In this article, we learned why tests break for the wrong reasons, and how to stop it. We replaced fragile CSS classes and element order with data-cy attributes, learned how Cypress retries queries and assertions, avoided the .then() trap, replaced every cy.wait(ms) with a real event, and tracked down the usual causes of flaky tests.

You may have noticed one thing: we typed cy.get('[data-cy="..."]') and the same three login steps over and over again. In the next article, we'll fix that with custom commands. We'll build helpers like cy.getByCy('login-button') and cy.login(), with full TypeScript support, so your tests become shorter and easier to read. Stay tuned, and happy testing!

CypressTypeScriptTSXEnd-to-End TestingCypress TutorialTest Best Practices

Share On:

Copy linkShare on FacebookShare on LinkedInShare on ThreadsShare on X

Written by Bhavik Bamania

Bhavik Bamania is a software engineer and writer exploring frontend engineering, web architecture, technology, history, psychology, astrology, and culture.

This site uses cookies for basic analytics (page views, no ads). You can accept or decline.