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:
- A Beginner's Guide to Cypress: End-to-End Testing Made Easy
- Understanding Cypress Basics: Core Features and Syntax Explained
- Cypress Stubs, Spies, and Clocks: Take Control of Your Tests
- 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-cyattribute 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-buttonstays 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-inputlogin-button,reset-buttonsuccess-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 |
| Never | Breaks when any element is added or moved |
Tag name |
| Never | Matches every button on the page |
CSS class |
| Avoid | Changes with styling and design updates |
ID |
| Sometimes | Fairly stable, but often used by CSS or JavaScript too |
Text content |
| Sometimes | Good when the text itself matters, breaks when wording changes |
data-cy |
| 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(), andcy.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 |
|
|
An element to disappear |
|
|
Text to change |
|
|
A network request to finish |
|
|
A timer in your app |
|
|
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 |
| Wait for an element, request, or timer instead |
Checking inside |
| Use |
Real network calls | Tests depend on a live, busy API | Mock responses with |
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 |
| Use |
Real timers and dates | A test only passes before midnight | Freeze time with |
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-cyattribute, or withcy.contains()only when the text matters. - No
cy.wait()with a number anywhere. - Every check uses
.should(), notexpect()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!
