What is Cypress?
Cypress is a JavaScript-based end-to-end testing framework built for the modern web. Unlike traditional tools such as Selenium that communicate with the browser via an external WebDriver protocol, Cypress runs inside the browser itself — in the same run loop as your application. This architectural difference is what gives Cypress its signature developer experience: real-time reloads, time-travel debugging, automatic waiting, and highly readable error messages.
Cypress was built from the ground up for developers and QA engineers who test web applications written in frameworks like React, Vue, Angular, and Next.js. It ships with its own test runner, assertion library (Chai), command chaining API, and an interactive GUI that shows every step of your test alongside the rendered app.
Key advantages over Selenium
- In-browser execution — No separate WebDriver process; Cypress talks directly to the browser engine, removing the network round-trip that causes Selenium timing issues.
- Automatic waiting — Cypress automatically waits for elements to appear, animations to finish, and network requests to complete before running the next command. No
sleep()calls needed. - Time-travel debugging — The Test Runner takes a DOM snapshot at every command. You can hover over any step and see exactly what the page looked like at that moment.
- Network control — Stub and spy on XHR/fetch requests without extra libraries, giving you full control over API responses in tests.
- Fast feedback — Tests run in milliseconds rather than seconds because there is no HTTP tunnel overhead.
- JavaScript-native — Tests are written in JavaScript or TypeScript, using the same language as your web app. No Java boilerplate or language switching required.
time.sleep() calls that had been plaguing our Selenium suite. Cypress's built-in auto-wait resolved the majority of our intermittent failures on the first pass, cutting our flaky-test rate from roughly 25% to under 3% within a month of migration.
Architecture Diagram
Understanding how Cypress differs architecturally from Selenium helps explain why it behaves the way it does.
Selenium, by contrast, communicates via the W3C WebDriver protocol over HTTP: your test code → WebDriver client → HTTP request → browser driver (chromedriver) → browser. Each hop introduces latency and a potential failure point. Cypress eliminates those hops entirely.
Installation & Project Structure
Cypress is an npm package. You add it to any Node.js project as a dev dependency:
# Install Cypress
npm install cypress --save-dev
# Open the Cypress interactive Test Runner
npx cypress open
# Or run headlessly from the CLI
npx cypress run
When you run npx cypress open for the first time, Cypress scaffolds the following project structure:
my-project/
├── cypress/
│ ├── e2e/ # Your test files live here
│ │ └── login.cy.js
│ ├── fixtures/ # Static test data (JSON)
│ │ └── users.json
│ ├── support/
│ │ ├── commands.js # Custom cy.* commands
│ │ └── e2e.js # Global config / imports
│ └── downloads/ # Files downloaded during tests
├── cypress.config.js # Main Cypress configuration
├── package.json
└── node_modules/
The e2e/ directory is where your test specs live. Fixtures store reusable test data. The support/ folder is where you add custom commands and global hooks.
Writing Your First Test
Cypress tests use a familiar Mocha-style describe / it structure. Every browser interaction is a Cypress command prefixed with cy.:
// cypress/e2e/login.cy.js
describe('Login Page', () => {
beforeEach(() => {
cy.visit('/login');
});
it('logs in with valid credentials', () => {
cy.get('[data-cy="email-input"]').type('user@example.com');
cy.get('[data-cy="password-input"]').type('SecurePass123');
cy.get('[data-cy="login-btn"]').click();
// Assert dashboard loads
cy.url().should('include', '/dashboard');
cy.get('[data-cy="welcome-message"]').should('contain', 'Welcome');
});
it('shows error for invalid password', () => {
cy.get('[data-cy="email-input"]').type('user@example.com');
cy.get('[data-cy="password-input"]').type('WrongPassword');
cy.get('[data-cy="login-btn"]').click();
cy.get('[data-cy="error-alert"]')
.should('be.visible')
.and('contain', 'Invalid email or password');
});
});
Key commands used above:
cy.visit(url)— Navigates to a page (relative tobaseUrlin config)cy.get(selector)— Queries for a DOM element.type(text)— Types into an input field.click()— Clicks an element.should(assertion)— Asserts a condition (with auto-retry).and(assertion)— Chains another assertioncy.url()— Returns the current page URL
Selectors: Finding Elements
Cypress supports any valid CSS selector, but the recommended approach is to use data-cy attributes — custom HTML attributes added specifically for testing. This decouples your tests from styling decisions and DOM structure.
/* CSS selector — works but fragile */
cy.get('.btn-primary')
/* XPath (requires plugin) — verbose and brittle */
cy.xpath('//button[@class="btn btn-primary"]')
/* data-cy attribute — recommended best practice */
cy.get('[data-cy="submit-btn"]')
/* cy.contains — useful for finding by visible text */
cy.contains('button', 'Submit')
/* Chained selectors */
cy.get('[data-cy="user-card"]').find('[data-cy="user-name"]')
/* nth element */
cy.get('[data-cy="product-item"]').eq(2)
Add data-cy to your HTML:
<button data-cy="submit-btn" type="submit">Submit</button>
<input data-cy="email-input" type="email" />
<div data-cy="error-alert" class="alert alert-danger">...</div>
Why data-cy beats CSS classes: when your designer renames .btn-primary to .btn-brand, your tests break. When they add a new div wrapper, XPath indices break. data-cy attributes are test-only contracts — they don't affect CSS or JS and should never change for non-testing reasons.
Assertions
Cypress bundles Chai, Sinon-Chai, and jQuery-Chai assertion styles. The most common pattern is chaining .should() off a query:
// Visibility
cy.get('[data-cy="modal"]').should('be.visible');
cy.get('[data-cy="spinner"]').should('not.exist');
// Text content
cy.get('[data-cy="heading"]').should('have.text', 'Dashboard');
cy.get('[data-cy="status"]').should('contain', 'Active');
// Length (count)
cy.get('[data-cy="result-item"]').should('have.length', 5);
// Attribute values
cy.get('[data-cy="link"]').should('have.attr', 'href', '/pricing');
cy.get('[data-cy="input"]').should('have.value', 'hello@example.com');
// CSS class
cy.get('[data-cy="card"]').should('have.class', 'selected');
// Multiple assertions chained
cy.get('[data-cy="submit-btn"]')
.should('be.visible')
.and('not.be.disabled')
.and('have.text', 'Submit');
// Chai expect style
cy.get('[data-cy="count"]').invoke('text').then((text) => {
expect(parseInt(text)).to.be.greaterThan(0);
});
All .should() assertions automatically retry for up to the configured defaultCommandTimeout (4000ms by default) before failing. This built-in retry removes the need for explicit waits in the vast majority of cases.
Fixtures & Test Data
Hard-coding test data in your spec files makes maintenance painful. Cypress fixtures let you store test data in JSON files and load them with cy.fixture():
// cypress/fixtures/users.json
{
"validUser": {
"email": "testuser@example.com",
"password": "SecurePass123",
"name": "Test User"
},
"adminUser": {
"email": "admin@example.com",
"password": "AdminPass456",
"name": "Admin"
}
}
// Using fixture in a test
describe('Login with fixture data', () => {
it('logs in as a valid user', () => {
cy.fixture('users').then((users) => {
cy.visit('/login');
cy.get('[data-cy="email-input"]').type(users.validUser.email);
cy.get('[data-cy="password-input"]').type(users.validUser.password);
cy.get('[data-cy="login-btn"]').click();
cy.get('[data-cy="welcome-name"]').should('contain', users.validUser.name);
});
});
});
You can also alias the fixture to use it with this in multiple tests:
beforeEach(function() {
cy.fixture('users').as('userData');
});
it('uses aliased fixture', function() {
cy.get('[data-cy="email-input"]').type(this.userData.validUser.email);
});
Network Interception
cy.intercept() is one of Cypress's most powerful features. It lets you spy on, modify, or stub any HTTP request made by your application — without changing any application code.
// Stub a GET request to return fixture data
cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers');
cy.visit('/users');
cy.wait('@getUsers'); // Wait for the stubbed request to complete
cy.get('[data-cy="user-list-item"]').should('have.length', 2);
// Stub with inline response
cy.intercept('POST', '/api/login', {
statusCode: 200,
body: { token: 'fake-jwt-token', name: 'Test User' }
}).as('loginRequest');
// Simulate server error
cy.intercept('GET', '/api/products', { statusCode: 500 }).as('productsError');
// Spy without stubbing (just observe)
cy.intercept('GET', '/api/search*').as('searchRequest');
cy.get('[data-cy="search-input"]').type('laptop');
cy.wait('@searchRequest').its('request.url').should('include', 'laptop');
Custom Commands
Custom commands let you encapsulate reusable actions into a named cy. command. Define them in cypress/support/commands.js:
// cypress/support/commands.js
// Custom login command — reusable across all test files
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login');
cy.get('[data-cy="email-input"]').type(email);
cy.get('[data-cy="password-input"]').type(password);
cy.get('[data-cy="login-btn"]').click();
cy.url().should('include', '/dashboard');
});
// Login via API to skip the UI (faster for test setup)
Cypress.Commands.add('loginViaApi', (email, password) => {
cy.request('POST', '/api/auth/login', { email, password })
.then((resp) => {
window.localStorage.setItem('authToken', resp.body.token);
});
});
// Select a dropdown option by visible text
Cypress.Commands.add('selectOption', (selector, optionText) => {
cy.get(selector).select(optionText);
});
// Usage in tests:
cy.login('user@example.com', 'SecurePass123');
cy.loginViaApi('user@example.com', 'SecurePass123');
The loginViaApi pattern is especially useful: by bypassing the login UI and going straight to the API, your logged-in test setup takes milliseconds instead of seconds. Reserve UI interaction for tests that are specifically testing the login page itself.
Configuration: cypress.config.js
All Cypress settings live in cypress.config.js at the project root:
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000', // Used by cy.visit('/')
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 6000, // ms before a command fails
requestTimeout: 10000, // ms for cy.request()
responseTimeout: 30000, // ms for server responses
retries: {
runMode: 2, // Retry failed tests in CI
openMode: 0 // No retries in interactive mode
},
env: {
apiUrl: 'http://localhost:3001/api',
adminEmail: 'admin@example.com'
},
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
video: false, // Disable video in dev mode
screenshotOnRunFailure: true
}
});
The env section stores values accessible via Cypress.env('apiUrl') in any test or command. Environment-specific values (staging URLs, credentials) can be passed at runtime using --env key=value on the CLI, keeping secrets out of the config file.
Running Headless
The npx cypress run command runs your full test suite headlessly, generating videos and screenshots:
# Run all tests headlessly (default: Electron browser)
npx cypress run
# Run with Chrome
npx cypress run --browser chrome
# Run with Firefox
npx cypress run --browser firefox
# Run a specific spec file
npx cypress run --spec "cypress/e2e/login.cy.js"
# Run multiple specs with glob
npx cypress run --spec "cypress/e2e/auth/**/*.cy.js"
# Pass environment variables
npx cypress run --env apiUrl=https://staging.api.example.com
# Run tagged tests (requires cypress-grep plugin)
npx cypress run --env grep="@smoke"
GitHub Actions CI Integration
The official Cypress GitHub Action (cypress-io/github-action@v6) handles installing dependencies, starting a dev server, and running tests:
# .github/workflows/cypress.yml
name: Cypress E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
cypress-run:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Run Cypress tests
uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm start
wait-on: 'http://localhost:3000'
wait-on-timeout: 60
browser: chrome
record: false
- name: Upload screenshots on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
- name: Upload videos
uses: actions/upload-artifact@v4
if: always()
with:
name: cypress-videos
path: cypress/videos
The wait-on key ensures Cypress does not start running tests until the dev server is actually listening on port 3000. The if: failure() condition on the screenshots step only uploads evidence when tests fail, keeping artifact storage lean.
Tool Comparison
| Feature | Cypress | Selenium | Playwright | WebdriverIO |
|---|---|---|---|---|
| Language | JavaScript / TypeScript | Java, Python, JS, C#, Ruby | JS, TS, Python, Java, .NET | JavaScript / TypeScript |
| Setup complexity | Very low (1 npm install) | High (driver management) | Low (npm init playwright) | Medium (wdio wizard) |
| Browser support | Chrome, Firefox, Edge, Electron | All major browsers | Chromium, Firefox, WebKit | All (via WebDriver) |
| Speed | Very fast (in-browser) | Moderate | Very fast | Moderate |
| Auto-wait | Yes (built-in) | No (explicit waits) | Yes (built-in) | Partial (waitFor* methods) |
| Mobile testing | No native mobile | Via Appium | Mobile emulation only | Yes (via Appium) |
| Network stubbing | Built-in (cy.intercept) | Requires proxy setup | Built-in (route) | Via mock plugin |
| Debugging | Excellent (time-travel) | Limited | Good (trace viewer) | Good |
| Best for | JS-heavy SPAs | Enterprise/multi-lang | Multi-browser automation | Node.js + mobile combo |
Best Practices
1. Always use data-cy attributes
Never rely on CSS classes, IDs tied to JS behaviour, or XPath. Add data-cy="element-name" to any element your tests interact with. These attributes are pure test contracts — they communicate to every developer that this element is part of the test surface and must not be renamed without updating tests.
2. Never use cy.wait(milliseconds)
Hardcoded waits like cy.wait(2000) are a smell. They make tests slow in the happy path and still flaky when the server is slower than your wait time. Instead:
// Bad — arbitrary sleep
cy.wait(2000);
cy.get('[data-cy="result"]').should('be.visible');
// Good — wait for the actual condition
cy.get('[data-cy="result"]').should('be.visible');
// Good — wait for a specific network request
cy.intercept('GET', '/api/results').as('loadResults');
cy.get('[data-cy="search-btn"]').click();
cy.wait('@loadResults');
cy.get('[data-cy="result-item"]').should('have.length.gt', 0);
3. Keep each test independent
Every test must be able to run in any order and produce the same result. Do not rely on state from a previous test. Use beforeEach to set up fresh state. Use cy.loginViaApi() to authenticate without going through the login UI every time.
4. Use fixtures for test data
Keep test data in cypress/fixtures/. This makes it easy to update data once and have all tests that use that fixture pick up the change automatically.
5. Encapsulate repeated actions as custom commands
If you write the same 3-step sequence in multiple tests, turn it into a custom command in cypress/support/commands.js. Your tests become shorter, more readable, and easier to maintain.
6. Run a smoke suite on every PR
Keep a small set of high-value, fast tests tagged @smoke. Run these on every pull request (takes 2–3 minutes). Run the full regression suite nightly or before releases.
Back to Blog