๐ฒ 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 openandcypress runand when to use each - Write a spec file in
cypress/e2eusingdescribeandit - Drive the browser with
cy.visit,cy.get,cy.contains,cy.click, andcy.type - Make assertions with
cy.shouldand describe how auto-waiting and retry-ability prevent flaky tests - Configure
baseUrl, load test data from fixtures, and select elements with resilientdata-cyattributes
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.
| Command | What it does | Use it for |
|---|---|---|
cypress open | Opens the interactive GUI with a visible browser and time-travel | Writing and debugging tests locally |
cypress run | Runs all specs headlessly in the terminal, recording video | CI 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)
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.
| Command | What 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.
// โ 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:
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
baseUrland use relative paths incy.visit - Select elements with
data-cyattributes, 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 opento write and debug,cypress runin 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 withcypress open, run in CI withcypress run - Specs live in
cypress/e2e/*.cy.jsand usedescribe/itjust 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
baseUrlin the config, load data from fixtures, and select with resilientdata-cyattributes
๐ Additional Resources
- Cypress Docs โ Why Cypress?
- Cypress Docs โ Introduction & retry-ability
- Cypress Docs โ Selecting elements (data-cy)
- Playwright โ Writing tests (the alternative)
๐ 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.