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:
-
The test function (and its
beforeEachhooks) runs once, top to bottom, as ordinary synchronous JavaScript. Eachcy.*call does nothing yet: it appends a command to Cypress’s queue and returns at once. -
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. -
Each command yields a value, the subject, to the command chained after it. The chain ends when a new
cy.*starts one. -
If a command or assertion fails after its retries are exhausted, the test fails, the remaining commands in the queue are skipped and the
afterEachhooks run.
The figure shows the loop for the three kinds of command (the next sections explain each kind):
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()yieldsnull, 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 followscyand only the subject’s descendants when it follows an element. Commands such ascy.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
undefinedbecomes 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 yieldsnull, 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>
});
Source: Cypress — .then()
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 |
|
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 |
|
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 |
|
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
Source: Cypress — Retry-ability
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 unlessfailOnStatusCodeisfalse). -
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 |
|---|---|---|
|
|
Most DOM commands: queries, assertions and the wait for actionability. |
|
|
Page transitions, |
|
|
A |
|
|
|
|
|
The response to |
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'.
Source: Cypress — Retry-ability
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()`.
Source: Cypress — Retry-ability
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:
-
Scroll the element into view.
-
Ensure the element is not hidden.
-
Ensure the element is not disabled.
-
Ensure the element is not detached from the DOM.
-
Ensure the element is not
readonly(for.type()). -
Ensure the element is not animating.
-
Ensure the element is not covered by another element.
-
Scroll the page again if the element is still covered by a
position: fixedelement. -
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
disabledproperty of form controls such asbuttonandinput. Adisabledattribute 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. SetwaitForAnimations: falseto 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 |
|---|---|---|
|
|
Aligns the element to the top of its scrollable container; the horizontal position is left to the browser. |
|
|
Aligns to the bottom, vertical axis only. |
|
|
Aligns both axes to the start (top-left in a left-to-right document). |
|
|
Aligns both axes to the end. |
|
|
Centres the element on both axes. |
|
|
Scrolls the minimum needed on both axes. |
|
none |
Disables scrolling before the command. |
An object, such as |
each axis as given |
Sets the axes independently with |
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();
Source: Cypress — Variables and aliases
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.nameuses Mocha’s shared context, so it needsfunction () {}callbacks for the hook and the test; an arrow function does not bindthis, andthis.nameisundefinedin it. -
cy.get('@name')uses the@syntax and works in arrow functions. Its value can differ fromthis.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
});
});
Source: Cypress — .as()
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.
Source: Cypress — Variables and aliases
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
localStoragein all domains; -
clears
sessionStoragein 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');
});
});
Source: Cypress — Test isolation
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 examplecy.setCookie('cookieConsent', 'accepted'). -
Cache server-set cookies with
cy.session(), which captures the cookies,localStorageandsessionStorageonce and restores them before each test, without repeating the login. The Bookshelfcy.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() |
|---|---|---|
|
Visits |
Same: clears the page, cookies, |
|
Does not alter the current browser context. |
Clears cookies, |
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');
});
});
});
Source: Cypress — Conditional testing
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()orcy.task(), or read a cookie withcy.getCookie(), and branch on that. -
Embed the data in the page in an always-present way, such as a
data-campaignattribute onhtmlthat 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_modulesthat work in a browser are fine. To reach the back end, usecy.task()(Node code registered insetupNodeEvents) orcy.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/puppeteerplugin. -
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 |
Open mode |
The interactive workflow of |
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 ( |
Writing and organizing tests |
The scaffolded folder structure, |
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() {},
},
});
Source: Cypress — Configuration
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 {};
Source: Cypress — cy.session(), Cypress — cy.env()
# 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
Source: Cypress — Command line
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 |
16.0.0 |
The |
15.20.0 |
|
15.19.0 |
The bundled Chai moves from 4.2.0 to 4.5.0, adding assertion aliases such as |
14.3.0 |
|
13.0.0 |
|
12.4.0 |
|
12.0.0 |
Queries are introduced and aliases always re-query. Test isolation ( |
Source: Cypress — Changelog