🌲 Cypress Testing Framework
Cypress runs your tests inside the browser, alongside your app, which makes it fast, reliable, and genuinely pleasant to debug. In this lesson you'll install it, write and run real tests, and learn the handful of concepts — automatic waiting, chained assertions, network interception — that make Cypress click.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Install and configure Cypress, and run tests in both interactive and headless modes
- Write tests using
describe/it, Cypress commands, and chained.should()assertions - Explain automatic waiting and retry-ability and why they remove the need for fixed sleeps
- Choose resilient selectors and intercept/stub network requests with
cy.intercept() - Organise a suite with custom commands,
cy.session(), and the Page Object pattern
Estimated Time: 45–60 minutes • Difficulty: Intermediate
Hands-on: Build a complete login test suite with stubbed API responses and a Page Object.
In This Lesson
What Makes Cypress Different
Traditional tools like Selenium sit outside the browser and send commands across a network protocol (WebDriver). Cypress runs in the same run loop as your application, with direct access to the DOM, the network layer, and the browser's own APIs. That architecture is the source of nearly every Cypress feature you'll love.
💡 A useful analogy: A Selenium test is like operating a car by remote control from across the street — every command has a delay and you only see it from a distance. Cypress puts you in the driver's seat, hands on the wheel, watching the dashboard directly.
| Feature | Selenium-based tools | Cypress |
|---|---|---|
| Architecture | External, via WebDriver | Runs inside the browser |
| Languages | Many (Java, Python, C#…) | JavaScript / TypeScript |
| Waiting | Manual waits and sleeps | Automatic waiting & retries |
| Debugging | Logs and screenshots | Time-travel, live reload, dev tools |
| Network control | Limited | Full stubbing via cy.intercept() |
| Setup | Drivers to install & match | One npm install, batteries included |
📖 A note on scope
Cypress is JavaScript/TypeScript-only and runs in Chromium, Firefox, Edge, and WebKit. If you need broad multi-language or native-Safari coverage, Playwright is the common alternative — but the concepts you learn here transfer directly.
Installing & Configuring
Cypress installs as a single dev dependency — no separate browser drivers to manage.
# add Cypress to your project
npm install --save-dev cypress
# open the interactive Test Runner (also scaffolds the folders)
npx cypress open
# run everything headlessly (for CI)
npx cypress run
Add scripts so your team has a consistent entry point:
{
"scripts": {
"cy:open": "cypress open",
"cy:run": "cypress run"
}
}
The first run creates this structure:
cypress/
├── e2e/ # your test (spec) files, named *.cy.js
├── fixtures/ # static test data (JSON)
├── support/
│ ├── commands.js # custom cy.* commands
│ └── e2e.js # runs before every spec
└── cypress.config.js # main configuration
The config file is a normal JS module. A modern setup with a base URL, sensible viewport, and CI retries:
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
// retry failed tests in CI only
retries: { runMode: 2, openMode: 0 },
setupNodeEvents(on, config) {
// register plugins and Node-side tasks here
return config;
},
},
});
💡 TypeScript in one step
Cypress ships its own types. Install TypeScript (npm i -D typescript), name your specs *.cy.ts, and add "types": ["cypress"] to your tsconfig.json — you get autocomplete for every cy command for free.
Your First Test
A Cypress spec reads almost like plain English. describe groups related tests, it is one test, cy.* are commands, and .should() makes assertions.
// cypress/e2e/first-test.cy.js
describe('My first test', () => {
it('visits a page and fills a field', () => {
cy.visit('https://example.cypress.io');
// find a link by its text and click it
cy.contains('type').click();
// Cypress waited for navigation automatically
cy.url().should('include', '/commands/actions');
// type into an input and assert its value
cy.get('.action-email')
.type('hello@example.com')
.should('have.value', 'hello@example.com');
});
});
You run tests two ways, and you'll use both:
- Interactive (
cypress open) — a real browser with time-travel snapshots at every step. This is where you write and debug. - Headless (
cypress run) — command-line, records video and screenshots. This is what CI uses.
Automatic Waiting & Assertions
This is the concept that changes how you write tests. Cypress commands and assertions are retry-able: instead of failing the instant an element isn't ready, Cypress keeps re-trying (up to a timeout, 4 seconds by default) until the condition passes.
// Cypress automatically waits for the button to:
// 1. exist in the DOM
// 2. be visible
// 3. be enabled
// 4. not be covered by another element
// ...before it clicks. No manual wait needed.
cy.get('button[data-testid="save"]').click();
// The assertion retries too — it polls until the text
// appears or the timeout is reached.
cy.get('[data-testid="status"]').should('have.text', 'Saved');
⚠️ Almost never use cy.wait(number)
Pausing for a fixed number of milliseconds is slow when the app is fast and flaky when it's slow. Instead, assert on a condition (Cypress will wait for it) or wait on a specific network request with an alias — shown in the network section below.
Assertions chain naturally, and .and() adds more checks to the same subject:
cy.get('form.signup')
.should('be.visible')
.and('have.class', 'ready')
.and('not.have.class', 'submitted');
// For custom logic, drop into .then() to get the raw element
cy.get('.item').should('have.length', 5).then(($items) => {
expect($items.eq(0)).to.contain('First item');
});
Use aliases with .as() to reference an element or a request later without re-querying:
cy.get('button.submit').as('submitBtn');
cy.get('@submitBtn').click();
Selectors & Actions
The single biggest factor in whether your tests survive a redesign is how you select elements. Prefer selectors tied to meaning, not to styling or position.
| ❌ Brittle | ✅ Resilient |
|---|---|
cy.get('button:nth-child(2)') | cy.get('[data-testid="submit"]') |
cy.get('.btn.btn-primary.mt-3') | cy.contains('button', 'Sign up') |
cy.get('[style="color:red"]') | cy.get('[aria-label="Close"]') |
The Cypress team recommends a dedicated data-testid (or data-cy) attribute for elements you test — it signals intent and never changes for cosmetic reasons.
Common actions
// mouse
cy.get('[data-testid="menu"]').click();
cy.get('.tile').dblclick();
// keyboard & special keys
cy.get('#search').type('cypress{enter}');
cy.get('body').type('{ctrl+k}');
// forms
cy.get('[type="checkbox"]').check();
cy.get('[type="radio"]').check('express');
cy.get('select').select('Priority');
cy.get('textarea').clear().type('New note');
💡 Force only as a last resort
By default Cypress refuses to click a hidden or covered element — that's protecting you from a bug the user would hit too. { force: true } bypasses the check, but reach for it rarely; a hidden "Submit" button is usually a real problem worth failing on.
Intercepting the Network
Because Cypress sits in the browser, it can watch and control every network request with cy.intercept(). This unlocks two superpowers: waiting on real requests and stubbing responses to test states that are hard to reproduce.
Wait on a request, not the clock
// register an interception and alias it
cy.intercept('GET', '/api/users').as('getUsers');
cy.visit('/users');
// wait until that exact request finishes — reliable, no guessing
cy.wait('@getUsers').its('response.statusCode').should('eq', 200);
Stub a response to force a scenario
// pretend the server returned an empty list
cy.intercept('GET', '/api/users', { statusCode: 200, body: [] }).as('empty');
cy.visit('/users');
cy.wait('@empty');
cy.contains('No users yet').should('be.visible');
// pretend the server errored — test your error UI
cy.intercept('GET', '/api/users', { statusCode: 500 }).as('boom');
cy.visit('/users');
cy.wait('@boom');
cy.get('[data-testid="error-banner"]').should('be.visible');
Inspect and assert on an outgoing request
cy.intercept('POST', '/api/users', (req) => {
expect(req.body.email).to.equal('john@example.com');
req.reply({ statusCode: 201, body: { id: 1 } });
}).as('createUser');
cy.get('form').submit();
cy.wait('@createUser').its('response.statusCode').should('eq', 201);
✅ Why stubbing matters
Stubbed responses are instant and deterministic. You can test loading spinners, empty states, rate-limits, and 500 errors on demand — states that would be slow, flaky, or impossible to trigger against a live backend.
Organising a Suite
Three tools keep a growing suite fast and maintainable: custom commands, sessions, and Page Objects.
Custom commands
Wrap a repeated flow in a reusable cy command. Prefer the API-based version for speed — logging in through the UI on every test wastes seconds that add up to minutes.
// cypress/support/commands.js
Cypress.Commands.add('loginByApi', (email, password) => {
cy.request('POST', '/api/login', { email, password }).then((res) => {
window.localStorage.setItem('token', res.body.token);
});
});
Persist login with cy.session()
Modern Cypress caches and restores browser state between tests, so you authenticate once instead of every test:
Cypress.Commands.add('login', (email, password) => {
cy.session([email, password], () => {
cy.loginByApi(email, password);
});
});
// in a spec — instant on every run after the first
beforeEach(() => {
cy.login('user@example.com', 'password123');
cy.visit('/dashboard');
});
The Page Object pattern
Centralise a page's selectors and actions so a UI change means editing one file:
// cypress/support/pages/LoginPage.js
class LoginPage {
visit() {
cy.visit('/login');
return this;
}
fillEmail(email) {
cy.get('[data-testid="email"]').type(email);
return this;
}
fillPassword(password) {
cy.get('[data-testid="password"]').type(password);
return this;
}
submit() {
cy.get('[data-testid="submit"]').click();
return this;
}
login(email, password) {
return this.fillEmail(email).fillPassword(password).submit();
}
}
export default new LoginPage();
// cypress/e2e/login.cy.js
import LoginPage from '../support/pages/LoginPage';
describe('Login', () => {
it('logs in with valid credentials', () => {
LoginPage.visit().login('user@example.com', 'password123');
cy.url().should('include', '/dashboard');
});
});
Hands-on Exercise
🏋️ Build a login test suite
Objective: Combine everything — commands, selectors, assertions, and network stubbing — into a small, realistic suite.
Instructions:
- Scaffold a spec
cypress/e2e/login.cy.jswith abeforeEachthat visits/login. - Write a test for the happy path: fill valid credentials, submit, assert the URL includes
/dashboardand a welcome message is visible. - Write a test for invalid credentials — but stub the login API to return a 401 so it's fast and deterministic — and assert an error message appears.
- Refactor the field selectors into a
LoginPagePage Object. - Add a
loginByApicustom command and use it to test a page that requires authentication.
💡 Hint
Register the intercept before the action that triggers it: cy.intercept('POST', '/api/login', { statusCode: 401 }).as('login'), then submit the form, then cy.wait('@login'), then assert on the error UI.
✅ Example solution (invalid-credentials test)
it('shows an error on bad credentials', () => {
cy.intercept('POST', '/api/login', {
statusCode: 401,
body: { error: 'Invalid credentials' },
}).as('login');
cy.get('[data-testid="email"]').type('user@example.com');
cy.get('[data-testid="password"]').type('wrong');
cy.get('[data-testid="submit"]').click();
cy.wait('@login');
cy.get('[data-testid="error"]')
.should('be.visible')
.and('contain', 'Invalid credentials');
});
🎯 Quick Quiz
Question 1: What does Cypress's automatic waiting mean in practice?
Question 2: Which selector is the most resilient to UI redesigns?
Question 3: What is cy.intercept() most useful for?
Summary & Quiz
🎉 Key Takeaways
- Cypress runs inside the browser, giving it direct access, precise control, and excellent debugging.
- Tests use
describe/it,cy.*commands, and retry-able.should()assertions. - Automatic waiting removes fixed sleeps — assert on conditions, or wait on aliased requests.
- Prefer resilient selectors (
data-testid, roles, text) over position or styling. cy.intercept()lets you wait on and stub the network; custom commands,cy.session(), and Page Objects keep the suite fast and maintainable.
📚 Further Reading
🚀 What's Next?
You can now write reliable Cypress tests. The final piece is running them automatically on every change: the next lesson wires your tests into a CI pipeline with GitHub Actions, parallelisation, reporting, and flaky-test handling.
🎉 Nice work!
Your tests run locally — let's make them run for the whole team, on every commit.