QA Interview Hub · Knowledge base · Quizzes
Cypress
Command queue and asynchrony
why there is no await
Why can't you write await cy.get() in Cypress?
Cypress commands return not a Promise but a Chainable object. Calling cy.get() executes nothing at that moment: it only puts a command in the queue.
When the test function finishes, Cypress itself runs the queue in order: each command waits for the previous one. So await here either does nothing or breaks the execution order. Values are obtained through .then() and checked with .should().
// ❌ doesn't work the way you expect
const text = await cy.get('h1').invoke('text');
// ✅ the value, through .then
cy.get('h1').invoke('text').then((text) => {
expect(text).to.contain('Cart');
});
// ✅ the check, through should (with retries)
cy.get('h1').should('contain', 'Cart');
In an interview: Because Cypress commands return not a Promise but a chainable object. A cy.get call only puts the command in the queue, and Cypress itself runs that queue, sequentially, after the test body has finished. Await here either does nothing or breaks the order. I get values through then, and check through should, because should is also retried.
And in Playwright:In Playwright it is the opposite: every action returns a real Promise, and await is mandatory. A typical mistake there is a forgotten await: the action isn't awaited, and the test becomes flaky. The ESLint rule no-floating-promises catches it.
const text = await page.locator('h1').textContent();
await expect(page.locator('h1')).toContainText('Cart');
What is a subject and how do commands pass values along a chain?
Each command "yields" a result to the next one, and that is the subject. cy.get('li') yields a jQuery collection of elements, .its('length') yields a number, .invoke('text') a string.
There are commands that start a new chain (cy.get, cy.visit, cy.request), and commands that work with the previous one's subject (.find, .click, .should). Actions like .click() usually yield the same element onward.
cy.get('.cart-item') // yields: all the cart elements
.its('length') // yields: a number
.should('eq', 3);
cy.get('[data-cy=email]')
.type('qa@test.com') // yields: the same input
.should('have.value', 'qa@test.com');
In an interview: A subject is the result that one command passes to the next one in the chain. For example, cy.get passes the found elements, its length passes a number, invoke text passes a string. Some commands start a new chain, like cy.get or cy.request, and others work with what came from the previous one, like find, click or should.
And in Playwright:There is no subject chain. A locator is an ordinary object, it can be saved into a variable and narrowed (locator.locator()), and the value is returned by await.
How do you keep a value between commands: variables, .then, aliases?
The statement const el = cy.get('h1') doesn't give an element: it is a Chainable. There are three correct ways:
- .then(): you work with the value inside the callback.
- An alias through
.as('name'), then cy.get('@name'). For the DOM and for intercepts the alias looks up the current value again.
- this.name: only in an ordinary
function(), not in an arrow function, because an arrow has no this of its own.
Aliases are cleared between tests, so create them in beforeEach.
beforeEach(() => {
cy.fixture('user.json').as('user');
});
it('login', function () { // function, not an arrow
cy.get('[data-cy=email]').type(this.user.email);
});
cy.get('[data-cy=total]').invoke('text').as('total');
cy.get('@total').should('match', /\d+/);
In an interview: An ordinary variable from cy.get is not an element but a chainable, so that doesn't work. I pass values either through then, or through an alias: I mark the result with as and then get it with cy.get and the at sign. You can also use this, but only in an ordinary function, because in an arrow function this is not the right one. Aliases are reset between tests, so I create them in beforeEach.
And in Playwright:Aliases aren't needed: the value goes into an ordinary variable.
const total = await page.getByTestId('total').textContent();
const user = JSON.parse(fs.readFileSync('data/user.json', 'utf8'));
How does .then() differ from .should() with a callback?
.should(cb) is retried: Cypress re-executes the preceding queries and the callback until all the assertions inside pass or the timeout expires. So you can't call other cy commands or cause side effects in it.
.then(cb) runs once. It is for getting a value and starting the following commands.
// ✅ retried until the condition becomes true
cy.get('.status').should(($el) => {
expect($el.text().trim()).to.eq('Paid');
});
// ⚠️ runs once: if the status hasn't updated yet, it fails
cy.get('.status').then(($el) => {
expect($el.text().trim()).to.eq('Paid');
});
In an interview: Should with a callback is retried: Cypress repeats the element search and the check until the assertion passes. So inside should you can't call other cy commands. Then runs exactly once, it is for getting a value and continuing the chain. If I check a state that changes over time, I choose should.
And in Playwright:The equivalent of .should(cb) with retries is expect.poll() for a value or expect(async () => { ... }).toPass() for a block of assertions. A one-off check is expect(await locator.textContent()).toBe(...), like .then in Cypress.
How do you combine Cypress with Promises and ordinary async code?
cy.wrap(promise) adds a Promise to the queue: Cypress waits for it to resolve and passes the value on. If the Promise rejects, the test fails.
For code that must run in Node (a database, files, token generation), cy.task is used: it returns a value or a Promise from the Node process into the test.
The main rule is not to mix: don't return a Promise from a test and don't await cy commands in one block.
const getToken = () => fetch('/api/token').then((r) => r.json());
cy.wrap(getToken()).its('token').then((token) => {
cy.setCookie('auth', token);
});
cy.task('db:getUser', { email: 'qa@test.com' })
.its('status')
.should('eq', 'active');
In an interview: For a Promise there is cy.wrap: I wrap the Promise, Cypress waits for it in the queue and passes the value on. And code that must run in Node, such as database queries, I move into cy.task, which returns the result straight into the test. The main thing is not to mix the Cypress queue and await in one place, because the order of execution becomes unpredictable.
And in Playwright:There is no mixing problem: Playwright is ordinary async/await, so Promise.all and any libraries work. An equivalent of cy.task isn't needed, because the test runs in Node anyway: you can go to the database directly from the test.
The asynchrony of Cypress commands
Cypress commands don't run immediately: when called they are only added to a queue, and run sequentially later. They return not a promise but a chain object (chainable), so await cy.get(...) won't work as you expect. Mixing async/await with commands breaks the queue order.
Because of this you can't just assign a result to a variable: const text = cy.get('h1').text() won't give the text. To get a value you use .then(), which receives the result of the previous command. For reuse between steps there are aliases through .as() and cy.get('@alias').
Ordinary JS code outside commands runs immediately, before the commands in the queue, which is a frequent source of bugs. So conditional logic and calculations with values from the page are placed inside .then().