Cypress Core Concepts

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Cypress looks like a library of ordinary function calls, but a test file is not executed the way the code reads. Every cy.* call only appends an instruction to a queue; the queue runs after the test function has returned, one command at a time, and each command waits, retries and checks things before the next one starts. Nearly every surprise a newcomer meets — a variable that is empty, a click on an element that was "just there", an assertion that passes only sometimes — comes from not knowing this model. This page explains it end to end: the command queue and subjects, queries, actions and assertions, retry-ability, actionability, variables and aliases, test isolation, conditional testing, and the trade-offs that come with Cypress’s architecture.

The baseline is Cypress 16.2.0 (released on 13 October 2026), checked on 25 October 2026 against the official documentation. Every example on this page is real test code that was run against the Bookshelf demo application with Cypress 16.2.0 in Electron and in Chrome 155, in headless mode (cypress run); where a section quotes an error message, it is the message Cypress 16.2.0 printed for a deliberately failing test. Bookshelf’s routes, data-testid conventions and the cy.signIn() command are described on that page and in Test Design & Page Objects; passwords come from the CYPRESS_READER_PASSWORD environment variable and never appear in a test. Where this page says "the documentation says" and the observed behaviour differs, the difference is called out.

How a Cypress test runs

Cypress runs inside the browser, in the same run loop as the application under test, with a Node.js process alongside it for everything the browser cannot do (see How Browser Automation Works for the architecture and the comparison with WebDriver and Playwright). Your spec is bundled and loaded in the browser; Mocha registers the describe, it and hook functions, and then runs them. What is different is what a cy.* call does when Mocha runs the test function:

  1. The test function (and its beforeEach hooks) runs once, top to bottom, as ordinary synchronous JavaScript. Each cy.* call does nothing yet: it appends a command to Cypress’s queue and returns at once.

  2. When the function returns, Cypress starts executing the queue, strictly in order. A command that needs time (a cy.visit(), a query waiting for an element, an action waiting for the element to be clickable) finishes before the next one starts.

  3. Each command yields a value, the subject, to the command chained after it. The chain ends when a new cy.* starts one.

  4. If a command or assertion fails after its retries are exhausted, the test fails, the remaining commands in the queue are skipped and the afterEach hooks run.

The figure shows the loop for the three kinds of command (the next sections explain each kind):

flowchart TD A["Spec loads: describe, it and hooks are registered"] --> B["Test function runs once, top to bottom"] B --> C["Each cy call is appended to the command queue"] C --> D["Test function returns: the queue starts to run"] D --> E{"Next command"} E -->|"query: get, find, eq, its, contains"| F["Run the whole chain of linked queries from the top"] F --> G{"Attached assertions pass?"} G -->|"yes"| H["Yield the subject to the next command"] G -->|"no, time left"| F G -->|"no, timeout reached"| X["Fail the test and skip the rest of the queue"] E -->|"action: click, type, check, select"| I["Re-run the query chain that finds the element"] I --> J{"Actionable: visible, enabled, not covered, not moving?"} J -->|"no, time left"| I J -->|"no, timeout reached"| X J -->|"yes"| K["Perform the action once"] K --> H E -->|"other command: visit, request, task, then"| L["Run once, with its own timeout"] L --> H H --> M{"Queue empty?"} M -->|"no"| E M -->|"yes"| N["Test passes"]

Because a command’s work happens later, the return value of a cy.* call is not the result of the command. It is an internal chainer object used only to link the next command. This is the reason for the rules in the next sections: you cannot assign what a command finds to a variable, you cannot await it, and you cannot catch its failure.

Synchronous code runs first

The first example below records the order in which three things happen, and the second shows what goes wrong when plain JavaScript tries to read the page before the queued cy.visit() has run. Both tests passed.

  it('enqueues commands and runs them after the test function returns', () => {
    const order: string[] = [];

    cy.visit('/books').then(() => order.push('2: visit finished'));
    cy.get('[data-testid=book-card]').should('have.length', 6).then(() => order.push('3: six cards'));
    order.push('1: test function body done'); // synchronous code runs first

    cy.then(() => {
      expect(order).to.deep.equal(['1: test function body done', '2: visit finished', '3: six cards']);
    });
  });

  it('cannot read a value synchronously from a command', () => {
    cy.visit('/books');
    // Cypress.$ is synchronous, so it looks at the page *before* cy.visit() has run.
    const cards = Cypress.$('[data-testid=book-card]').length;
    expect(cards).to.equal(0);

    cy.get('[data-testid=book-card]').its('length').should('eq', 6);
  });

Cypress.$ is jQuery evaluated immediately, so it counted zero cards although the page has six: the cy.visit() above it had been queued but not run. The same trap catches if statements, while loops and plain variables placed next to commands. When you need to run synchronous code after earlier commands have finished, put it inside a .then() callback, which Cypress calls at the right point in the queue.

A while loop that enqueues commands never lets the queue start, because the test function never returns; the loop adds commands until the browser runs out of memory. The documented fix is recursion: a function that enqueues a few commands and, inside a .then(), calls itself again only if the condition still holds.

const checkAndReload = () => {
  cy.get('#result')
    .should('not.be.empty')
    .invoke('text')
    .then((text) => parseInt(text, 10))
    .then((number) => {
      if (number === 7) {
        return; // stop enqueueing
      }
      cy.reload();
      checkAndReload(); // enqueue another round, after this callback has run
    });
};

This snippet is adapted from the documentation’s own recursion example and was not run against Bookshelf, which has no element that changes after a reload.

Commands run serially and are not promises

The documentation lists the design choices that follow from the queue. They are intentional, not limitations that a later release will lift:

  • No parallel commands and no racing. Commands mutate browser state (cy.request() sets cookies, .click() fires events), so they run one at a time, the way a user acts step by step.

  • No .catch() on a failed command. A command either passes or fails the test; there is no error recovery inside a test. The last section of this page returns to why.

  • No async/await. A Cypress chain looks like a promise because it has a .then() method, but it is not a promise and awaiting it does not wait for the command. Results are reached through .then() callbacks, aliases and closures (see below).

The one place where promises do appear is Cypress.Promise, the Bluebird-based promise that a .then() callback may return when a test must wait for a browser API such as IndexedDB (an example is further down).

Chaining and subjects

A chain starts with cy (or cy.get(), cy.visit() and so on) and continues with methods chained onto the previous command. The value a command yields to the next one is its subject, and three rules govern how it is used:

  • All commands yield something. cy.get() yields the matching element or elements as a jQuery collection, .invoke('text') yields a string, cy.clearCookies() yields null, and .click() yields the same element it received.

  • Some commands need a previous subject. .click() needs a DOM element, .its() needs a subject of any type, and .find() needs elements to search in.

  • Some commands change meaning with the subject. cy.contains() searches the whole document when it follows cy and only the subject’s descendants when it follows an element. Commands such as cy.clearCookies() ignore the subject.

Commands are yielded, not returned: you cannot read a command’s subject from its return value, only inside the next command in the chain.

  it('yields subjects from one command to the next', () => {
    cy.visit('/books');

    cy.get('[data-testid=book-list]') // yields the <ul>
      .contains('[data-testid=book-card]', 'Quiet') // yields the first matching <li>
      .find('[data-testid=book-title]') // yields the <h3>
      .should('contain.text', 'The Quiet Compiler')
      .invoke('text') // yields a string
      .then((title) => title.toUpperCase()) // a non-null return value becomes the new subject
      .should('eq', 'THE QUIET COMPILER');
  });

What .then() yields

.then() is how you step into the chain to use a subject with ordinary JavaScript. It calls your function with the subject as the argument and decides the next subject from the callback’s result:

  • A return value other than undefined becomes the new subject (the example above returns an upper-cased string).

  • Returning nothing keeps the previous subject, as in the first test below.

  • If the callback enqueues Cypress commands, the subject becomes what the last of those commands yields. This is easy to trip over: a cy.log() inside the callback yields null, so a chai-jQuery assertion such as .should('be.visible') after it fails with an "invalid subject" error. The second test below shows the supported use of this rule.

  it('keeps the previous subject when a callback returns nothing', () => {
    cy.visit('/books');
    cy.get('[data-testid=book-title]')
      .first()
      .then(($title) => {
        expect($title.text()).to.have.length.greaterThan(0); // plain code, no return value
      })
      .should('be.visible'); // still the <h3>
  });

  it('yields the last nested command when a callback enqueues commands', () => {
    cy.visit('/books');
    cy.get('[data-testid=book-title]')
      .first()
      .then(() => {
        cy.get('[data-testid=book-author]').first(); // a command inside the callback
      })
      .should('have.attr', 'data-testid', 'book-author'); // the subject is now the <p>
  });

The documentation says only that a callback must return something other than null or undefined to change the subject; the "last nested command wins" behaviour was observed on 16.2.0 (a cy.log() callback produced the error "You attempted to make a chai-jQuery assertion on an object that is neither a DOM object or a jQuery object …​ The invalid subject you asserted on was: null").

Ending a chain after an action

A new chain always starts with cy. As the retry-ability section explains, a chain should end with the action and the next step should be a fresh cy.get():

  it('starts a fresh chain from cy and ends a chain after an action', () => {
    cy.visit('/');
    cy.get('[data-testid=search-input]').type('quiet{enter}');
    cy.get('[data-testid=result-count]').should('have.text', '1 book found');
  });

Cypress 16.0 removed the cy.end() command that used to terminate a chain by yielding null; the next cy.* call already starts a new chain, so existing .end() calls can simply be deleted.

Queries, actions and assertions

Everything chained off cy is a command, but three kinds of command follow different rules, and the rules are the key to understanding retries:

Kind Examples Behaviour

Queries

cy.get(), .find(), cy.contains(), .eq(), .first(), .last(), .filter(), .not(), .children(), .parent(), .closest(), .siblings(), .its(), cy.window(), cy.document(), cy.title(), cy.url(), cy.location(), cy.focused(), cy.root(), .shadow(), .invoke(), .as(); since Cypress 16 also cy.getCookie(), cy.getCookies(), cy.getAllCookies(), cy.getAllLocalStorage() and cy.getAllSessionStorage(); and cy.readFile() since 13.0

Read state from the page or the window without changing it. Linked queries form one chain that Cypress can re-run from the top and are retried until the attached assertions pass or the timeout expires.

Actions and other commands

.click(), .dblclick(), .rightclick(), .type(), .clear(), .check(), .uncheck(), .select(), .focus(), .blur(), .trigger(), .selectFile(), cy.visit(), cy.request(), cy.reload(), .then(), cy.wrap(), cy.task()

Do something or may change state. A command runs once. Actions first wait for the element to become actionable (see below), then act a single time.

Assertions

.should(), .and(), and the implicit assertions built into queries and actions

Describe the desired state. An assertion is a type of query as far as retrying goes: when it fails, Cypress re-runs the linked queries before it and checks again.

Queries were introduced in Cypress 12.0, together with the rule that they cannot be overwritten with Cypress.Commands.overwrite() (use Cypress.Commands.overwriteQuery(); see Queries, Assertions & Custom Commands). Cypress 16 turned the cookie and storage readers into queries as well, so assertions on cy.getCookie() now retry, while .then() callbacks that read cookies do not.

Cypress is not jQuery, even though it uses its selectors

Cypress bundles jQuery and exposes its traversal methods, so cy.get('#main-content').find('.article').children('img') reads like the jQuery equivalent. The difference is what happens when nothing matches: jQuery returns an empty collection at once, and you add if statements and manual retries; a Cypress query retries until the element exists or the timeout expires, which removes the family of flaky failures caused by a DOM that has not loaded, a framework that has not finished bootstrapping, an XHR that has not answered or an animation that has not completed. cy.contains() finds elements by what the user sees; be careful with it in applications that are translated, because the text changes with the language.

Retry-ability

Retry-ability is the core of Cypress’s reliability, and it works on whole chains, not on single commands. Take the last chain of a test:

cy.get('.todoapp') // query
  .find('.todo-list li') // query
  .should('have.length', 1) // assertion

cy.get() finds the elements, passes them to .find(), which finds a new set and passes it to .should(). If the assertion fails, Cypress does not simply re-check the same elements: it re-queries from the top, runs .get().find() again against the current DOM and re-runs the assertion, until it passes or the timeout is reached. The command finishes the moment the assertion passes, so a test never waits longer than the application needs and never needs a hard-coded cy.wait(ms).

describe('Queries, assertions and retry-ability', () => {
  beforeEach(() => {
    cy.request('POST', '/api/test/seed', { books: [{ title: 'Only Book', author: 'Test Author' }], reviews: [] });
    cy.signIn('reader');
    cy.visit('/books/1');
  });

  function writeReview(text: string) {
    cy.get('[data-testid=review-rating-input]').select('5');
    cy.get('[data-testid=review-text-input]').type(text);
    cy.get('[data-testid=review-submit]').click();
  }

  it('retries the whole query chain until the assertion passes', () => {
    writeReview('Retried until the server answers');

    // The optimistic row is added at once and turns "saved" after the 400 ms POST.
    cy.get('[data-testid=review-list]')
      .find('[data-testid=review]')
      .should('have.length', 1)
      .and('have.attr', 'data-state', 'saved');
  });

In Bookshelf the review row is added at once with data-state="pending" and changes to saved when the 400 ms POST /api/reviews returns; the test passed in about 800 ms, including typing, because the assertion retried until the attribute changed.

Implicit assertions

Many commands carry assertions of their own, so a test without a single .should() still verifies a lot:

  • cy.visit() expects an HTML response with a 200 status; cy.request() expects the server to answer (a non-2xx or non-3xx status fails it unless failOnStatusCode is false).

  • cy.get(), cy.contains() and .find() expect the element to exist; .eq() expects the index to exist; .its() expects the property to exist, and waits for it if the object is changed later.

  • .type() expects the element to be typeable; .click() expects it to be actionable.

This is why .should('exist') after a query is redundant. The assertion that inverts the default is .should('not.exist'), which tells the query to wait for the element to disappear instead.

Timeouts

Timeouts belong on the command, not on the assertion, and they cover all the assertions chained after the command. The default is 4 seconds (defaultCommandTimeout) for queries, actions and assertions; other commands have their own defaults:

Option Default Applies to

defaultCommandTimeout

4000

Most DOM commands: queries, assertions and the wait for actionability.

pageLoadTimeout

60000

Page transitions, cy.visit(), cy.go() and cy.reload() waiting for the load event.

taskTimeout

60000

A cy.task() call. The removed execTimeout belonged to cy.exec(), which Cypress 16 removed.

requestTimeout

5000

cy.wait('@alias') waiting for a request to go out.

responseTimeout

30000

The response to cy.request(), the matching request in cy.wait(), and cy.fixture(), cy.setCookie(), cy.clearCookie(), cy.clearCookies(), cy.clearAllCookies() and cy.screenshot().

Raise the timeout on the single command that is slow, never globally, and never "extra long, just in case": a failing test spends the whole timeout before it reports, and a working test is never slower for it. Setting { timeout: 0 } turns retrying off for a state that must already hold, such as an element that a server render must not contain.

  it('waits for an element to disappear when the assertion says so', () => {
    cy.get('[data-testid=review-submit]').click(); // empty form: shows a validation message
    cy.get('[data-testid=review-error]').should('be.visible').and('contain.text', 'Choose a rating');

    cy.get('[data-testid=review-rating-input]').select('4');
    cy.get('[data-testid=review-text-input]').type('Long enough to pass validation');
    cy.get('[data-testid=review-submit]').click();
    cy.get('[data-testid=review-error]').should('not.be.visible');
  });

  it('can disable retrying with timeout 0 for a state that must already hold', () => {
    cy.get('[data-testid=review]', { timeout: 0 }).should('not.exist');
    cy.get('[data-testid=no-reviews]', { timeout: 0 }).should('exist');
  });

  it('gives a slow assertion a longer timeout on the command, not on the assertion', () => {
    writeReview('Raising the timeout of the query');

    cy.get('[data-testid=review][data-state=saved]', { timeout: 10_000 }).should('have.length', 1);
  });

Running a test with the timeout passed to the assertion instead of the command shows that the assertion ignores it. This deliberately failing test (kept outside the main specs) waited the full default 4 seconds although it asked for 1:

  it('D: timeout option on the assertion is ignored', () => {
    cy.get('[data-testid=does-not-exist]').should('exist', { timeout: 1000 });
  });

The output:

AssertionError: Timed out retrying after 4000ms: Expected to find element: `[data-testid=does-not-exist]`, but never found it.

When a query chain times out, the message starts with "Timed out retrying after" and the time. Two more messages from deliberately failing tests, with a 1-second timeout on the query:

AssertionError: Timed out retrying after 1000ms: Expected to find element: `[data-testid=does-not-exist]`, but never found it.
AssertionError: Timed out retrying after 1000ms: Not enough elements found. Found '1', expected '2'.

Only queries are retried

Anything that is not a query runs once. Cypress retries the queries leading up to a command and the assertions after it, but not the command itself, because a command such as .click() could change the application. Four consequences follow.

Do not continue a chain after an action. The chain below might act on an element that the framework has re-rendered between steps; separate chains re-query each time. Aliases make the pattern less repetitive:

// Fragile: actions chained on one element reference
cy.get('.new-todo')
  .type('todo A{enter}') // action
  .type('todo B{enter}') // action after another action
  .should('have.class', 'active') // assertion after an action

// Robust: every statement re-queries the element
cy.get('.new-todo').type('todo A{enter}')
cy.get('.new-todo').type('todo B{enter}')
cy.get('.new-todo').should('have.class', 'active')

// Same, with an alias
cy.get('.new-todo').as('new')
cy.get('@new').type('todo A{enter}')
cy.get('@new').type('todo B{enter}')
cy.get('@new').should('have.class', 'active')

Several actions in a row on one stable element are fine, because .focus(), .clear(), .type() and .blur() each yield the element they received: cy.get('#payment-input').focus().clear().type('new value').blur(). Use separate cy.get() calls when the application re-renders the element between actions. If you do continue after an action that removes the element, Cypress fails with a message that explains the cause. This is the real message for a click that removes its own element, from a deliberately failing test:

CypressError: `cy.click()` failed because the page updated as a result of this command, but you tried to continue the command chain. The subject is no longer attached to the DOM, and Cypress cannot requery the page after commands such as `cy.click()`.

A mid-chain assertion is a retry boundary. When a .should() appears in the middle of a chain with more queries after it, the assertion passing locks the subject in; later queries retry from that subject, not from cy.get(). If the page re-renders in between, the locked-in element is detached and Cypress cannot re-query above the assertion. Split the chain at the assertion, or fold the checks into one .should() callback:

// Risky: the li is locked in once the first .should() passes
cy.get('.list')
  .find('li')
  .eq(2)
  .should('contain', 'Header')
  .children('.child')
  .eq(3)
  .should('contain', 'child')

// Safer: two separate chains
cy.get('.list').find('li').eq(2).should('contain', 'Header')
cy.get('.list').find('li').eq(2).children('.child').eq(3).should('contain', 'child')

// Safest: one retried callback against a freshly queried element
cy.get('.list')
  .find('li')
  .eq(2)
  .should(($li) => {
    expect($li).to.contain('Header')
    expect($li.children('.child').eq(3)).to.contain('child')
  })

.then() is not retried and breaks the chain of queries. Nothing before a .then() re-runs. The first deliberately failing test below reads the author with .invoke('text').then(...); the row says you while the review is pending and reader once it is saved, so the assertion was evaluated once, on the first value, and failed immediately:

  it('A: .then() does not retry', () => {
    cy.get('[data-testid=review-author]')
      .invoke('text')
      .then((text) => text)
      .should('eq', 'reader');
  });

The output:

AssertionError: expected 'you' to equal 'reader'

The message has no "Timed out retrying" prefix: nothing was retried. Rewrite it as a .should() callback, which Cypress re-runs, together with the query before it, until it passes or times out:

  it('retries a .should() callback, including the code that reads the DOM', () => {
    writeReview('Callback assertions retry as one unit');

    cy.get('[data-testid=review-author]').should(($author) => {
      // The text is "you" while pending and "reader" once saved; the callback reruns until it holds.
      expect($author.text()).to.equal('reader');
    });
  });

A .should() callback can run many times, so it must be safe to repeat: no clicks, no counters, no assignments. Put only reads and expect() calls in it.

Stubs and spies need an alias, not a .then(). Asserting expect(stub).to.be.calledTwice inside a .then() after a click is not retried and passes only if the call has already happened. Alias the stub and use cy.get('@name') so the assertion retries:

  it('waits on a stub through its alias instead of chaining off the click', () => {
    const onSubmit = cy.stub().as('submitted');
    cy.get('[data-testid=review-form]').then(($form) => {
      $form[0].addEventListener('submit', () => setTimeout(onSubmit, 300));
    });
    writeReview('The stub is called after a delay');

    cy.get('@submitted').should('have.been.calledOnce');
  });

Very rarely a command such as .click() must be repeated because the application attaches its listener late; the documentation points to its blog post "When Can the Test Click?" for that case. The implicit waits for actionability make the pattern unnecessary in nearly all tests.

Hard waits

cy.wait(500) pauses for a fixed time and is almost always a sign that an assertion is missing: wait for the thing the test needs (an element, an attribute, a request alias) instead. See Test Design & Page Objects for the same principle across tools. The retry mechanism is also separate from test retries, which re-run a whole failed test; those are configured with the retries option and covered in Debugging, Studio, Plugins & Reporters.

Interacting with elements

An action command simulates a user: .click(), .dblclick(), .rightclick(), .type(), .clear(), .check(), .uncheck(), .select(), .trigger() and .selectFile(). Cypress fires the events a browser would fire, so the application’s event bindings run. Before issuing any of them Cypress watches the DOM, re-running the queries that yielded the subject, until the element passes every check for the duration of defaultCommandTimeout.

The actionability checks

In order, the checks and steps are:

  1. Scroll the element into view.

  2. Ensure the element is not hidden.

  3. Ensure the element is not disabled.

  4. Ensure the element is not detached from the DOM.

  5. Ensure the element is not readonly (for .type()).

  6. Ensure the element is not animating.

  7. Ensure the element is not covered by another element.

  8. Scroll the page again if the element is still covered by a position: fixed element.

  9. Fire the event at the chosen coordinates.

When a check keeps failing until the timeout, the error explains which one. These are the real messages from deliberately failing tests with a 1-second command timeout:

`cy.click()` failed because this element is `disabled`:  <button id="go" disabled="">Go</button>
`cy.click()` failed because the center of this element is hidden from view:  <button id="go">Go</button>
`cy.click()` failed because this element is not visible:  <button id="go" style="display:none">Go</button>
  This element `<button#go>` is not visible per `Element.checkVisibility()`. Computed: `display: none`, `visibility: visible`, `opacity: 1`, `content-visibility: visible`.

Details on each check:

  • Disabled. Cypress checks the disabled property of form controls such as button and input. A disabled attribute on any other element has no effect on users and none on actionability.

  • Detached. If the application re-renders and replaces the node, Cypress re-runs the queries and looks again. It never re-runs a command, which is why chained actions are unsafe.

  • Animating. Cypress compares the element’s current and previous positions; if the distance exceeds animationDistanceThreshold (5 pixels), the element counts as animating and Cypress waits for it to stop. Set waitForAnimations: false to skip the wait.

  • Covered. Cypress checks the element’s centre coordinates. A child covering the centre is fine: the event is fired at that child, and the Command Log says so. A covering ancestor or sibling blocks the action.

  • Scrolling. Cypress always scrolls the element, including its scrollable parents, into view first, even if it was already visible, to behave the same every time. DOM queries such as cy.get() never scroll.

// Bookshelf has no covered, disabled or animated controls, so each test injects a small lab into the home page.
function lab(html: string) {
  cy.document().then((doc) => {
    const host = doc.createElement('section');
    host.id = 'lab';
    host.innerHTML = html;
    doc.body.append(host);
  });
}

function clicks() {
  return cy.window().its('labClicks');
}

describe('Actionability', () => {
  beforeEach(() => {
    cy.visit('/');
    cy.window().then((win) => {
      (win as any).labClicks = 0;
      (win as any).count = () => ((win as any).labClicks += 1);
    });
  });

  it('waits for a disabled button to become enabled', () => {
    lab('<button id="go" disabled onclick="count()">Go</button>');
    cy.window().then((win) => {
      win.setTimeout(() => win.document.querySelector<HTMLButtonElement>('#go')!.removeAttribute('disabled'), 1000);
    });

    cy.get('#go').click(); // retried until the button is enabled
    clicks().should('eq', 1);
  });

  it('waits for an overlay that covers the element to go away', () => {
    lab(`
      <button id="go" onclick="count()">Go</button>
      <div id="overlay" style="position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:99"></div>`);
    cy.window().then((win) => {
      win.setTimeout(() => win.document.getElementById('overlay')!.remove(), 1000);
    });

    cy.get('#go').click();
    clicks().should('eq', 1);
  });

  it('waits for an animation to finish before clicking', () => {
    lab(`
      <style>@keyframes slide { from { transform: translateX(0) } to { transform: translateX(600px) } }</style>
      <button id="go" onclick="count(); window.clickedAt = this.getBoundingClientRect().left"
              style="animation: slide 1s linear forwards">Go</button>`);

    // Cypress samples the position of a moving element, so let the animation start first.
    cy.get('#go').should(($go) => expect($go[0].getAnimations()[0].currentTime).to.be.greaterThan(30));
    cy.get('#go').click();
    clicks().should('eq', 1);
    cy.window().its('clickedAt').should('be.greaterThan', 590); // clicked at the end of the slide
  });

Bookshelf has no disabled, covered or animated control, so each test injects a small "lab" into the home page and counts clicks. These tests passed in both browsers. One observation is not in the documentation: an animation that has not yet moved between Cypress’s two position samples is not detected, so a click issued in the very first frame hit the button at its start position. The test therefore waits until getAnimations() reports progress before clicking. With { waitForAnimations: false } the click happens during the slide:

  it('clicks a moving element at once when waitForAnimations is off', { waitForAnimations: false }, () => {
    lab(`
      <style>@keyframes slide { from { transform: translateX(0) } to { transform: translateX(600px) } }</style>
      <button id="go" onclick="count(); window.clickedAt = this.getBoundingClientRect().left"
              style="animation: slide 1s linear forwards">Go</button>`);

    cy.get('#go').should(($go) => expect($go[0].getAnimations()[0].currentTime).to.be.greaterThan(30));
    cy.get('#go').click();
    cy.window().its('clickedAt').should('be.lessThan', 590); // clicked while still sliding
  });

Scrolling and coordinates

Cypress scrolls the top of the element to the top of its scrollable container by default. If the element ends up behind a fixed header, Cypress "nudges" the page until it is not covered. The scrollBehavior option changes where the element goes:

Value scrollIntoView() options Result

'top' (default)

{ block: 'start' }

Aligns the element to the top of its scrollable container; the horizontal position is left to the browser.

'bottom'

{ block: 'end' }

Aligns to the bottom, vertical axis only.

'start'

{ block: 'start', inline: 'start' }

Aligns both axes to the start (top-left in a left-to-right document).

'end'

{ block: 'end', inline: 'end' }

Aligns both axes to the end.

'center'

{ block: 'center', inline: 'center' }

Centres the element on both axes.

'nearest'

{ block: 'nearest', inline: 'nearest' }

Scrolls the minimum needed on both axes.

false

none

Disables scrolling before the command.

An object, such as { block: 'center', inline: 'nearest' }

each axis as given

Sets the axes independently with 'start', 'end', 'center' or 'nearest'. An axis left out falls back to the browser default ('start' for block, 'nearest' for inline).

The per-axis object form arrived in Cypress 15.20. A scrollBehavior passed to a command or to test configuration replaces the configured value instead of merging with it axis by axis, so name every axis you care about. The same option can be set in cypress.config.ts for a whole project.

  it('scrolls the element into view before acting and honours scrollBehavior', () => {
    lab('<div style="height:2500px"></div><button id="far" onclick="count()">Far away</button><div style="height:600px"></div>');

    cy.get('#far').click({ scrollBehavior: 'center' });
    cy.window().its('scrollY').should('be.greaterThan', 2000);
    cy.get('#far').then(($el) => {
      const rect = $el[0].getBoundingClientRect();
      const middle = rect.top + rect.height / 2;
      expect(middle).to.be.closeTo(Cypress.config('viewportHeight') / 2, 5);
    });
  });

  it('fires the event at a chosen position', () => {
    lab('<div id="pad" style="width:200px;height:100px;background:#ccc" onclick="window.at = [event.offsetX, event.offsetY]"></div>');

    cy.get('#pad').click('topLeft');
    cy.window().its('at').should(([x, y]: number[]) => {
      expect(x).to.be.within(0, 2); // the top-left corner, give or take a pixel of rounding
      expect(y).to.be.within(0, 2);
    });
  });

Events are normally fired at the centre of the element; most actions take a position ('topLeft', 'top', 'topRight', 'left', 'center', 'right', 'bottomLeft', 'bottom', 'bottomRight') or x/y offsets. The Command Log shows a red hitbox where the event was fired. In Chrome the top-left click landed one pixel from the corner (offset [0, 1] instead of [0, 0]), so the test accepts up to two pixels.

Visibility

Since Cypress 16 the default visibility algorithm delegates to the browser’s native Element.checkVisibility(). An element is hidden if its width or height is zero, or if its computed style hides it: display: none on the element or an ancestor, visibility: hidden or collapse, content-visibility: hidden (or auto when not rendered), and opacity: 0 when you assert visibility. The same element at opacity: 0 is still actionable, so a command acts on it.

  it('treats opacity 0 as actionable but not visible', () => {
    lab('<button id="go" onclick="count()" style="opacity:0">Go</button>');

    cy.get('#go').should('not.be.visible');
    cy.get('#go').click();
    clicks().should('eq', 1);
  });

The visibilityStrategy option selects the algorithm. 'modern' is the default; 'legacy' is the Cypress 15 algorithm that also considered elements hidden when they were clipped by an ancestor’s overflow: hidden, scrolled out of a scroll container, rotated past 90 degrees with backface-visibility: hidden, or covered while fixed or sticky. It can be set globally, or per suite or test (describe('...', { visibilityStrategy: 'legacy' }, ...)). The option and the 'legacy' value are deprecated and will be removed in a future major version, so use them only as a stop-gap. The documentation recommends rewriting should('be.visible')-then-act patterns: the action already checks for coverage, and the assertion now passes sooner than it used to, so keep it only to wait out a fade-in.

Forcing

{ force: true } on an action skips the checks and fires the event at the element anyway:

  it('does not check anything when the click is forced', () => {
    lab(`
      <button id="go" onclick="count()">Go</button>
      <div id="cover" style="position:fixed;inset:0;z-index:99" onclick="window.coverClicks = (window.coverClicks || 0) + 1"></div>`);

    cy.get('#go').click({ force: true }); // fired at the button even though the cover sits on top
    clicks().should('eq', 1);
    cy.window().its('coverClicks').should('not.exist');
  });

A forced action still performs the default actions, but it does not scroll, does not require the element to be visible, enabled, attached, writable, still or uncovered, and fires at the element instead of at a descendant. Use it to reach elements that need a very specific mouse path, and prefer fixing the cause (waiting for the overlay to close) in ordinary cases: a forced click can make a test pass for a control that users cannot use. { force: true } does not override the check for a disabled <option> or an option in a disabled <optgroup> with .select().

To debug an action that is not considered actionable, place .debug() or a debugger statement before the action and inspect the calculation with the browser’s developer tools; hovering over a command in the Command Log scrolls the element into view with a different algorithm and does not show what Cypress saw. See Debugging, Studio, Plugins & Reporters.

Variables, closures and aliases

Because commands do not return their results, this does not work, however natural it looks:

// This won't work the way you think it does
const button = cy.get('button');
const form = cy.get('form');
button.click();

button is a chainer, not an element. Three techniques replace the variable: closures, const for values that change, and aliases.

Closures

A .then() callback receives the yielded subject, and everything nested inside it can use that subject and any earlier one. This is a JavaScript closure, and the commands outside the callback do not run until all the nested ones finish.

  it('keeps values in a closure to compare them later', () => {
    cy.visit('/books');
    cy.get('[data-testid=result-count]').then(($count) => {
      const before = $count.text(); // "24 books found"
      cy.get('[data-testid=search-input]').type('quiet{enter}');
      cy.get('[data-testid=result-count]').should(($after) => {
        expect($after.text()).not.to.equal(before);
      });
      cy.get('[data-testid=result-count]').should('have.text', '1 book found');
    });
  });

  it('captures the text of an element before the page changes', () => {
    cy.visit('/books');
    cy.get('[data-testid=page-indicator]')
      .invoke('text')
      .then((text) => {
        const first = text.trim(); // "Page 1 of 4"
        cy.get('[data-testid=page-next]').click();
        cy.get('[data-testid=page-indicator]').invoke('text').should('not.equal', first);
      });
  });

Reach for a const only when the value you need to compare is mutable: capture $count.text() into a constant before the click, because the same jQuery object would show the new text afterwards. For everything else, nest the next command inside the callback and keep using the yielded value.

Aliases

Closures work inside a test but not between hooks and tests: a value found in beforeEach is not visible in the test body unless you smuggle it out through a let variable, which the documentation calls "not a great solution". .as('name') stores something under a name that later commands, hooks and the test can read.

  describe('sharing context with this', () => {
    beforeEach(() => {
      cy.visit('/books');
      cy.get('[data-testid=result-count]').invoke('text').as('initialCount');
    });

    it('reads the alias as this.initialCount in a function() test', function () {
      expect(this.initialCount).to.equal('24 books found');
    });

    it('reads the same alias with cy.get("@initialCount")', () => {
      cy.get('@initialCount').should('equal', '24 books found');
    });

    it('cannot read this.* before the alias command has run', function () {
      cy.wrap('late').as('late');
      expect(this.late).to.be.undefined; // .as() is only queued so far
      cy.then(function () {
        expect(this.late).to.equal('late');
      });
    });
  });

There are two ways to read an alias:

  • this.name uses Mocha’s shared context, so it needs function () {} callbacks for the hook and the test; an arrow function does not bind this, and this.name is undefined in it.

  • cy.get('@name') uses the @ syntax and works in arrow functions. Its value can differ from this.name, as the next sections show.

In both cases the alias exists only after the .as() command has run, not when it is queued: reading this.late straight after cy.wrap('late').as('late') gave undefined, and reading it in a later .then() gave the value.

Element aliases re-query

For DOM elements Cypress stores the query chain that produced them, not the elements. Each cy.get('@cards') re-runs the chain against the current DOM, so a re-render cannot leave you with a stale element:

  describe('aliases for elements are queries, not references', () => {
    it('re-queries the page every time the alias is used', () => {
      cy.visit('/books');
      cy.get('[data-testid=book-card]').as('cards');
      cy.get('@cards').should('have.length', 6).first().should('contain.text', 'The Quiet Compiler');

      cy.get('[data-testid=page-next]').click(); // a new document with six different cards
      cy.get('@cards').should('have.length', 6).first().should('not.contain.text', 'The Quiet Compiler');
    });

The re-query also means an alias can describe a different element later. Alias the element before you act on it (.as('user').click(), not .click().as('user')), and make the aliased query specific enough that it still matches the element you meant after the page changes. Since Cypress 12.0 every alias re-queries, and tests that relied on the earlier behaviour (for example an alias for "the collapsed button" that was later expanded) fail until the alias is made more specific or static.

To keep a value as it was, Cypress 12.4 added the type option to .as() (the migration guide for 12.0 says 12.3, the .as() page and the changelog say 12.4):

    it('freezes a value with type static', () => {
      const favourites = { colour: 'blue' };
      cy.wrap(favourites).its('colour').as('dynamicColour'); // query alias: evaluated on every use
      cy.wrap(favourites).its('colour').as('staticColour', { type: 'static' });

      cy.then(() => {
        favourites.colour = 'red';
      });

      cy.get('@dynamicColour').should('equal', 'red');
      cy.get('@staticColour').should('equal', 'blue');
    });

    it('stores a snapshot in this.* even for a query alias', function () {
      const favourites = { colour: 'blue' };
      cy.wrap(favourites).its('colour').as('colour');
      cy.then(() => {
        favourites.colour = 'red';
      });
      cy.get('@colour').then(function (value) {
        expect(value).to.equal('red'); // re-evaluated
        expect(this.colour).to.equal('blue'); // set when .as() first ran
      });
    });

The documentation says aliases are available as this.* and "cleaned up after each test". Observed on 16.2.0, a this.* value set with .as() was still readable in the next test of the same suite, even though cy.get('@name') failed there; the alias registry, not the Mocha context, is what Cypress resets. Do not rely on a leftover this.* value; create the alias in beforeEach.

The value this.colour also differs from the cy.get('@colour') result above: this.* is a snapshot taken when .as() first ran, while cy.get('@alias') re-executes the whole query chain each time.

Aliasing network traffic

cy.intercept() routes get aliases too, and cy.wait('@alias') waits for a matching request. Cypress keeps every matching request, not only the latest. cy.get('@alias') and cy.wait('@alias') give the most recent one; the .all suffix and a 1-based numeric suffix reach the history. Index 0 is not valid, and these suffixes work with cy.get() but not with cy.wait().

  describe('aliases for network traffic', () => {
    it('keeps every matching request and lets you read them by index', () => {
      cy.intercept('GET', '/api/books*').as('books');
      cy.visit('/');
      cy.window().then((win) => win.fetch('/api/books?page=1'));
      cy.wait('@books');
      cy.window().then((win) => win.fetch('/api/books?page=2'));
      cy.wait('@books');

      cy.get('@books.all').should('have.length', 2);
      cy.get('@books.1').its('request.url').should('include', 'page=1');
      cy.get('@books.2').its('request.url').should('include', 'page=2');
      cy.get('@books').its('request.url').should('include', 'page=2'); // the latest one
    });

    it('aliases a cy.request response', () => {
      cy.request('/api/books?page=1').as('page1');
      cy.get('@page1').its('body.items').should('have.length', 6);
    });
  });

The Network page covers routes, stubs and waiting in detail (Network, Stubs, Spies & Clocks). In 16.2.0 cy.get('@getUsers.0') fails at once instead of retrying until the timeout.

Aliases are reset before each test

All aliases are reset before every test, so an alias created once in a before() hook works in the first test only. The fix is beforeEach():

  describe('aliases are reset before every test', () => {
    let attempts = 0;

    beforeEach(() => {
      attempts += 1;
      cy.wrap(`created in beforeEach #${attempts}`).as('note'); // recreated for each test
    });

    it('first test', () => {
      cy.get('@note').should('equal', 'created in beforeEach #1');
    });

    it('second test gets its own alias', () => {
      cy.get('@note').should('equal', 'created in beforeEach #2');
    });
  });

The error from a deliberately failing spec that creates the alias in before():

CypressError: `cy.get()` could not find a registered alias for: `@exampleValue`.
You have not aliased anything yet.

Test isolation

Cypress’s rule is that every test must be able to run on its own and pass. To enforce it, Cypress starts each test with a clean slate, restoring and clearing all aliases, clock mocks, intercepts, spies, stubs and viewport changes. On top of that it runs end-to-end tests in a clean browser context, which is what the testIsolation option controls.

testIsolation: true (the default)

Test isolation has been enabled by default since Cypress 12.0. Before each test Cypress:

  • clears the DOM state by visiting about:blank;

  • clears cookies in all domains;

  • clears localStorage in all domains;

  • clears sessionStorage in all domains.

Because every test starts on a blank page, each test must cy.visit() the application again and rebuild the state it needs. cy.session() follows the same setting and clears the page and the browser context when it establishes a session, so that tests pass alone and in random order. Component testing always isolates and cannot be configured.

describe('testIsolation: true (the default)', () => {
  it('test 1: signs in, stores a theme, changes the viewport and stubs the API', () => {
    cy.request('POST', '/api/test/reset');
    cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => {
      cy.request('POST', '/api/session', { username: 'reader', password: READER_PASSWORD });
    });
    cy.visit('/account');
    cy.get('[data-testid=account-heading]').should('contain.text', 'Hello');

    cy.get('[data-testid=theme-toggle]').click();
    cy.window().its('localStorage').invoke('getItem', 'theme').should('match', /light|dark/);

    cy.viewport(500, 600);
    cy.intercept('GET', '/api/books*', { items: [], total: 0, page: 1, pageSize: 6, pages: 0 }).as('stubbed');
    cy.setCookie('extra', 'yes');
  });

  it('test 2: starts on about:blank with no cookies, storage, stub or viewport change', () => {
    cy.url().should('equal', 'about:blank');
    cy.getCookies().should('have.length', 0);
    cy.visit('/');
    cy.window().its('localStorage.length').should('eq', 0);
    cy.window().its('sessionStorage.length').should('eq', 0);
    cy.wrap(Cypress.config('viewportWidth')).should('equal', 1000);
    cy.window().then((win) => win.fetch('/api/books')).then((res) => res.json()).its('total').should('eq', 24);
  });

  it('test 3: reaches /account only after signing in again', () => {
    cy.visit('/account');
    cy.location('pathname').should('equal', '/signin');
  });
});

The three tests passed in order: the second one found about:blank, no cookies, empty localStorage and sessionStorage, the default viewport width (1000), and the unstubbed API (24 books), although the first one changed all of them. The third one was redirected to the sign-in page: the session cookie of test 1 was gone.

Storage that is not cleared

Only cookies, localStorage and sessionStorage are cleared. Every other storage mechanism lasts for the life of the browser, with isolation on or off, in end-to-end and component tests. IndexedDB, including data written by libraries that use it for offline caches and client-side databases, is the one to watch, and cy.session() does not save or restore it either. If a test needs IndexedDB empty, delete the database yourself, after a cy.visit() so a window is available:

describe('storage that Cypress does not clear', () => {
  it('test 1: writes an IndexedDB database', () => {
    cy.visit('/');
    cy.window().then((win) => {
      return new Cypress.Promise<void>((resolve, reject) => {
        const request = win.indexedDB.open('bookshelf-cache', 1);
        request.onsuccess = () => {
          request.result.close();
          resolve();
        };
        request.onerror = () => reject(request.error);
      });
    });
  });

  it('test 2: still sees the database, so clear it yourself', () => {
    cy.visit('/');
    cy.window().then(async (win) => {
      const names = (await win.indexedDB.databases()).map((db) => db.name);
      expect(names).to.include('bookshelf-cache');
    });
  });

  it('test 3: deletes the database it depends on being empty', () => {
    cy.visit('/');
    cy.window().then((win) => {
      return new Cypress.Promise<void>((resolve) => {
        const request = win.indexedDB.deleteDatabase('bookshelf-cache');
        request.onsuccess = () => resolve();
        request.onerror = () => resolve();
        request.onblocked = () => resolve();
      });
    });
    cy.window().then(async (win) => {
      const names = (await win.indexedDB.databases()).map((db) => db.name);
      expect(names).not.to.include('bookshelf-cache');
    });
  });
});

The second test still saw the database that the first one created. The documentation notes that indexedDB.databases() works in Chromium-based browsers and WebKit but not in Firefox, where each database must be named explicitly; the examples ran in Electron and Chrome only.

Keeping state across tests

With isolation enabled there are three ways to keep specific state:

  • Re-set a known cookie in beforeEach, for example cy.setCookie('cookieConsent', 'accepted').

  • Cache server-set cookies with cy.session(), which captures the cookies, localStorage and sessionStorage once and restores them before each test, without repeating the login. The Bookshelf cy.signIn() command is built on it; see Sessions, Authentication & Cross-Origin.

  • Turn isolation off for a suite with testIsolation: false.

testIsolation: false

With isolation off, Cypress does not alter the browser context before a test. The page is not cleared and cookies, localStorage and sessionStorage stay available across the tests of the suite; cy.session() clears the browser context when establishing a session but not the page. The option can be set globally, but a per-suite override is the usual form, and it can only be overridden at the suite level, not with Cypress.config().

testIsolation Before each test cy.session()

true

Visits about:blank; clears cookies, localStorage and sessionStorage in all domains.

Same: clears the page, cookies, localStorage and sessionStorage.

false

Does not alter the current browser context.

Clears cookies, localStorage and sessionStorage, but not the page.

describe('testIsolation: false', { testIsolation: false }, () => {
  before(() => {
    cy.request('POST', '/api/test/reset');
    cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => {
      cy.request('POST', '/api/session', { username: 'reader', password: READER_PASSWORD });
    });
    cy.visit('/books');
  });

  it('test 1: the page and the signed-in cookie are there', () => {
    cy.location('pathname').should('equal', '/books');
    cy.get('[data-testid=nav-account]').should('exist');
    cy.get('[data-testid=theme-toggle]').click();
  });

  it('test 2: still on the same page, with the cookie and the theme left by test 1', () => {
    cy.location('pathname').should('equal', '/books');
    cy.getCookie('bookshelf_session').should('exist');
    cy.window().its('localStorage').invoke('getItem', 'theme').should('match', /light|dark/);
  });
});

Test 2 started on /books, with the session cookie and the theme set by test 1 and by the before() hook. Disabling isolation can speed up a suite because the application does not have to be reloaded and signed in for every test, but it lets state leak between tests, and later tests then fail for reasons that have nothing to do with the code under test. The documented check is to add .only() to a test and verify it passes without the tests before it. Prefer cy.session() and API-seeded state to switching isolation off.

Conditional testing

Conditional testing is the pattern "if X then Y else Z": do something different depending on whether an element exists, which A/B variant was served, whether a welcome wizard appeared, or what text is on the page. It looks harmless and produces flaky tests. A web application changes continuously, and a conditional can only be trusted once the state has settled. To a human, 10 ms is invisible; to a robot it is billions of clock cycles, and it has no intuition that a spinner means "wait".

it('does something different based on the class of the button', () => {
  // Rerun this test over and over: sometimes the branch is true, sometimes false.
  cy.get('button').then(($btn) => {
    if ($btn.hasClass('active')) {
      // do something if it's active
    } else {
      // do something else
    }
  })
})

This is the documentation’s example for an element that gets its class after a random delay; it was not run against Bookshelf. The Bookshelf equivalent is the optimistic review row, whose data-state depends on how fast the server answers. The same test, branching on that attribute right after the click, took a different branch when the stubbed response arrived at once (0 ms) than when it arrived after 300 ms:

  [0, 300].forEach((latency) => {
    it(`shows why branching on in-flight UI is unsafe: the branch follows a ${latency} ms response`, () => {
      cy.signIn('reader');
      cy.intercept('POST', '/api/reviews', {
        statusCode: 201,
        body: { id: 99, bookId: 1, rating: 5, text: 'Stubbed review text', author: 'reader' },
        delay: latency,
      });
      cy.visit('/books/1');
      cy.get('[data-testid=review-rating-input]').select('5');
      cy.get('[data-testid=review-text-input]').type('Stubbed review text');
      cy.get('[data-testid=review-submit]').click();

      // Do not do this: the state depends on how fast the response arrives.
      cy.get('[data-testid=review]')
        .last()
        .then(($row) => $row.attr('data-state'))
        .then((state) => {
          cy.log(`branch taken for ${latency} ms: ${state}`);
          expect(state).to.equal(latency === 0 ? 'saved' : 'pending');
        });
    });
  });

Both tests passed, each taking its own branch. That is the point: nothing in the test says which branch it will take on a slower CI machine.

When the DOM can be trusted

Conditionals on the DOM are safe only if the DOM cannot change after load: a server-rendered page without JavaScript that modifies it, or client-side code that renders synchronously. Bookshelf’s catalogue pager is rendered by the server, so the tests below branch on it. They passed, including 25 repeated runs of the conditional with settled state:

  it('branches on server-rendered DOM that cannot change after load', () => {
    cy.visit('/books?page=4');
    cy.get('[data-testid=page-indicator]').should('contain.text', 'Page 4');

    cy.get('body').then(($body) => {
      // Safe here: the pager is rendered by the server and no script modifies it.
      if ($body.find('[data-testid=page-next]:not([aria-disabled])').length) {
        cy.get('[data-testid=page-next]').click();
      } else {
        cy.get('[data-testid=page-prev]').click();
      }
    });
    cy.get('[data-testid=page-indicator]').should('contain.text', 'Page 3');
  });

  it('branches on text only after the page has settled', () => {
    cy.visit('/books?q=zzzz');
    cy.get('body').then(($body) => {
      if ($body.text().includes('No books match')) {
        cy.get('[data-testid=empty-state]').should('be.visible');
      } else {
        cy.get('[data-testid=book-card]').should('have.length.greaterThan', 0);
      }
    });
  });

For a client-rendered page, $body.find(...) or $body.text() right after cy.visit() cannot prove the page has finished: the load event does not mean rendering is done, and you would have to be certain that no request, timer, postMessage or async code is still pending.

Strategies that do work

If the DOM is not reliable, anchor the condition to something that is. The documentation’s list, with Bookshelf equivalents that passed:

  • Remove the need for the condition. Arrange the state so only one path exists. Use a URL parameter (?campaign=A, ?wizard=0), a seeded user, or a flag you set before the test.

  • Force the application to be deterministic. Seed the data through the test API instead of hoping for it.

  • Ask another source of truth. Query your server or database with cy.request() or cy.task(), or read a cookie with cy.getCookie(), and branch on that.

  • Embed the data in the page in an always-present way, such as a data-campaign attribute on html that you can .should('have.attr', ...) before reading.

  it('asks the server which state the session is in instead of inspecting the DOM', () => {
    cy.visit('/books/1');
    cy.request('/api/session')
      .its('body.authenticated')
      .then((authenticated) => {
        if (authenticated) {
          cy.get('[data-testid=review-form]').should('be.visible');
        } else {
          cy.get('[data-testid=signin-prompt]').should('be.visible');
        }
      });
  });

  it('makes the application deterministic instead of branching on it', () => {
    // Arrange the state through the test API: no sign-in prompt, no "maybe" in the test.
    cy.request('POST', '/api/test/seed', { books: [{ title: 'Only Book', author: 'Test Author' }], reviews: [] });
    cy.visit('/books/1');
    cy.get('[data-testid=no-reviews]').should('be.visible');
    cy.get('[data-testid=signin-prompt]').should('be.visible');
  });

Ending a test early

There is no API to stop a test midway and count it as passed, because a test ends as passed, failed or pending (skipped). To "stop here", do not enqueue the remaining commands: make the decision inside a .then(), and the commands inside the callback are simply not added. A return only prevents commands inside that callback; commands already enqueued at the top level still run. To abort as a failure, throw an error; to mark the test skipped, call Mocha’s this.skip(), which needs a function () {} callback.

  it('ends a test early by not enqueueing more commands', () => {
    cy.visit('/books');
    cy.get('a').then(($links) => {
      const hasAdmin = [...$links].some((el) => el.innerText.trim() === 'Admin');
      if (!hasAdmin) {
        return; // nothing else is enqueued from here, the test ends as passed
      }
      cy.get('[data-testid=nav-admin]').click();
    });
  });

  it('skips itself at run time with this.skip()', function () {
    cy.getCookie('bookshelf_session').then((cookie) => {
      if (!cookie) {
        this.skip();
      }
      cy.log('never reached: no session cookie in a fresh test');
    });
  });

In Bookshelf the second test is reported as skipped (pending) because a fresh test has no session cookie; both tests ran in both browsers.

Error recovery does not exist

People ask for .catch() to "try X, and if it fails go find Y". It is the same question as conditional testing in another form. A failed command is like an uncaught exception on a server: the system is in an unknown state, so the sensible reaction is to stop and log. If Cypress did recover, every command would first wait out its timeout (4 seconds or more), and a late arrival of the element you gave up on could break a later command. A chain has no .catch():

  it('has no error handling on a command chain', () => {
    expect((cy.get('body') as any).catch).to.be.undefined;
  });

To find out whether a test is flaky, repeat it many times, then repeat again with the network and CPU throttled in the browser’s developer tools, which simulates slower environments like CI. A good test passes or fails 100 per cent of the time. The documentation generates the repeats with Cypress._.times(100, ...); the Bookshelf run uses 25:

  Cypress._.times(25, (i) => {
    it(`passes every time when the branch uses settled state (run ${i + 1})`, () => {
      cy.visit('/books?q=quiet');
      cy.get('body').then(($body) => {
        if ($body.find('[data-testid=book-card]').length) {
          cy.get('[data-testid=book-card]').should('have.length', 1);
        } else {
          cy.get('[data-testid=empty-state]').should('exist');
        }
      });
    });
  });

Trade-offs of the Cypress architecture

Cypress’s design gives it things other tools cannot offer — native access to the application under test, retry-ability, time travel — and it comes with restrictions that the documentation splits into permanent ones and temporary ones. Weigh them against the other tools in Choosing a Tool.

Permanent trade-offs

  • It is not a general-purpose automation tool. It is built to test your own application, not for indexing or spidering the web, performance testing or scripting third-party sites.

  • Tests run inside the browser. There is no JSON wire protocol and no object serialization, and you have real access to the application’s objects. The consequence is that test code is JavaScript or TypeScript evaluated in the browser, not in Node.js: you cannot import server libraries (a database driver) in a spec, although node_modules that work in a browser are fine. To reach the back end, use cy.task() (Node code registered in setupNodeEvents) or cy.request(), and expose a way to seed and reset your data, as Bookshelf’s /api/test/* endpoints do.

  • One browser at a time. Cypress cannot drive two browsers simultaneously. For a chat or collaboration feature, the documentation proposes three levels: stub everything in the browser; keep the browser and have your server simulate "the other participant"; or run a separate process (for example a small HTTP server holding a socket.io client) that you control with cy.request(). Multiple tabs can be tested with the @cypress/puppeteer plugin.

  • One superdomain per test, unless you use cy.origin() for cross-origin navigation inside a test (Sessions, Authentication & Cross-Origin).

  • No interception of WebSocket frames. WebSocket connections work, but stubbing individual messages is not supported natively.

Temporary trade-offs that Cypress hopes to address: there is no cy.hover() command (use .trigger() or the documented workarounds), no native or mobile events, and iframe support is limited — same-origin frames can be queried, while a command that "switches into" a frame is an open proposal.

Several of these are the flip side of strengths: because the test runs next to the application, Cypress can retry, snapshot and stub synchronously. The two other tools take the opposite choice (a remote protocol, so any language and any number of browsers or tabs), which is why How Browser Automation Works compares the architectures before the tool pages.

The other core-concept guides

Cypress’s documentation has nine core-concept guides. The ones above are the heart; the other four are summarised here and covered where they fit best.

Guide What it adds

Best practices

Organizing tests, logging in and controlling state; selecting elements with data-* attributes rather than classes or ids; never relying on the state of previous tests; combining small assertions where tiny tests only add overhead; cleaning state before each test with beforeEach, not in after/afterEach; avoiding unnecessary waits; setting a global baseUrl; not visiting external sites; and handling secrets with cy.env() and public values with Cypress.expose(). See Test Design & Page Objects and Flaky Tests & Reliability.

Open mode

The interactive workflow of cypress open: the Launchpad, the Specs page, the Debug and Runs pages for recorded runs, the Test Runner with its Command Log, time travel and pinned snapshots (50 tests of snapshots are kept in memory in open mode by default, numTestsKeptInMemory), the instrument panel for routes, stubs and spies, the application preview, Studio, keyboard shortcuts (r reruns, s stops, f focuses the spec list) and the special commands cy.pause() and cy.debug(). See Debugging, Studio, Plugins & Reporters.

Testing types

Choosing between end-to-end, component, API and accessibility tests. End-to-end tests exercise every layer and cost more to set up; component tests mount one component in isolation (cy.mount()); API tests use cy.request() and prepare state quickly; accessibility checks layer onto the other three. See Component Testing and Accessibility Checks in E2E Tests.

Writing and organizing tests

The scaffolded folder structure, specPattern and the .cy. spec naming convention, excludeSpecPattern and --spec, the support file that runs before every spec, fixtures, setupNodeEvents, the downloads, screenshots and videos folders, Mocha’s describe/context/it/specify, hooks and their order, .only and .skip, test configuration as the second argument, and dynamically generated tests. See Getting Started.

Running the examples

The specs live in a Cypress project with this cypress.config.ts (the base URL comes from the environment so the same specs run against a container or a staging host):

import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
    video: false,
    screenshotsFolder: 'cypress/screenshots',
    supportFile: 'cypress/support/e2e.ts',
    specPattern: 'cypress/e2e/**/*.cy.ts',
    setupNodeEvents() {},
  },
});

and this support file, which provides the cy.signIn() command the sign-in steps use. It reads the password with cy.env(), so it never appears in a test, and cy.session() caches the cookie with a validate check because /api/test/reset clears every server-side session:

declare global {
  namespace Cypress {
    interface Chainable {
      /** Sign in through the API once per user and reuse the cached session. */
      signIn(username: string): Chainable<void>;
    }
  }
}

Cypress.Commands.add('signIn', (username: string) => {
  cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => {
    cy.session(
      username,
      () => {
        cy.request('POST', '/api/session', { username, password: READER_PASSWORD });
      },
      {
        // /api/test/reset clears the server-side sessions, so re-check the cached one.
        validate: () => {
          cy.request('/api/session').its('body.authenticated').should('eq', true);
        },
      },
    );
  });
});

export {};
# Bookshelf on another port, then the whole project in Electron and in Chrome
CYPRESS_READER_PASSWORD="$BOOKSHELF_READER_PASSWORD" BASE_URL=http://localhost:3922 npx cypress run --browser electron
CYPRESS_READER_PASSWORD="$BOOKSHELF_READER_PASSWORD" BASE_URL=http://localhost:3922 npx cypress run --browser chrome

Results on 25 October 2026 with Cypress 16.2.0: six specs and 75 tests ran in each browser, 74 passed and one test (this.skip()) was reported as pending, in about 15 seconds. CYPRESS_READER_PASSWORD reached cy.env() with no entry in the configuration file; when the variable is missing, cy.env(['READER_PASSWORD']) yields an empty object rather than failing, so the sign-in request carries no password. The deliberately failing specs quoted above were run separately and are not part of the 75. In 16.2.0 cypress run --browser electron prints "Electron 146 (headless) (deprecated)" and recommends switching to Chrome or another installed browser.

What changed recently

Version Change

16.2.0

Adds the cy.getByRole() query (see Queries, Assertions & Custom Commands).

16.0.0

The modern visibility algorithm (the browser’s Element.checkVisibility()) is the default, with the deprecated visibilityStrategy: 'legacy' as an opt-out, replacing the experimental experimentalFastVisibility. cy.getCookie(), cy.getCookies(), cy.getAllCookies(), cy.getAllLocalStorage() and cy.getAllSessionStorage() become retry-able queries and can no longer be overwritten with Cypress.Commands.overwrite(). cy.type() no longer waits 10 ms between keystrokes (keystrokeDelay defaults to 0). cy.end() and cy.exec() are removed, and Cypress.env() is replaced by cy.env() and Cypress.expose(). viewportWidth, viewportHeight and blockHosts can no longer be set with Cypress.config() during a test; use cy.viewport() or test configuration.

15.20.0

scrollBehavior accepts a per-axis object, and the 'start' and 'end' alignments.

15.19.0

The bundled Chai moves from 4.2.0 to 4.5.0, adding assertion aliases such as .exists, .greaterThanOrEqual, .lessThanOrEqual and .oneOf chained with .contain.

14.3.0

cy.press() dispatches native Tab key events.

13.0.0

cy.readFile() becomes a retry-able query.

12.4.0

.as() takes a type option ('query' or 'static') to opt back into a fixed value.

12.0.0

Queries are introduced and aliases always re-query. Test isolation (testIsolation, default true) is enabled, cy.session() and cy.origin() reach general availability, and Cypress.Cookies.preserveOnce, cy.server() and cy.route() are removed.