Skip to main content
Back to Blog
Tutorial
2026-05-12

Cypress Best Practices 2026: 25 Rules for Reliable Tests

The definitive 2026 guide to Cypress best practices. 25 actionable rules covering selectors, network stubbing, custom commands, CI, and flake elimination with side-by-side examples.

Cypress remains one of the most popular end-to-end testing frameworks in 2026, but the gap between a Cypress test suite that scales and one that becomes a flaky maintenance nightmare comes down to discipline. This guide distills the 25 rules every Cypress engineer should follow, with side-by-side good and bad examples for each rule and clear explanations of the underlying mechanics.

If you are starting a new Cypress project or auditing an existing one, treat these rules as a checklist. Each rule maps to a real failure mode we have seen in production codebases -- from cy.wait(3000) peppered through test files to single 500-line spec files that flake every other run.

Key Takeaways

  • Selector strategy is non-negotiable. Always use data-cy attributes; never select by class, ID, or generated text.
  • Eliminate arbitrary waits. cy.wait(ms) is the single biggest cause of flake. Use route aliases and assertions instead.
  • Isolate every test. Tests must not depend on order, leftover state, or other tests' data.
  • Stub the network at the boundary. cy.intercept() lets you control timing, payloads, and edge cases without backend changes.
  • Custom commands are leverage, not magic. Use them to encode policy (login, seed, navigate) -- not to hide assertions.
  • Run in CI like you run locally. Headless mode, the same Node version, and parallelization should match.

Rule 1 -- Use data-cy Attributes for Selectors

The official Cypress documentation has recommended data-cy attributes for years, and yet most flaky Cypress suites still select elements by class, ID, or visible text. This is the root cause of more flake than any other single anti-pattern.

Bad -- coupled to styling and copy:

cy.get('.btn-primary').click();
cy.get('#submit-button').click();
cy.contains('Save changes').click();

Good -- decoupled from presentation:

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

A data-cy attribute is invisible to users, ignored by SEO, untouched by CSS refactors, and survives translation. When a designer changes .btn-primary to .button-action, your tests do not break. When the product team changes "Save changes" to "Save", your tests do not break.

For component libraries that wrap third-party elements, set a custom command:

Cypress.Commands.add('dataCy', (selector) => {
  return cy.get(`[data-cy=${selector}]`);
});

// Usage
cy.dataCy('save-button').click();

Rule 2 -- Never Use cy.wait with a Number

cy.wait(3000) is the most common cause of flake in real Cypress suites. It is a band-aid on a real timing problem and creates a brittle, slow test.

Bad -- arbitrary waiting:

cy.get('[data-cy=submit]').click();
cy.wait(5000);
cy.get('[data-cy=success]').should('be.visible');

Good -- wait for the network alias:

cy.intercept('POST', '/api/checkout').as('checkout');
cy.get('[data-cy=submit]').click();
cy.wait('@checkout');
cy.get('[data-cy=success]').should('be.visible');

Cypress automatically retries assertions until they pass or time out. Combined with route aliases, this gives you precise control: wait exactly as long as needed, never longer.

Rule 3 -- One Assertion Per Concept, Not Per Test

A test should verify one behavior end-to-end. That behavior may need multiple assertions to fully describe the outcome -- that is fine. What is not fine is splitting "user can complete checkout" into seven separate tests.

Good -- one scenario, multiple assertions:

it('completes checkout with valid payment', () => {
  cy.visit('/cart');
  cy.get('[data-cy=checkout-button]').click();
  cy.fillPayment(validCard);
  cy.get('[data-cy=place-order]').click();

  cy.wait('@order');
  cy.url().should('include', '/order-confirmation');
  cy.get('[data-cy=order-number]').should('be.visible');
  cy.get('[data-cy=order-total]').should('contain', '$99.99');
});

Rule 4 -- Stub the Network by Default

Hitting the real backend on every test run is a recipe for slow, flaky tests. Stub at the boundary using cy.intercept(), and reserve real backend hits for a small set of smoke tests.

beforeEach(() => {
  cy.intercept('GET', '/api/products', { fixture: 'products.json' }).as('products');
  cy.intercept('POST', '/api/cart', { statusCode: 200, body: { ok: true } }).as('addToCart');
});

Rule 5 -- Reset State Before Tests, Not After

State cleanup belongs in beforeEach, not afterEach. If a test crashes mid-run, afterEach may never execute -- leaving the next test in a corrupted state.

Good:

beforeEach(() => {
  cy.task('db:reset');
  cy.task('db:seed');
});

Rule 6 -- Use cy.session for Authentication

Re-running the login flow in every test is slow and brittle. cy.session() caches authentication state across tests in the same spec file.

beforeEach(() => {
  cy.session('user-alice', () => {
    cy.visit('/login');
    cy.get('[data-cy=email]').type('alice@example.com');
    cy.get('[data-cy=password]').type('SecurePass1!');
    cy.get('[data-cy=submit]').click();
    cy.url().should('include', '/dashboard');
  }, {
    validate() {
      cy.getCookie('session').should('exist');
    },
  });
});

Rule 7 -- Login Programmatically When Possible

If your application supports it, log in via an API call rather than the UI. This is faster and decouples your test from login UI changes.

Cypress.Commands.add('loginByApi', (email, password) => {
  cy.session([email], () => {
    cy.request({
      method: 'POST',
      url: '/api/auth/login',
      body: { email, password },
    }).then((response) => {
      window.localStorage.setItem('auth_token', response.body.token);
    });
  });
});

Rule 8 -- Avoid Conditional Logic in Tests

If your test has if statements, you are testing two things instead of one.

Bad:

cy.get('[data-cy=banner]').then(($el) => {
  if ($el.is(':visible')) {
    cy.get('[data-cy=close-banner]').click();
  }
});

Good -- two separate tests:

it('shows the banner on first visit', () => { /* ... */ });
it('hides the banner after dismissal', () => { /* ... */ });

Rule 9 -- Use Fixtures for Test Data

Hardcoding large data objects in test files makes them unreadable. Use fixtures.

// cypress/fixtures/user.json
{ "email": "alice@example.com", "role": "admin" }

// In test
cy.fixture('user').then((user) => {
  cy.get('[data-cy=email]').type(user.email);
});

Rule 10 -- Run Tests in Random Order

Cypress runs specs in alphabetical order by default. Tests should be order-independent. Validate this by running specs in random order in CI occasionally.

Rule 11 -- Keep Specs Under 200 Lines

A 1000-line spec file is a maintenance disaster. Split by feature, by user role, or by user journey.

Rule 12 -- Custom Commands for Reusable Workflows

Cypress.Commands.add('addToCart', (productId, quantity = 1) => {
  cy.get(`[data-cy=product-${productId}]`).click();
  cy.get('[data-cy=quantity]').clear().type(quantity);
  cy.get('[data-cy=add-to-cart]').click();
  cy.get('[data-cy=cart-count]').should('contain', quantity);
});

Rule 13 -- Custom Commands Should Not Hide Assertions

A custom command named addToCart should add to the cart. It should not also assert that the cart total is correct -- that is the test's job.

Rule 14 -- Use should with a Callback for Complex Assertions

cy.get('[data-cy=user-list]').should(($list) => {
  expect($list.find('li')).to.have.length.greaterThan(2);
  expect($list.find('li').first()).to.contain('Alice');
});

Rule 15 -- Never Use cy.wait Before Assertions

Cypress already retries assertions. Adding a wait before is redundant and slows down passing tests.

Rule 16 -- Use cy.intercept Aliases for Form Submissions

Every form submission should have a corresponding intercept and cy.wait('@alias'). This is the single highest-impact change you can make to reduce flake.

Rule 17 -- Test User Behavior, Not Implementation

If your test reads cy.get('[data-cy=internal-state-flag]'), you are testing implementation details. Test what the user sees.

Rule 18 -- Run Headless Locally Before Pushing

cypress open is great for debugging, but cypress run is what your CI uses. Run headless before pushing -- you will catch issues that only appear in CI.

npx cypress run --browser chrome --spec "cypress/e2e/checkout.cy.ts"

Rule 19 -- Parallelize Tests in CI

Cypress Cloud and GitHub Actions matrix builds let you run 10x faster.

strategy:
  matrix:
    containers: [1, 2, 3, 4]
steps:
  - run: npx cypress run --record --parallel

Rule 20 -- Pin Your Cypress Version

"dependencies": {
  "cypress": "13.15.0"
}

Use exact versions, not ^13.15.0. Cypress patch releases occasionally introduce breaking changes.

Rule 21 -- Use TypeScript

TypeScript catches selector typos, custom command signature errors, and refactoring breakage at compile time.

declare global {
  namespace Cypress {
    interface Chainable {
      dataCy(selector: string): Chainable<JQuery<HTMLElement>>;
      login(email: string, password: string): Chainable<void>;
    }
  }
}

Rule 22 -- Page Objects Are Optional in Cypress

Cypress's chainable API and custom commands often eliminate the need for page objects. Use them only when a page has genuinely complex shared behavior.

Rule 23 -- Visual Regression for Critical Pages

For pages where pixel-perfect layout matters (landing pages, marketing, dashboards), add visual regression with Percy or Applitools.

cy.percySnapshot('Dashboard - empty state');

Rule 24 -- Tag Tests for Selective Execution

describe('Checkout', { tags: ['@smoke', '@checkout'] }, () => {
  it('completes purchase', { tags: '@critical' }, () => { /* ... */ });
});

// Run only smoke tests
// npx cypress run --env grepTags=@smoke

Rule 25 -- Capture and Review Videos and Screenshots

Cypress records videos and screenshots automatically. Upload them as CI artifacts so you can review failures without re-running locally.

- name: Upload Cypress artifacts
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: cypress-artifacts
    path: |
      cypress/screenshots
      cypress/videos

Common Anti-Patterns

Selecting by Visible Text

Tests that select with cy.contains('Save') break when product copy changes or when the application is internationalized. Use data-cy selectors and let assertions verify text content separately.

Long beforeEach Hooks

If your beforeEach is 50 lines long, every test pays that cost on every run. Move shared setup to fixtures or programmatic seeds.

Sharing State Between Tests

// BAD
let userId;
it('creates a user', () => {
  cy.request('POST', '/api/users').then((res) => { userId = res.body.id; });
});
it('updates the user', () => {
  cy.request('PATCH', `/api/users/${userId}`);
});

If the first test fails, the second one fails for the wrong reason. Each test should create its own data.

Mixing UI and API Setup

If your test logs in via the UI just to test a settings page, you have coupled two unrelated things. Log in via API; test the settings UI.

Not Using cy.intercept Aliases

// BAD
cy.get('[data-cy=submit]').click();
cy.wait(2000);

// GOOD
cy.intercept('POST', '/api/orders').as('createOrder');
cy.get('[data-cy=submit]').click();
cy.wait('@createOrder');

Cypress vs Playwright: Best Practices Differ

Many engineers come to Cypress from Playwright and try to apply the same patterns. They are different tools with different defaults.

ConcernCypressPlaywright
Selectorsdata-cy attributes preferredgetByRole, getByTestId, getByLabel
WaitingAutomatic retry of assertionsAuto-waiting with locators
Network stubbingcy.intercept()page.route()
Auth cachingcy.session()storageState
ParallelismCypress Cloud or CI matrixBuilt-in workers
Browser enginesChromium, Firefox, WebKit (experimental)Chromium, Firefox, WebKit (all first-class)

See our Cypress vs Playwright comparison for a deeper dive.


Running These Practices in CI

A minimal GitHub Actions setup that follows these best practices:

name: Cypress Tests
on: [pull_request]
jobs:
  cypress:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        containers: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install
      - run: pnpm build
      - name: Cypress run
        uses: cypress-io/github-action@v6
        with:
          install: false
          start: pnpm start
          wait-on: 'http://localhost:3000'
          record: true
          parallel: true
        env:
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Automate Cypress Best Practices with AI Agents

Manually enforcing 25 rules across a growing team is hard. AI coding agents can pre-flight every Cypress PR and catch violations before they reach review.

npx @qaskills/cli add cypress-e2e
npx @qaskills/cli add fix-flaky-tests

With these skills installed, your agent will:

  • Refactor selectors to data-cy attributes automatically
  • Replace cy.wait(ms) with route alias waits
  • Convert UI logins to programmatic logins
  • Add intercept aliases for every form submission
  • Suggest splits when spec files exceed 200 lines

Browse all available skills at qaskills.sh/skills.


Frequently Asked Questions

Should I use data-cy or data-testid?

Either works. data-testid is the React Testing Library convention and is often already present in component code. data-cy is Cypress's official recommendation. Pick one and use it everywhere. If your codebase already uses data-testid, do not duplicate -- configure Cypress to look for it.

How do I handle authentication in Cypress 2026?

Use cy.session() for caching login state and prefer programmatic API logins over UI logins. For SSO and OAuth flows, see Cypress's documentation on cross-origin testing with cy.origin().

Is page object model still relevant for Cypress?

Page objects can help in very large suites with shared complex pages, but Cypress custom commands often eliminate the need. Start without POM, add it only when you see clear duplication that custom commands cannot resolve.

How do I run Cypress against multiple environments?

Use Cypress environment variables or cypress.config.ts overrides. Pass environment-specific config via --env flags or CI environment variables.

What about component testing in Cypress?

Cypress Component Testing supports React, Vue, Angular, and Svelte. It uses the same Cypress runner with a different mount harness. See our Cypress component testing with Vue guide and Cypress component testing with Angular guide.

Cypress Best Practices 2026: 25 Rules for Reliable Tests | QASkills.sh