Cypress - Developer-Friendly E2E Testing
Cypress is an end-to-end testing framework built for the modern web, known for its exceptional developer experience. Unlike Selenium-based tools, Cypress runs directly in the browser alongside your application, enabling real-time reloading, time-travel debugging, and automatic waiting. If you value fast feedback during test development and debugging, Cypress delivers an unmatched interactive experience.
Cypress Architecture
Understanding how Cypress works helps explain its strengths and limitations.
Traditional E2E tools (Selenium, WebDriver) run outside the browser, sending commands over a network protocol:
Test Runner → WebDriver → Browser
Cypress runs inside the browser:
┌─────────────────────────────────────┐
│ Browser │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ Cypress │ │ Your App │ │
│ │ Tests │←→│ │ │
│ └─────────────┘ └──────────────┘ │
└─────────────────────────────────────┘
This architecture means:
- Cypress has direct access to everything in the browser (DOM, network, storage)
- Commands execute synchronously in the same event loop as your app
- No network latency between test commands
- Some limitations on multi-tab and multi-domain testing (though improving)
Installation and Setup
Create a new Cypress project:
pnpm add -D cypress
pnpm exec cypress open
The first run opens the Cypress Launchpad, which guides you through:
- Choosing testing type (E2E or Component)
- Creating configuration files
- Setting up example tests
For automated setup:
pnpm add -D cypress
pnpm exec cypress install
Project Structure
├── cypress/
│ ├── e2e/ # E2E test files
│ │ └── login.cy.ts
│ ├── fixtures/ # Test data (JSON files)
│ │ └── users.json
│ ├── support/ # Support files
│ │ ├── commands.ts # Custom commands
│ │ └── e2e.ts # Runs before each E2E test
│ └── downloads/ # Downloaded files during tests
├── cypress.config.ts # Configuration
└── tsconfig.json
Configuration
// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
// Base URL for cy.visit('/')
baseUrl: 'http://localhost:3000',
// Test file patterns
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
// Support file (runs before each test file)
supportFile: 'cypress/support/e2e.ts',
// Viewport settings
viewportWidth: 1280,
viewportHeight: 720,
// Timeouts
defaultCommandTimeout: 4000,
pageLoadTimeout: 60000,
requestTimeout: 5000,
responseTimeout: 30000,
// Screenshots and videos
screenshotOnRunFailure: true,
video: true,
videosFolder: 'cypress/videos',
screenshotsFolder: 'cypress/screenshots',
// Retry configuration
retries: {
runMode: 2, // Retries in cypress run
openMode: 0, // Retries in cypress open
},
// Test isolation (Cypress 12+)
testIsolation: true,
// Experimental features
experimentalRunAllSpecs: true,
setupNodeEvents(on, config) {
// Node event listeners (plugins)
return config;
},
},
component: {
// Component testing configuration
devServer: {
framework: 'react',
bundler: 'vite',
},
},
});
TypeScript Setup
Cypress has built-in TypeScript support. Create or update tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"types": ["cypress"],
"moduleResolution": "node",
"strict": true
},
"include": ["cypress/**/*.ts", "cypress.config.ts"]
}
Writing Tests
Basic Test Structure
// cypress/e2e/home.cy.ts
describe('Homepage', () => {
beforeEach(() => {
cy.visit('/');
});
it('displays the hero section', () => {
cy.get('h1').should('contain', 'Welcome');
cy.get('.hero-image').should('be.visible');
});
it('navigates to about page', () => {
cy.contains('About').click();
cy.url().should('include', '/about');
cy.get('h1').should('contain', 'About Us');
});
});
Commands and Chaining
Cypress commands are chainable and asynchronous (but you don't use async/await):
// Commands chain naturally
cy.visit('/')
.get('input[name="email"]')
.type('[email protected]')
.get('input[name="password"]')
.type('password123')
.get('button[type="submit"]')
.click();
// Commands yield subjects to the next command
cy.get('.items') // yields .items element
.find('li') // yields all li children
.first() // yields first li
.should('contain', 'First Item');
Selecting Elements
// By CSS selector
cy.get('.btn-primary');
cy.get('#login-form');
cy.get('input[name="email"]');
// By content
cy.contains('Submit');
cy.contains('button', 'Submit'); // Button containing "Submit"
cy.contains('.card', 'Product Name'); // Card containing text
// By data attribute (recommended for test stability)
cy.get('[data-cy="submit-button"]');
cy.get('[data-testid="user-profile"]');
// Traversal
cy.get('ul').find('li'); // Children
cy.get('li').parent(); // Parent
cy.get('li').siblings(); // Siblings
cy.get('.item').first(); // First match
cy.get('.item').last(); // Last match
cy.get('.item').eq(2); // Third match (0-indexed)
// Within scope
cy.get('.modal').within(() => {
cy.get('input').type('text');
cy.get('button').click();
});
Actions
// Click
cy.get('button').click();
cy.get('button').click({ force: true }); // Skip visibility checks
cy.get('button').dblclick(); // Double-click
cy.get('button').rightclick(); // Right-click
// Type
cy.get('input').type('Hello World');
cy.get('input').type('[email protected]{enter}'); // With special chars
cy.get('input').type('{selectall}{backspace}'); // Clear field
cy.get('input').clear().type('New value'); // Clear then type
// Special keys: {enter}, {backspace}, {del}, {esc}, {tab}, {ctrl}, {alt}, {shift}
// Select
cy.get('select').select('optionValue');
cy.get('select').select(['value1', 'value2']); // Multi-select
// Checkbox and radio
cy.get('[type="checkbox"]').check();
cy.get('[type="checkbox"]').uncheck();
cy.get('[type="radio"]').check('optionValue');
// File upload
cy.get('input[type="file"]').selectFile('cypress/fixtures/file.pdf');
cy.get('input[type="file"]').selectFile(['file1.pdf', 'file2.pdf']);
// Scroll
cy.scrollTo('bottom');
cy.get('.container').scrollTo(0, 500);
cy.get('.item').scrollIntoView();
// Focus and blur
cy.get('input').focus();
cy.get('input').blur();
// Hover (trigger mouseover)
cy.get('.dropdown').trigger('mouseover');
Assertions
Cypress uses Chai assertions with automatic retrying:
// Should assertions (chainable)
cy.get('.title').should('be.visible');
cy.get('.title').should('not.be.visible');
cy.get('.title').should('have.text', 'Welcome');
cy.get('.title').should('contain', 'Welcome');
cy.get('.title').should('have.class', 'active');
cy.get('.title').should('have.attr', 'href', '/home');
cy.get('.title').should('have.css', 'color', 'rgb(0, 0, 0)');
// Multiple assertions (chained)
cy.get('input')
.should('be.visible')
.and('be.enabled')
.and('have.value', '');
// Length assertions
cy.get('li').should('have.length', 5);
cy.get('li').should('have.length.greaterThan', 2);
cy.get('li').should('have.length.lessThan', 10);
// Existence
cy.get('.error').should('exist');
cy.get('.error').should('not.exist');
// State
cy.get('input').should('be.disabled');
cy.get('input').should('be.enabled');
cy.get('input').should('be.focused');
cy.get('[type="checkbox"]').should('be.checked');
// Value
cy.get('input').should('have.value', 'test');
cy.get('input').should('be.empty');
// URL and page
cy.url().should('include', '/dashboard');
cy.url().should('eq', 'http://localhost:3000/dashboard');
cy.title().should('eq', 'Dashboard - My App');
// Then for complex assertions
cy.get('.items').then(($items) => {
expect($items).to.have.length(3);
expect($items.first()).to.contain('First');
});
Aliases
Store values for later use:
// Alias an element
cy.get('table').find('tr').as('rows');
cy.get('@rows').should('have.length', 5);
cy.get('@rows').first().click();
// Alias a network request
cy.intercept('GET', '/api/users').as('getUsers');
cy.visit('/users');
cy.wait('@getUsers').its('response.statusCode').should('eq', 200);
// Alias fixture data
cy.fixture('users.json').as('usersData');
cy.get('@usersData').then((users) => {
// Use users data
});
Network Stubbing with cy.intercept()
Intercept and mock HTTP requests:
// Basic stub
cy.intercept('GET', '/api/users', { fixture: 'users.json' });
// With status code
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [{ id: 1, name: 'John' }],
});
// Simulate error
cy.intercept('GET', '/api/users', {
statusCode: 500,
body: { error: 'Server error' },
});
// Delay response
cy.intercept('GET', '/api/users', {
body: { users: [] },
delay: 2000, // 2 second delay
});
// Pattern matching
cy.intercept('GET', '/api/users/*').as('getUser');
cy.intercept({ method: 'POST', url: '/api/**' }).as('apiPost');
// Modify request
cy.intercept('POST', '/api/users', (req) => {
req.headers['Authorization'] = 'Bearer test-token';
req.body.timestamp = Date.now();
});
// Modify response
cy.intercept('GET', '/api/users', (req) => {
req.reply((res) => {
res.body.users = res.body.users.slice(0, 5); // Limit to 5 users
});
});
// Conditional responses
let requestCount = 0;
cy.intercept('GET', '/api/status', (req) => {
requestCount++;
if (requestCount === 1) {
req.reply({ status: 'pending' });
} else {
req.reply({ status: 'complete' });
}
});
// Wait for network request
cy.intercept('POST', '/api/login').as('login');
cy.get('form').submit();
cy.wait('@login').then((interception) => {
expect(interception.request.body).to.have.property('email');
expect(interception.response.statusCode).to.eq(200);
});
Custom Commands
Extend Cypress with reusable commands:
// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
getByTestId(testId: string): Chainable<JQuery<HTMLElement>>;
}
}
}
Cypress.Commands.add('login', (email: string, password: string) => {
cy.session([email, password], () => {
cy.visit('/login');
cy.get('[data-cy="email"]').type(email);
cy.get('[data-cy="password"]').type(password);
cy.get('[data-cy="submit"]').click();
cy.url().should('include', '/dashboard');
});
});
Cypress.Commands.add('getByTestId', (testId: string) => {
return cy.get(`[data-testid="${testId}"]`);
});
export {};
// cypress/support/e2e.ts
import './commands';
// Global hooks
beforeEach(() => {
// Runs before every test
});
// Handle uncaught exceptions
Cypress.on('uncaught:exception', (err, runnable) => {
// Return false to prevent test failure
if (err.message.includes('ResizeObserver loop')) {
return false;
}
return true;
});
Use custom commands in tests:
// cypress/e2e/dashboard.cy.ts
describe('Dashboard', () => {
beforeEach(() => {
cy.login('[email protected]', 'password123');
});
it('displays user data', () => {
cy.visit('/dashboard');
cy.getByTestId('user-name').should('contain', 'John');
});
});
Component Testing
Cypress can test components in isolation:
pnpm add -D @cypress/react @cypress/vite-dev-server
// cypress.config.ts
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}',
},
});
// src/components/Button.cy.tsx
import { Button } from './Button';
describe('Button', () => {
it('renders with label', () => {
cy.mount(<Button label="Click me" />);
cy.get('button').should('have.text', 'Click me');
});
it('calls onClick when clicked', () => {
const onClick = cy.stub().as('onClick');
cy.mount(<Button label="Click me" onClick={onClick} />);
cy.get('button').click();
cy.get('@onClick').should('have.been.calledOnce');
});
it('renders different variants', () => {
cy.mount(<Button label="Primary" variant="primary" />);
cy.get('button').should('have.class', 'btn-primary');
});
});
Run component tests:
pnpm exec cypress open --component
# or
pnpm exec cypress run --component
CI Setup
GitHub Actions
# .github/workflows/cypress.yml
name: Cypress Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Build app
run: pnpm build
- name: Cypress run
uses: cypress-io/github-action@v6
with:
start: pnpm preview
wait-on: 'http://localhost:4173'
browser: chrome
- name: Upload screenshots
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
Parallelization with Cypress Cloud
For large test suites, Cypress Cloud provides parallelization:
# Record to Cypress Cloud
pnpm exec cypress run --record --key $CYPRESS_RECORD_KEY
# Parallel CI jobs
jobs:
cypress:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
containers: [1, 2, 3, 4]
steps:
# ... setup steps ...
- name: Cypress run
uses: cypress-io/github-action@v6
with:
record: true
parallel: true
group: 'E2E Tests'
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
Time-Travel Debugging
Cypress's most distinctive feature is its time-travel debugger:
- Open Cypress GUI:
pnpm exec cypress open - Run a test: Click on a spec file
- Hover over commands: See DOM snapshots at each step
- Click on commands: Jump to that point in time
- Use browser DevTools: Inspect elements, console, network
Additional Debugging Tools
// Pause execution
cy.pause();
// Debug at this point
cy.debug();
// Log to command log
cy.log('Current user:', user.name);
// Take screenshot
cy.screenshot('login-page');
// Full page screenshot
cy.screenshot('full-page', { capture: 'fullPage' });
Selector Playground
In the Cypress GUI:
- Click the crosshair icon
- Hover over elements
- Get suggested selectors
- Copy to clipboard
Gotchas and Tips
Don't Use async/await
Cypress commands are not Promises—they're queued and run sequentially:
// ❌ Wrong - async/await doesn't work
it('fails', async () => {
const text = await cy.get('h1').invoke('text');
expect(text).to.eq('Welcome');
});
// ✅ Correct - use .then()
it('works', () => {
cy.get('h1').invoke('text').then((text) => {
expect(text).to.eq('Welcome');
});
});
// ✅ Or use should() for assertions
it('also works', () => {
cy.get('h1').should('have.text', 'Welcome');
});
Variables and Closures
// ❌ Wrong - variable is undefined when cy.visit runs
let userName;
cy.get('.user').invoke('text').then((text) => {
userName = text;
});
cy.visit(`/users/${userName}`); // userName is still undefined!
// ✅ Correct - use .then() or alias
cy.get('.user').invoke('text').then((userName) => {
cy.visit(`/users/${userName}`);
});
// ✅ Or with alias
cy.get('.user').invoke('text').as('userName');
cy.get('@userName').then((userName) => {
cy.visit(`/users/${userName}`);
});
Same-Origin Policy
Cypress enforces same-origin. Multi-domain flows need cy.origin():
it('handles OAuth redirect', () => {
cy.visit('/login');
cy.get('.oauth-button').click();
// Handle different origin
cy.origin('https://auth-provider.com', () => {
cy.get('#email').type('[email protected]');
cy.get('#password').type('password');
cy.get('#login').click();
});
// Back on original origin
cy.url().should('include', '/dashboard');
});
Detached DOM Elements
Elements can become detached after re-renders:
// ❌ May fail if element re-renders
cy.get('.item').then(($item) => {
// ... some async operation
cy.wrap($item).click(); // $item may be detached
});
// ✅ Re-query the element
cy.get('.item').as('item');
// ... operations that cause re-render
cy.get('@item').click(); // Actually re-queries with same selector
Conditional Testing
Avoid conditional logic based on DOM state when possible:
// ❌ Problematic - race condition
cy.get('body').then(($body) => {
if ($body.find('.modal').length > 0) {
cy.get('.modal .close').click();
}
});
// ✅ Better - ensure known state
beforeEach(() => {
// Reset app state via API or command
cy.request('POST', '/api/reset');
});
Flaky Tests
Common causes and fixes:
// Problem: Animation not complete
cy.get('.animated-element').click(); // May click wrong location
// Fix: Wait for animation or disable
cy.get('.animated-element').should('be.visible').click();
// Or add { animationDistanceThreshold: 20 } to config
// Problem: Network timing
cy.get('.data').should('contain', 'Loaded'); // May timeout
// Fix: Intercept and wait
cy.intercept('GET', '/api/data').as('getData');
cy.visit('/');
cy.wait('@getData');
cy.get('.data').should('contain', 'Loaded');
Performance Tips
// cypress.config.ts
export default defineConfig({
e2e: {
// Disable video in CI if not needed
video: false,
// Only screenshot on failure
screenshotOnRunFailure: true,
// Reduce viewport for faster rendering
viewportWidth: 1280,
viewportHeight: 720,
// Increase timeout for slow CI
defaultCommandTimeout: 10000,
},
});
See Also
- Cypress Documentation
- Testing Overview - Testing philosophy and strategies
- Playwright vs. Cypress - Detailed comparison
- Playwright - Alternative E2E framework
- Vitest - Unit testing for component tests