Cypress Tutorial for Beginners: Your First Test (2026)

15 min readupdated September 10, 2026

You write your first Cypress test, run it, and it fails. The element was not found. You look at the app and the element is clearly there.

The test may have checked before the page was ready. Cypress is built to reduce that problem. Once you understand its retrying behaviour, the framework is easier to use.

Cypress is a test automation tool that runs your test inside the browser, in the same tab as your app. That single design choice explains its best feature and most of its limits. This tutorial takes you from an empty folder to a suite you can run in CI.

You do not need any setup to follow along. Every example runs in the TestAcademy editor.

Install Cypress and open it

You need Node.js and a project with a package.json.

npm install cypress --save-dev

Then open the app:

npx cypress open

The Cypress app launches and asks whether you want end-to-end testing or component testing. Pick end-to-end (E2E) testing. Cypress then offers to create its starter files for you. Accept.

What Cypress creates

cypress.config.js            configuration
cypress/e2e/                 your test files live here
cypress/fixtures/example.json  sample test data
cypress/support/e2e.js       runs before every spec file
cypress/support/commands.js  your own custom commands

One filename rule can silently stop a test from running. Cypress does not run every file in that folder. It only loads files matching the default pattern cypress/e2e/**/*.cy.{js,jsx,ts,tsx}, which needs .cy. in the filename. Name a file login.js and it will be silently ignored. Name it login.cy.js and it runs.

The config file is small to begin with:

const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    viewportWidth: 1280,
    viewportHeight: 720,
  },
});

baseUrl lets you write cy.visit('/login') instead of the full address. Setting the viewport explicitly is worth doing on day one, because a different window size in CI is a classic cause of tests that pass locally and fail on the build server.

Your first test, line by line

describe('Login page', () => {
  it('signs in with valid details', () => {
    cy.visit('/');
    cy.get('[data-testid="email"]').type('demo@nimbus.app');
    cy.get('[data-testid="password"]').type('secure123');
    cy.get('[data-testid="submit"]').click();
    cy.get('[data-testid="welcome-message"]').should('contain.text', 'Welcome back');
  });
});

describe groups related tests. The string is a label that appears in the report.

it is one test. Write the name as a behaviour, so a failure reads like a bug report. "signs in with valid details" tells you what broke.

cy.visit opens the page.

cy.get finds elements with a CSS selector.

.type and .click act on what was found.

.should is the assertion. It is the only line that can fail this test.

Notice there is no await anywhere. That is not an oversight, and it is the thing you have to understand next.

The command queue: the one concept that matters

Cypress commands do not run when you write them. They are added to a queue, and Cypress runs the queue afterwards, in order, waiting for each step.

This is why the following does not work:

// Wrong. cy.get() queues a command. It does not return an element.
const button = cy.get('[data-testid="submit"]');
button.click(); // not what you think

And why this never works either:

// Wrong. There is nothing to await. Cypress is not promise based.
const text = await cy.get('[data-testid="cart-count"]').text();

You work with values inside the chain instead:

cy.get('[data-testid="cart-count"]').should('have.text', '1');

// Or reach the value with .then() when you genuinely need it
cy.get('[data-testid="cart-count"]').then(($el) => {
  expect($el.text()).to.equal('1');
});

If you are coming from Playwright and reaching for await, this is the adjustment. If you learn nothing else today, learn this.

Retry-ability: why you rarely write a wait

Cypress retries. When a command or assertion does not succeed immediately, it tries again until it passes or the timeout is reached. The default is four seconds.

So this works even when the app takes two seconds to respond:

cy.get('[data-testid="submit"]').click();
cy.get('[data-testid="toast"]').should('be.visible');

There is one rule that trips people up. Cypress only retries the last command in a chain. So put the assertion where the retry needs to happen:

// Fragile. The .find() runs once against whatever was there at that moment.
cy.get('.list').find('.item').should('have.length', 3);

// Better. The assertion is attached to the query that needs retrying.
cy.get('.list .item').should('have.length', 3);

And never do this:

cy.wait(3000); // do not do this

A fixed wait is a bet on a number. On a slow day it is not enough, and on a fast day you wasted three seconds. Wait for a condition instead. The rules across all three major tools are compared in waiting in Cypress, Playwright and Selenium.

Selectors: how to find things reliably

Cypress uses CSS selectors by default:

cy.get('#email')                       // by id
cy.get('.product-card')                // by class
cy.get('[data-testid="submit"]')       // by test id, the recommended one
cy.contains('Add to cart')             // by visible text
cy.contains('[role="alert"]', 'Error') // text within a selector

Cypress's own guidance is to use dedicated data-* attributes rather than CSS classes, because classes exist for styling and change whenever the design does.

Prefer this:

cy.get('[data-testid="submit"]').click();

Over this:

cy.get('.btn.btn-primary.mt-4').click(); // breaks the next time someone restyles

I have seen a suite go entirely red because a designer swapped a utility class across the app. Not one of those failures was a real bug.

Narrowing down. Chain to get more specific rather than reaching for an index:

cy.get('[data-testid="product-card"]')
  .contains('Alpine Tent')
  .parents('[data-testid="product-card"]')
  .find('button')
  .click();

Actions

cy.get(sel).click();
cy.get(sel).dblclick();
cy.get(sel).type('some text');
cy.get(sel).clear();
cy.get(sel).type('{enter}');       // special keys in braces
cy.get(sel).check();               // checkbox or radio
cy.get(sel).uncheck();
cy.get(sel).select('DE');          // a select dropdown
cy.get(sel).trigger('mouseover');
cy.get(sel).selectFile('a.pdf');   // file input

Before acting, Cypress checks that the element is visible, not disabled, not covered by something else and not animating. If a modal is over your button, the click waits rather than landing on the overlay.

Assertions

Cypress uses Chai, so assertions read close to English:

cy.get(sel).should('be.visible');
cy.get(sel).should('not.exist');
cy.get(sel).should('have.text', 'Order confirmed');
cy.get(sel).should('contain.text', 'confirmed');
cy.get(sel).should('have.value', 'demo@nimbus.app');
cy.get(sel).should('have.length', 8);
cy.get(sel).should('have.class', 'active');
cy.get(sel).should('be.checked');
cy.url().should('include', '/dashboard');
cy.title().should('eq', 'Dashboard');

Chain several with .and:

cy.get('[data-testid="toast"]')
  .should('be.visible')
  .and('contain.text', 'Saved');

not.exist versus not.be.visible is worth knowing. not.exist means it is not in the DOM at all. not.be.visible means it is there but hidden. Choosing the wrong one gives you a test that passes for the wrong reason.

Hooks and shared setup

describe('Contacts', () => {
  beforeEach(() => {
    cy.visit('/contacts');
  });

  it('adds a contact', () => {
    cy.get('[data-testid="new-contact"]').click();
    cy.get('[data-testid="name"]').type('Ada Lovelace');
    cy.get('[data-testid="save"]').click();
    cy.contains('[data-testid="contact-row"]', 'Ada Lovelace').should('exist');
  });
});

beforeEach runs before each test. Use it for navigation and setup you repeat, not for building data that a later test depends on.

Cypress clears cookies and local storage between tests by default. That is deliberate: every test should stand alone.

Network control with cy.intercept

Because Cypress lives in the browser, it can see and change network traffic. This is one of its best features.

Wait for a real request instead of guessing:

cy.intercept('GET', '**/api/contacts').as('getContacts');
cy.visit('/contacts');
cy.wait('@getContacts');
cy.get('[data-testid="contact-row"]').should('have.length', 8);

Note that cy.wait('@alias') waits for a named request. That is completely different from cy.wait(3000), and it is good practice rather than bad.

Return fake data so a test does not depend on a real server:

cy.intercept('GET', '**/api/contacts', { body: [] }).as('empty');
cy.visit('/contacts');
cy.contains('No contacts yet').should('be.visible');

Force an error to test the unhappy path, which is otherwise very hard:

cy.intercept('GET', '**/api/contacts', { statusCode: 500 });
cy.visit('/contacts');
cy.contains('Something went wrong').should('be.visible');

Custom commands

When you repeat the same steps, put them in cypress/support/commands.js:

Cypress.Commands.add('login', (email, password) => {
  cy.visit('/');
  cy.get('[data-testid="email"]').type(email);
  cy.get('[data-testid="password"]').type(password);
  cy.get('[data-testid="submit"]').click();
  cy.get('[data-testid="welcome-message"]').should('be.visible');
});

Then use it anywhere:

cy.login('demo@nimbus.app', 'secure123');

Keep testing the login form itself in its own test, without the shortcut.

Running tests

npx cypress open                          # interactive app
npx cypress run                           # headless, what CI uses
npx cypress run --spec "cypress/e2e/login.cy.js"
npx cypress run --browser chrome

cypress open is where you write tests. cypress run is where you verify them.

Debugging: the time-travel runner

This is what Cypress is famous for, and it is genuinely the friendliest debugger of any of these tools.

The runner lists every command as it executes. Hover over a step and the app snaps back to how it looked at that exact moment. Click a step and the details go to the browser console, including what the selector matched.

Two more tools:

cy.get('[data-testid="cart"]').debug();  // pause and inspect in devtools
cy.pause();                              // stop and step through manually

Running in CI

npx cypress run

The parts that matter in any CI system:

  • Start your app first, and wait for it to answer before running the tests.
  • Set the viewport in the config so CI matches your machine.
  • Save screenshots and videos as artifacts. Cypress captures a screenshot on failure automatically.

One honest note about cost. The Cypress runner is free and open source. Splitting a suite across several CI machines with balanced timing and a merged report is Cypress Cloud, which is paid above a free tier. For a small suite this never comes up. For a forty-minute suite it becomes a real decision. We cover the general approach in running tests in CI.

Common errors and what they mean

Error What it means Fix
Timed out retrying after 4000ms The element or condition never arrived Check the selector first, then the app. Full guide
element is detached from the DOM The page re-rendered between finding and using it Re-query inside the chain. Full guide
element is being covered by another element A modal or overlay is on top Wait for the overlay to go. Full guide
cy.click() can only be called on a single element Your selector matched several Narrow it or use .first()
Test file not running at all The filename has no .cy. in it Rename to name.cy.js

Five mistakes to avoid in your first Cypress tests

  1. Trying to await a cy command. Cypress is not promise based. Work inside the chain.
  2. Assigning a command to a variable. const el = cy.get(...) does not give you an element.
  3. Using cy.wait(3000). Wait for a condition or a named request instead.
  4. Selecting by styling classes. Use data-testid.
  5. Tests that depend on each other. Cypress clears state between tests on purpose. Let each one set itself up.

Frequently asked questions

Do I need to know JavaScript to use Cypress? Basic JavaScript is enough: variables, functions and arrow functions. You do not need promises or async/await, because Cypress does not use them.

Why can I not use async/await in Cypress? Commands are queued rather than executed immediately, so there is no promise to await. Use .should() for assertions and .then() when you need the value.

Can Cypress test multiple tabs? Not really. Cypress runs inside one browser tab. Multi-tab flows need a workaround, and this is one of the clearest reasons teams choose Playwright instead.

Can Cypress test more than one domain? Yes, with cy.origin(). It is more work than in Playwright, but the old claim that Cypress cannot cross origins is out of date.

Is Cypress free? The test runner is free and open source. Cypress Cloud, for CI orchestration and reporting, is paid above a free tier.

Does Cypress test components as well as pages? Yes, and this is a genuine advantage. It mounts React, Angular, Vue and Svelte components in a real browser.

Remember this

Cypress queues commands instead of running them immediately, so you never await them. It retries the last command in a chain, which is why assertion placement matters. It clears state between tests on purpose. And because it lives inside the browser, cy.intercept() gives you unusually good control of the network.

The official documentation is at docs.cypress.io.

If you are still choosing a tool, Playwright vs Cypress vs Selenium compares all three honestly.

Practice now

Reading about a test is not the same as writing one. Open the Cypress editor and try it.

Then take the graded problem below. You write the test, and we run it twice: once against the working app, and once against a copy where we broke the exact behaviour on purpose. If your test still passes on the broken copy, it was not really checking anything, and we will tell you which behaviour it missed.

now prove it

The cart badge lies

Reading is the easy part. Write the test yourself, then we break the app on purpose to check it would really catch the bug.

Solve this problem →

Learn this in full

Reading about a problem is not the same as fixing one. Pick a lecture or a practice problem and write the test yourself.

Keep reading