Skip to main content

๐ŸŒฒ Cypress Setup & Usage

In the last lesson you learned why E2E tests matter. Now you'll make one run. Cypress is a modern testing tool that opens a real browser, runs your test alongside the app, and lets you watch every step replay in a time-travel debugger. By the end you'll have installed it, understood its folders, and written a passing test with a handful of commands that read like plain English.

Week 12 · Day 3 (Wednesday: End-to-End Testing) · Lecture 2

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Install Cypress and scaffold its folder structure in a project
  • Explain the difference between cypress open and cypress run and when to use each
  • Write a spec file in cypress/e2e using describe and it
  • Drive the browser with cy.visit, cy.get, cy.contains, cy.click, and cy.type
  • Make assertions with cy.should and describe how auto-waiting and retry-ability prevent flaky tests
  • Configure baseUrl, load test data from fixtures, and select elements with resilient data-cy attributes

Estimated Time: 70 minutes

Practice: Install Cypress and write a first test that visits a page and asserts on its heading.

In This Lesson

Why Cypress?

Older tools like Selenium control the browser from the outside, sending commands over a network connection. That gap causes the timing bugs that plague so many test suites. Cypress takes a different approach: it runs inside the browser, in the same event loop as your app. It can see every DOM element, every network request, and every piece of state directly.

๐Ÿš— Driver's seat vs. remote control

Selenium is like driving a car with a remote control from across the parking lot โ€” every command has lag, and you can't quite tell what the car sees. Cypress puts you in the driver's seat: you're inside the browser with the app, reacting to exactly what it's doing in real time.

That architecture gives Cypress its signature features:

  • Automatic waiting โ€” no manual sleeps; it waits for elements to be ready
  • Time-travel debugging โ€” hover over any step to see a DOM snapshot at that moment
  • Real-time reloading โ€” save a spec and it re-runs instantly
  • Network control โ€” stub and spy on requests with cy.intercept

Installing & Opening Cypress

Cypress installs as a dev dependency in any Node project. From your project root:

npm install cypress --save-dev

Then launch the interactive test runner:

npx cypress open

The first time you run this, Cypress opens a friendly setup wizard: it asks whether you want E2E Testing or Component Testing (choose E2E), scaffolds the folder structure for you, and lets you pick a browser. From then on, cypress open shows your specs and runs them in a real, visible browser.

open vs. run

There are two ways to execute Cypress, and knowing when to use each matters.

CommandWhat it doesUse it for
cypress openOpens the interactive GUI with a visible browser and time-travelWriting and debugging tests locally
cypress runRuns all specs headlessly in the terminal, recording videoCI pipelines and quick full-suite checks

Add both as npm scripts so your team has one obvious way to run them:

{
  "scripts": {
    "cypress:open": "cypress open",
    "cypress:run": "cypress run",
    "test:e2e": "start-server-and-test start http://localhost:3000 cypress:run"
  }
}

๐Ÿ’ก start-server-and-test

E2E tests need the app running. The start-server-and-test helper boots your dev server, waits until the URL responds, runs Cypress, then shuts the server down โ€” perfect for one-command CI runs.

Folder Structure

After setup, Cypress creates a cypress/ directory. Here's what lives where:

cypress/
โ”œโ”€โ”€ e2e/            # Your test files (specs), named *.cy.js
โ”œโ”€โ”€ fixtures/       # Static test data (JSON) loaded with cy.fixture
โ”œโ”€โ”€ support/
โ”‚   โ”œโ”€โ”€ commands.js # Custom reusable commands (cy.login, etc.)
โ”‚   โ””โ”€โ”€ e2e.js      # Runs before every spec โ€” global setup
โ””โ”€โ”€ downloads/      # Files your tests download during a run
cypress.config.js   # Project-wide configuration (lives at the root)
How the Cypress folders relate: config feeds the runner, which loads support and fixtures into each spec in the e2e folder cypress.config.js baseUrl, tasks Test Runner opens browser e2e/*.cy.js your specs support/ commands + setup fixtures/ JSON test data
The config configures the runner, which opens a browser and loads each spec from e2e/, pulling in shared support/ code and fixtures/ data as needed.

Your First Test

A Cypress spec uses the same describe/it structure you met in unit testing. Create cypress/e2e/home.cy.js:

// cypress/e2e/home.cy.js

describe('Home page', () => {
  beforeEach(() => {
    // Runs before each test โ€” start from a clean, known page
    cy.visit('/');
  });

  it('displays the welcome heading', () => {
    cy.get('h1').should('contain', 'Welcome to Our App');
  });

  it('navigates to the login page', () => {
    cy.get('a[href="/login"]').click();          // act like a user
    cy.url().should('include', '/login');        // assert the outcome
    cy.get('h1').should('contain', 'Login');
  });
});

What you'll see

Home page
  โœ“ displays the welcome heading (412ms)
  โœ“ navigates to the login page (587ms)

2 passing

Read the second test aloud: "get the login link, click it, the URL should include /login, and the heading should say Login." That readability is the whole point โ€” a good E2E test doubles as living documentation of how the app behaves.

The Core Commands

You can go a very long way with just six commands. Learn these and you can express most user journeys.

CommandWhat it does
cy.visit(url)Navigate the browser to a page
cy.get(selector)Find element(s) by CSS selector
cy.contains(text)Find an element by its visible text
.click()Click the found element
.type(text)Type into an input
.should(assertion)Assert something about the element

Chaining reads like a sentence

Cypress commands chain. Each one passes its found element to the next, so a login flow becomes a readable sequence:

it('logs in with valid credentials', () => {
  cy.visit('/login');

  cy.get('[data-cy="email"]').type('ada@example.com');
  cy.get('[data-cy="password"]').type('correct-horse-battery');
  cy.get('[data-cy="submit"]').click();

  // After login, we should land on the dashboard
  cy.url().should('include', '/dashboard');
  cy.contains('Welcome, Ada').should('be.visible');
});

A tour of .should() assertions

Assertions are how you verify the app did the right thing. A few of the most common:

cy.get('[data-cy="banner"]').should('be.visible');
cy.get('[data-cy="error"]').should('not.exist');
cy.get('h1').should('contain', 'Dashboard');
cy.get('[data-cy="email"]').should('have.value', 'ada@example.com');
cy.get('[data-cy="items"]').should('have.length', 3);

// Chain several with .and()
cy.get('[data-cy="status"]')
  .should('be.visible')
  .and('contain', 'Active');

๐Ÿ’ก get vs. contains

Use cy.get when you know a stable selector (an id or data-cy attribute). Reach for cy.contains when the visible text is the meaningful thing โ€” like clicking a button that says "Sign In" regardless of its classes.

Auto-Waiting & Retry-Ability

This is the feature that saves you from the number-one cause of flaky tests: bad timing. In older tools you constantly wrote "wait 2 seconds and hope the button appeared." Cypress makes that unnecessary.

Every time you query an element, Cypress automatically retries the command and its assertion until they succeed or a timeout (4 seconds by default) is reached. It waits for the element to exist, be visible, and be actionable before clicking.

sequenceDiagram participant Test as Your Test participant Cy as Cypress participant App as The App Test->>Cy: Get the submit button and click it Cy->>App: Is the button in the DOM yet App-->>Cy: Not yet, still loading Cy->>App: Retry after a moment App-->>Cy: Yes, and it is now visible Cy->>App: Click the button Note over Cy,App: No manual sleep was ever needed
// โŒ The old, flaky way โ€” arbitrary sleep
cy.get('[data-cy="submit"]').click();
cy.wait(2000);                     // hope 2s is enough...
cy.get('[data-cy="toast"]').should('be.visible');

// โœ… The Cypress way โ€” it retries until the toast appears
cy.get('[data-cy="submit"]').click();
cy.get('[data-cy="toast"]').should('be.visible');   // waits automatically

โš ๏ธ Avoid cy.wait(number)

Waiting a fixed number of milliseconds is almost always wrong: too short and the test flakes, too long and the suite crawls. Instead, assert on the condition you're actually waiting for, or wait on a specific network request with cy.wait('@alias') (covered next lesson). Let retry-ability do the work.

Config, Fixtures & Selectors

cypress.config.js and baseUrl

Project-wide settings live in cypress.config.js. The most important is baseUrl โ€” set it once and every cy.visit('/products') becomes relative to it.

// cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',   // cy.visit('/') -> localhost:3000/
    viewportWidth: 1280,
    viewportHeight: 720,
    defaultCommandTimeout: 5000,        // how long retries keep trying
    setupNodeEvents(on, config) {
      // register cy.task handlers here (see next lesson)
    }
  }
});

With baseUrl set, your specs never hard-code the host. Point it at staging in CI and the same tests run against a different environment โ€” no code changes.

Fixtures: reusable test data

Static data lives in cypress/fixtures as JSON and loads with cy.fixture. This keeps test data out of your test logic.

// cypress/fixtures/user.json
{
  "email": "ada@example.com",
  "password": "correct-horse-battery",
  "name": "Ada Lovelace"
}
// In a spec
it('greets the user by name', () => {
  cy.fixture('user').then((user) => {
    cy.visit('/login');
    cy.get('[data-cy="email"]').type(user.email);
    cy.get('[data-cy="password"]').type(user.password);
    cy.get('[data-cy="submit"]').click();
    cy.contains(`Welcome, ${user.name}`).should('be.visible');
  });
});

data-cy selectors: your test contract

How you find elements decides how brittle your tests are. Consider the range of options, from most to least stable:

Selector stability scale: dedicated data-cy attributes are most stable, deep CSS structure selectors are least stable Most stable โ†’ [data-cy="submit"] #login-form ยท input[name="email"] Least stable โ†’ .btn.btn-primary > span
CSS classes exist for styling and change often; deep structural selectors break the moment markup shifts. A dedicated data-cy attribute is a promise your app makes to your tests.

Add the attribute in your markup, then select it in tests:

<button data-cy="login-button" class="btn btn-primary">Sign In</button>
cy.get('[data-cy="login-button"]').click();

โœ… Why data-cy wins

A designer can restyle the button, rename its classes, or move it in the DOM โ€” as long as data-cy="login-button" stays, your test keeps working. The attribute is meaningless to users and to CSS, so nobody has a reason to change it. That's what makes it the recommended Cypress selector.

Practice & Quiz

๐Ÿ‹๏ธ Exercise 1: Write your first spec

Goal: Given an app with a heading <h1>My Tasks</h1> and a link <a href="/about" data-cy="about-link">About</a>, write a spec that visits the home page, checks the heading, clicks the About link, and asserts the URL changed.

๐Ÿ’ก Hint

Use cy.visit('/'), then cy.get('h1').should('contain', โ€ฆ), then cy.get('[data-cy="about-link"]').click(), then cy.url().should('include', '/about').

โœ… Solution
// cypress/e2e/tasks.cy.js
describe('Task app', () => {
  it('shows the heading and navigates to About', () => {
    cy.visit('/');
    cy.get('h1').should('contain', 'My Tasks');

    cy.get('[data-cy="about-link"]').click();
    cy.url().should('include', '/about');
  });
});

๐Ÿ‹๏ธ Exercise 2: Fix the flaky test

Goal: This test uses a fixed sleep. Rewrite it to rely on Cypress auto-waiting instead.

cy.get('[data-cy="load-more"]').click();
cy.wait(3000);
cy.get('[data-cy="item"]').should('have.length', 20);
โœ… Solution
cy.get('[data-cy="load-more"]').click();
// Cypress retries the assertion until 20 items exist โ€” no sleep needed
cy.get('[data-cy="item"]').should('have.length', 20);

The should('have.length', 20) assertion retries automatically until the list reaches 20 items or the timeout is hit. It's both faster (no wasted seconds) and more reliable (no guessing) than cy.wait(3000).

๐ŸŽฏ Quick Quiz

Question 1: Which command should you use to run Cypress headlessly in a CI pipeline?

Question 2: Why is [data-cy="submit"] a better selector than .btn.btn-primary?

Question 3: What does Cypress's auto-waiting mean for cy.wait(2000)?

Best Practices & Pitfalls

โœ… Do

  • Set baseUrl and use relative paths in cy.visit
  • Select elements with data-cy attributes, not styling classes
  • Lean on auto-waiting โ€” assert on conditions, not clocks
  • Keep static test data in fixtures, out of your test logic
  • Use cypress open to write and debug, cypress run in CI

โŒ Don't

  • Sprinkle cy.wait(ms) to "fix" timing โ€” it hides real problems and slows the suite
  • Target elements by deep CSS structure like div > ul > li:nth-child(2)
  • Hard-code the host (http://localhost:3000/...) in every visit
  • Reuse state between tests โ€” each spec should stand on its own

๐Ÿ“– Playwright, the modern alternative

If you need to run the same tests across Chromium, Firefox, and WebKit, or want built-in mobile emulation, Playwright uses nearly identical concepts (page.goto, page.click, expect(...).toBeVisible()). Everything you learn here transfers directly.

Summary

๐ŸŽ‰ Key Takeaways

  • Install with npm install cypress --save-dev; scaffold and debug with cypress open, run in CI with cypress run
  • Specs live in cypress/e2e/*.cy.js and use describe/it just like unit tests
  • Six commands โ€” visit, get, contains, click, type, should โ€” express most journeys
  • Auto-waiting and retry-ability make manual sleeps unnecessary and kill a whole class of flakiness
  • Set baseUrl in the config, load data from fixtures, and select with resilient data-cy attributes

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

You can now write and run a Cypress test. Next you'll level up from individual commands to complete, resilient scenarios. In Writing E2E Test Scenarios, you'll structure real user journeys, seed state through the API and cy.task, stub the network with cy.intercept, and keep every test independent.

๐ŸŽ‰ Your first green run!

Watching a browser drive itself through your app never stops being satisfying. Now let's make those tests bulletproof.