Cypress
Cypress Best Practices for Maintainable End-to-End Tests
The Cypress habits that keep a suite fast and readable, checked against the official best-practices guide.

Most lists of Cypress best practices tell you what to avoid and stop there. This guide shows each practice as a before-and-after spec, run against a small demo orders app on Cypress 16.2 in Chrome and Electron. When a "before" spec fails, you see the real error message. The "after" specs pass on every run. Everything is checked against the official Cypress best practices guide, as of October 2026.
Cypress 16 changed one thing many older articles still get wrong: Cypress.env() is gone. The config section below shows what replaced it.
TL;DR: Cypress best practices at a glance
- Select by
data-cyfor anything a redesign might move. Usecy.contains()orcy.getByRole()when the visible text or role is part of what you're testing. - Log in with
cy.session()andcy.request(), not through the form in every test. Keep one test that uses the real login form. - Wait for requests, not time. Register
cy.intercept()before the action, thency.wait('@alias').cy.wait(500)is a guess. - Every test must pass on its own. Reset data in
beforeEach, never inafterEach, and never rely on what an earlier test created. - Custom commands suit setup that every spec needs. Page objects suit pages that many specs drive. Everything else is a plain function.
- One config, several environments. Pick the base URL with
TEST_ENV. Read secrets withcy.env()and public settings withCypress.expose().
The demo app
The app is about 100 lines of Node with no dependencies: a login page and an orders page. The orders list loads after 400 ms, a form adds an order, and a notes field autosaves one second after you stop typing. A POST /api/test/reset route restores two seeded orders. A "redesign" mode changes the CSS classes and adds a banner as the first list item, without changing what the page does.
Selectors: data-cy first, text and roles when the words matter
Here is a typical "before" spec. It logs in through the form, then finds elements by class, ID and position:
// cypress/e2e/before/selectors.cy.ts
describe('orders (before)', () => {
beforeEach(() => {
cy.visit('/login');
cy.get('input[type=email]').type('ada@example.test');
cy.get('input[type=password]').type('correct-horse');
cy.get('.btn.btn-primary').click();
});
it('shows the second order', () => {
cy.get('#orders li:nth-child(2)').should('have.text', '#1002 Wool Socks');
});
it('adds an order', () => {
cy.get('form#new-order input').type('Rain Jacket');
cy.get('form#new-order .btn.btn-primary').click();
cy.get('#orders li').last().should('have.text', '#1003 Rain Jacket');
});
});It passes against today's markup. Against the redesign, both tests fail:
1) orders (before)
shows the second order:
Timed out retrying after 4000ms
+ expected - actual
-'#1001 Trail Mug'
+'#1002 Wool Socks'
2) orders (before)
adds an order:
AssertionError: Timed out retrying after 4000ms: Expected to find element: `form#new-order .btn.btn-primary`, but never found it.Nothing a user cares about changed. The banner shifted nth-child(2), and the button got new classes. The second failure is the honest one: the element is gone, and Cypress says so. The first is worse, because the selector still matched an element, just the wrong one.
The Cypress guide ranks selectors from "never" to "always". Generic tags and styling classes sit at the bottom. IDs and name attributes are allowed sparingly. Text and roles are fine when the text is meant to be stable. A dedicated data-* attribute is the top choice, because nothing else depends on it. Here is the same spec, rewritten:
// cypress/e2e/after/selectors.cy.ts
describe('orders', () => {
beforeEach(() => {
cy.login();
cy.resetData();
cy.intercept('GET', '/api/orders').as('orders');
cy.visit('/orders');
cy.wait('@orders');
});
it('shows the seeded orders', () => {
cy.get('[data-cy="order-row"]').should('have.length', 2);
cy.contains('[data-cy="order-row"]', 'Wool Socks').should('have.text', '#1002 Wool Socks');
});
it('adds an order', () => {
cy.intercept('POST', '/api/orders').as('createOrder');
cy.get('[data-cy="new-order-item"]').type('Rain Jacket');
cy.getByRole('button', { name: 'Add order' }).click();
cy.wait('@createOrder').its('response.statusCode').should('eq', 201);
cy.get('[data-cy="order-row"]').last().should('have.text', '#1003 Rain Jacket');
cy.getByRole('status', { native: false }).should('have.text', '3 orders');
});
});It passes against both the old markup and the redesign. (cy.login() and cy.resetData() are custom commands, shown in the next sections.)
A few rules make this hold up:
- Put
data-cyon the repeating item, not on its position.[data-cy="order-row"]pluscy.contains()finds "the row with Wool Socks" wherever it sits. - Use text on purpose.
cy.contains()fails when the copy changes. That's right for a confirmation message and wrong for a button you only click to reach the next step. cy.getByRole()is built in from Cypress 16.2.0 (docs). Before that you needed Testing Library. By default it matches only elements whose tag gives them the role. A<p role="status">is skipped, and the error says so: "Some elements have the role "status" only through aroleattribute, so they were skipped." Pass{ native: false }to include them, as the spec above does.
For naming and ownership of test ids across a team, see data-testid best practices. The same advice applies whether you call the attribute data-cy or data-testid.
Log in with cy.session
The "before" spec above logs in through the form before every test. That's slow, every test fails when the login page breaks, and the password sits in the spec.
The Cypress guide recommends logging in programmatically and caching the session. cy.session() does the caching. The first time a spec calls it with a given id, it runs your setup function and saves cookies, localStorage and sessionStorage. Later tests in that spec restore the saved session instead. The docs suggest wrapping it in a custom command, so here it is:
// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
/** Signs in through the API once per spec file and restores the session for later tests. */
login(email?: string): Chainable<void>;
/** Puts the app's data back to its seeded state. */
resetData(): Chainable<void>;
}
}
}
Cypress.Commands.add('login', (email = 'ada@example.test') => {
cy.session(
// The id is shown in the Command Log, so never put a password in it.
['api-login', email],
() => {
cy.env(['APP_PASSWORD']).then(({ APP_PASSWORD }) => {
if (!APP_PASSWORD) throw new Error('Set APP_PASSWORD before running the tests.');
cy.request({
method: 'POST',
url: '/api/login',
body: { email, password: APP_PASSWORD },
log: false,
});
});
},
{
validate() {
// A 401 here fails validation, so Cypress runs the setup again.
cy.request('/api/session').its('status').should('eq', 200);
},
},
);
});
Cypress.Commands.add('resetData', () => {
if (Cypress.expose('allowDataReset') !== true) {
throw new Error(`Data reset is disabled on "${Cypress.expose('testEnv')}".`);
}
cy.request('POST', '/api/test/reset');
});
export {};cypress/support/e2e.ts contains one line, import './commands';, so every spec gets both commands.
What to know about it:
- The cookie from
cy.request()reaches the browser. The app sets an HttpOnly session cookie, and the nextcy.visit('/orders')is signed in. No form, no page load. validatedecides when to log in again. If a restored session fails validation, Cypress runs the setup again. If validation fails straight after setup, the test fails, which is what you want when the password is wrong.- Visit after you restore. With test isolation on (the default), Cypress clears the page before the setup runs. Always call
cy.visit()aftercy.session(). - Build the id from everything that changes the session, such as the user and role. Keep secrets out of it: the docs point out that the id shows in the reporter.
- Sessions last for one spec file by default.
cacheAcrossSpecs: trueshares one across specs in the same run on the same machine. - A missing secret fails loudly, with "Set APP_PASSWORD before running the tests.", instead of typing
undefinedinto a password field.
Keep one test that drives the real login form. Everything else can skip it.
Wait for requests, not time
cy.wait(<number>) is the most common Cypress anti-pattern. The guide says to wait on route aliases or assertions instead. Here's why, using the autosave field:
// cypress/e2e/before/waits.cy.ts
describe('notes (before)', () => {
beforeEach(() => {
cy.login();
cy.resetData();
});
it('keeps the notes after a reload', () => {
cy.visit('/orders');
cy.get('[data-cy="notes"]').type('Ship before Friday');
cy.wait(500); // "long enough" on the day this was written
cy.reload();
cy.get('[data-cy="notes"]').should('have.value', 'Ship before Friday');
});
it('waits for an intercept registered after the visit', () => {
cy.visit('/orders');
cy.intercept('GET', '/api/orders').as('orders'); // too late: the request already left
cy.wait('@orders');
cy.get('[data-cy="order-row"]').should('have.length', 2);
});
});Both tests fail:
1) notes (before)
keeps the notes after a reload:
Timed out retrying after 4000ms
+ expected - actual
+'Ship before Friday'
2) notes (before)
waits for an intercept registered after the visit:
CypressError: Timed out retrying after 5000ms: `cy.wait()` timed out waiting `5000ms` for the 1st request to the route: `orders`. No request ever occurred.The first test reloads before the one-second autosave fires, so the note is never saved. Raise the wait to 1,500 ms and it passes, until the save gets slower in CI. Then you raise it again, and every run pays for the longest delay you ever saw.
The second test shows the classic cy.intercept() mistake. An intercept only sees requests made after it's registered, and the page fetched its orders during cy.visit().
Here is the fixed version:
// cypress/e2e/after/waits.cy.ts
describe('notes', () => {
beforeEach(() => {
cy.login();
cy.resetData();
});
it('keeps the notes after a reload', () => {
cy.intercept('PUT', '/api/notes').as('saveNotes');
cy.visit('/orders');
cy.get('[data-cy="notes"]').type('Ship before Friday');
cy.wait('@saveNotes').then(({ request, response }) => {
expect(request.body).to.deep.equal({ notes: 'Ship before Friday' });
expect(response?.statusCode).to.eq(204);
});
cy.reload();
cy.get('[data-cy="notes"]').should('have.value', 'Ship before Friday');
});
it('saves the notes without waiting a real second', () => {
cy.clock(); // freezes setTimeout before the page loads
cy.intercept('PUT', '/api/notes').as('saveNotes');
cy.visit('/orders');
cy.get('[data-cy="notes"]').type('Ship before Friday');
cy.tick(1000); // runs the app's 1-second autosave timer now
cy.wait('@saveNotes');
cy.get('[data-cy="notes-saved"]').should('have.text', 'Saved');
});
it('shows an empty state when the API returns no orders', () => {
cy.intercept('GET', '/api/orders', { body: [] }).as('orders');
cy.visit('/orders');
cy.wait('@orders');
cy.get('[data-cy="order-row"]').should('not.exist');
cy.getByRole('status', { native: false }).should('have.text', '0 orders');
});
});How cy.wait('@alias') behaves:
- It has two timeouts. It waits up to
requestTimeout(5 seconds by default) for the request to leave the browser, then up toresponseTimeout(30 seconds) for the answer. A slow save no longer breaks the test. A save that never happens fails with a clear message. - It yields the interception. You can check the request body and status, which a fixed wait can never do. The first test proves the right data was sent, not just that something was saved.
- Each wait takes the next matching request. Calling
cy.wait('@saveNotes')twice waits for two saves. - Register first, act second. Put
cy.intercept()before thecy.visit()or click that sends the request. In the page object below,visit()does exactly that. - Stub only what the test is about. The third test replaces the response with
{ body: [] }to reach the empty state, which is hard to arrange with real data. Intercepts are cleared before every test, so the stub can't leak (cy.intercept).
You don't need an alias for everything. cy.get() and .should() already retry for 4 seconds by default (defaultCommandTimeout). Wait on a request when the page can't show that the work is done, as with an autosave, or when you need to check what was sent. For timers, cy.clock() is cheaper still: it controls setTimeout, so the debounce fires at once.
Keep tests isolated
Cypress has test isolation on by default. Before each test it loads about:blank and clears cookies, localStorage and sessionStorage for every domain. It doesn't clear IndexedDB, and it can't touch your server's data. That part is yours.
This spec breaks the rule:
// cypress/e2e/before/isolation.cy.ts
describe('order count (before)', () => {
beforeEach(() => {
cy.login();
});
it('adds an order', () => {
cy.visit('/orders');
cy.get('[data-cy="new-order-item"]').type('Rain Jacket');
cy.getByRole('button', { name: 'Add order' }).click();
cy.contains('[data-cy="order-row"]', 'Rain Jacket');
});
it('shows three orders', () => {
cy.visit('/orders');
cy.get('[data-cy="order-row"]').should('have.length', 3);
});
});On fresh data it passes, which is why specs like this get merged. It fails in two ways:
- Run the second test alone, with
it.onlyor after a retry, and it fails: "Not enough elements found. Found '2', expected '3'." - Run the spec again without a reset, and the first test passes for the wrong reason. Yesterday's "Rain Jacket" row is already on the page, so
cy.contains()succeeds at once. The test ends, and Cypress clears the page before the new order's request goes out. In our run the server never received it. The test is green and proved nothing.
The fix is to reset at the start of every test and have each test create what it checks:
// cypress/e2e/after/isolation.cy.ts
import { OrdersPage } from '../../pages/orders.page';
describe('order count', () => {
const orders = new OrdersPage();
beforeEach(() => {
cy.login();
cy.resetData();
});
it('adds an order', () => {
orders.visit();
orders.addOrder('Rain Jacket');
orders.rows().should('have.length', 3).last().should('contain.text', 'Rain Jacket');
});
it('counts a new order after a reload', () => {
orders.visit();
orders.addOrder('Rain Jacket');
orders.visit();
orders.rows().should('have.length', 3);
});
});We ran this suite twice in a row, after adding a stray order by hand, and it passed both times.
Rules that follow from this:
- Reset in
beforeorbeforeEach, not inafterorafterEach. The guide's reasoning: a cleanup hook may never run if the run is interrupted, and leftover state helps you debug a failure. - Reset through the fastest door you control. That might be a test-only API route, a
cy.task()that talks to the database, or a seeding script. Guard it so it can't run against production.cy.resetData()refuses unless the config allows it (next section). - Don't assign command results to variables.
const rows = cy.get(...)doesn't hold elements, because commands run later. Use.then(), or an alias with.as(). - Several assertions per test are fine. The guide warns against "tiny" one-assertion tests. Setup is the slow part of an end-to-end test, so check the whole outcome once you're there.
- Leave
testIsolationon (see the FAQ).
Custom commands or page objects
Both are ways to reuse code. They solve different problems.

Custom commands suit behaviour every spec needs, such as logging in and seeding data. The custom commands docs add limits: don't wrap a single cy.get().click(), keep commands small with few or no assertions, and use a plain function when only one spec needs it. Declare the type in a declare global block, as commands.ts does, so your editor autocompletes cy.login().
Page objects suit pages that many specs drive. The Cypress guide lists "sharing page objects" in one of its anti-pattern banners, without further detail. Read it as a warning against page objects that hold state, hide assertions or grow into one class per app. A small page object that only knows where things are and what a user does is still useful:
// cypress/pages/orders.page.ts
const sel = (id: string) => `[data-cy="${id}"]`;
export class OrdersPage {
visit() {
cy.intercept('GET', '/api/orders').as('orders');
cy.visit('/orders');
cy.wait('@orders');
}
rows() {
return cy.get(sel('order-row'));
}
addOrder(item: string) {
cy.intercept('POST', '/api/orders').as('createOrder');
cy.get(sel('new-order-item')).type(item);
cy.getByRole('button', { name: 'Add order' }).click();
return cy.wait('@createOrder');
}
}Note what it doesn't do. It makes no assertions and keeps no state. rows() returns the chain, so the test adds .should() and Cypress retries the query. When the markup changes, you fix one file instead of twenty specs.
sel() is a plain function that returns a string. It's not a custom command, so cy.get() stays a normal, retried query. If you want a reusable lookup that retries as one unit, Cypress has custom queries (Cypress.Commands.addQuery) for that.
Config per environment
One cypress.config.ts can serve local, staging and production runs. Here is the demo's:
// cypress.config.ts
import { defineConfig } from 'cypress';
// One config file, several environments: TEST_ENV picks the base URL.
const environments = {
local: 'http://localhost:4471',
staging: process.env.STAGING_URL ?? 'https://staging.example.com',
production: 'https://example.com',
} as const;
type EnvName = keyof typeof environments;
const name = (process.env.TEST_ENV ?? 'local') as EnvName;
if (!(name in environments)) {
throw new Error(`Unknown TEST_ENV "${name}". Use one of: ${Object.keys(environments).join(', ')}`);
}
export default defineConfig({
e2e: {
baseUrl: process.env.BASE_URL ?? environments[name],
specPattern: 'cypress/e2e/**/*.cy.ts',
retries: { runMode: process.env.CI ? 2 : 0, openMode: 0 },
video: false,
},
// Secrets: read in tests with cy.env(['APP_PASSWORD']). They stay in the Node process
// until a test asks for them by name.
env: {
APP_PASSWORD: process.env.APP_PASSWORD,
},
// Public, non-secret settings: read synchronously with Cypress.expose('testEnv').
expose: {
testEnv: name,
allowDataReset: name !== 'production',
},
});Run it per environment:
# Local (the default)
APP_PASSWORD=correct-horse npx cypress run
# Staging: set STAGING_URL to your staging host and the password from your CI secrets
TEST_ENV=staging STAGING_URL=https://staging.example.com APP_PASSWORD="$STAGING_PASSWORD" npx cypress runA typo fails before any test runs: "Your configFile is invalid … Error: Unknown TEST_ENV "qa". Use one of: local, staging, production".
Secrets and public settings are now separate. Cypress 15.10 added cy.env() and Cypress.expose(), and Cypress 16.0 removed Cypress.env(). Calling it now fails with:
CypressError: `Cypress.env()` was removed in Cypress version 16.0.0. Please update to use `Cypress.expose()` for non-sensitive values, or `cy.env()` for sensitive values.cy.env(['NAME'])reads only the keys you ask for, from the Node process, when the test asks. Values come from the config'senvblock,cypress.env.json,CYPRESS_*variables or--env. In our run,CYPRESS_APP_PASSWORDworked with no config change. The Command Log shows key names, not values.Cypress.expose('name')is synchronous and readable by any script in the browser, your app included. Use it for flags such asallowDataReset, never for passwords or tokens.--expose allowDataReset=falseturns the reset off for one run.- Values aren't masked.
cy.env()returns a plain object, so a failing assertion can still print a secret. Type secrets with{ log: false }, and keep them out ofshould()arguments.
Other config habits worth keeping:
- Always set
baseUrl. Specs use relative paths, and Cypress warns when the server isn't running. Start the server before Cypress, not fromcy.task(). - Retries in CI only. The config above retries twice in
cypress runwhenCIis set, and never in the interactive app. A test that only passes on retry is still flaky. Cypress flaky tests covers how to find out why. - Several config files work too.
--config-filepicks one per environment, but one file with a lookup table is easier to keep consistent.
Cypress exports from Blendra
Blendra records a flow in Chrome and exports it as a test suite in your repo. On the Pro+ and Team plans, a project can export a Cypress suite in TypeScript. It's in Beta. The export follows several of the practices above:
- Page objects are classes in
pages/, with their selectors inlocators/and no checks inside. - Test data is JSON under
fixtures/test-data/. A sensitive value is stored as a{{env:NAME}}reference, never as the value. The suite resolves it in the Cypress process throughcy.task(), notCypress.env..env.examplelists every secret the suite reads. config/environments.tspicks the base URL fromTEST_ENV(local,stagingorproduction). In CI, a failed test is retried twice.- When Blendra picks a locator, test ids come first, and
data-cyis one of the attributes it looks for (locator priority). - A vendored plugin sends each run's results to Blendra. Steps Cypress can't run, such as checks inside an iframe or a step on another origin, are exported as skipped tests, with the step named in a comment. They're never dropped silently.
If you're still deciding on a framework, Cypress vs Playwright compares the two with the same test written in both.
FAQ
Is cy.wait() with a number ever OK?
Rarely. It can help while you're debugging, but cy.pause() or cy.debug() are better tools for that. In a committed test, wait on an alias or an assertion. If the app runs a timer, such as a debounce or a polling interval, control it with cy.clock() and cy.tick().
Should I use data-cy or data-testid?
Either one works. The Cypress docs use data-cy; Testing Library's and Playwright's test id queries default to data-testid. What matters is that your team picks one name, agrees who adds the attributes, and treats removing one as a breaking change.
How do I fix "Cypress.env() was removed in Cypress version 16.0.0"?
Split your variables. Read secrets with cy.env(['NAME']).then(...) inside a test or command, and move public settings into the config's expose block, read with Cypress.expose('name'). The error also says "This call may come from a plugin." If you don't call Cypress.env() yourself, update the plugin to a version that supports Cypress 16.
Does cy.session keep me logged in across spec files?
Not by default. Each spec file logs in once. Add cacheAcrossSpecs: true to share a session across specs within one cypress run on one machine. Every spec must then call it with the same id, setup and validate function. Parallel CI machines each log in at least once.
Should I turn off testIsolation to make the suite faster?
Usually not. With cy.session() and API-based setup, isolated tests are already fast. Turning isolation off lets state leak between tests, so order matters again and failures get misleading. If you must, turn it off for one suite where shared state is the point, not globally.