Cypress is a JavaScript testing framework that runs directly inside the browser. It became popular because tests are easy to write and easy to watch: you see every command execute step by step. Many companies use Cypress for their end-to-end test suites, and it appears in many QA job descriptions.
You can run every example from this tutorial in the TestAcademy editor.
Your first Cypress test
describe('Login', () => {
it('signs in with valid credentials', () => {
cy.visit('/');
cy.get('[data-testid="email-input"]').type('demo@nimbus.app');
cy.get('[data-testid="password-input"]').type('secure123');
cy.get('[data-testid="login-button"]').click();
cy.get('[data-testid="welcome-message"]').should('contain', 'Welcome back');
cy.url().should('include', '/dashboard');
});
});
Structure:
describegroups related tests.itis one test.cy.*commands do the work: visit, find, act, assert.
The command chain, Cypress's special model
Look closely: there is no await anywhere. This is the biggest difference from Playwright or Selenium.
Cypress commands do not run immediately. Each command goes into a queue, and Cypress runs the queue in order after the test function finishes. That is why you chain commands instead of awaiting them:
cy.get('[data-testid="search-input"]') // find the input
.type('tent') // then type into it
.should('have.value', 'tent'); // then assert its value
Because of this model, never mix async/await or normal variables with cy commands:
// WRONG — text is not the element's text, commands have not run yet
const text = cy.get('h1').getText();
// RIGHT — use .then() to work with values
cy.get('h1').then(($el) => {
expect($el.textContent).to.contain('Sign in');
});
Finding elements
cy.get('[data-testid="login-button"]') // by CSS selector
cy.contains('Sign in') // by text anywhere on the page
cy.contains('button', 'Sign in') // by text inside a specific tag
cy.get('li').first() // first match
cy.get('li').last() // last match
cy.get('li').eq(2) // third match (0-based)
cy.get('table').find('tr') // search inside an element
The Cypress team recommends dedicated test attributes like data-testid, they do not change when styling changes.
Actions
cy.get('#email').type('demo@nimbus.app'); // type text
cy.get('#email').clear(); // clear an input
cy.get('#search').type('tent{enter}'); // special keys in braces
cy.get('#terms').check(); // checkbox on
cy.get('#country').select('Germany'); // select an option
cy.get('#save').click(); // click
Assertions with should
Assertions attach to the end of a chain:
cy.get('.toast').should('be.visible');
cy.get('.toast').should('contain', 'added to cart');
cy.get('#cart-count').should('have.text', '1');
cy.get('#email').should('have.value', 'demo@nimbus.app');
cy.get('li').should('have.length', 8);
cy.get('#save').should('be.disabled');
cy.get('.error').should('not.exist');
cy.url().should('include', '/dashboard');
Chain more checks with .and():
cy.get('.toast')
.should('be.visible')
.and('contain', 'Contact created');
Retry-ability: why Cypress tests are stable
When an assertion fails, Cypress does not give up. It retries the query and the assertion until it passes or a timeout (4 seconds by default) is reached.
So when your app needs a second to show a toast message, this just works:
cy.get('[data-testid="save-contact"]').click();
// The app saves for ~700ms — Cypress retries until the toast appears
cy.get('[data-testid="toast"]').should('contain', 'Contact created');
Important detail: Cypress retries queries and assertions, but never actions like click. An action runs once. So put your should on the element that proves the app is ready.
Shared setup with beforeEach
describe('Cart', () => {
beforeEach(() => {
cy.visit('/');
});
it('starts empty', () => {
cy.get('[data-testid="cart-count"]').should('have.text', '0');
});
it('counts added products', () => {
cy.get('[data-testid="add-to-cart"]').first().click();
cy.get('[data-testid="cart-count"]').should('have.text', '1');
});
});
Common mistakes
- Using await on cy commands. Cypress commands are not promises. Use chaining and
.then(). - Saving elements in variables.
const btn = cy.get('#save')does not hold an element. Re-query instead, queries are cheap and retried. - Fixed waits.
cy.wait(3000)is almost always wrong. Assert on a condition and let retry-ability wait for you. - Selectors tied to styling.
.btn.btn-primary.mt-2breaks with any design change. Usedata-testid. - Tests that depend on each other. Every
itmust work alone. UsebeforeEachfor setup.
Best practices
- One behavior per test, named clearly:
'shows an error for a wrong password'. - Prefer
data-testidselectors; usecontainsfor user-visible text. - End every meaningful action with an assertion that proves the result.
- Keep tests independent and let
beforeEachreset the starting point.
Keep going
Compare the command-queue style with Playwright's async style in Playwright vs Cypress, then practice: the the Cypress editor has a storefront app with challenges from a first assertion to a full checkout test.