Cypress Testing: Modern E2E Testing for Web Apps
Cypress is a JavaScript E2E testing framework built specifically for the modern web. It runs inside the browser, giving you real-time visibility into your tests as they execute. This guide covers installation, writing your first tests, handling authentication, CI integration, and when to choose Cypress over Playwright or Selenium.
Key Takeaways
Cypress runs in the browser. Unlike Selenium and Playwright, Cypress executes your test code inside the browser process. This gives you direct access to the DOM, application state, and network layer — and eliminates most timing issues.
No async/await required. Cypress uses a command queue. Commands are chained and execute sequentially. You write cy.get('.button').click(), not await page.click('.button'). This makes tests more readable.
cy.intercept() is powerful. You can stub any network request, return fake data, or assert on requests. This lets you test error states, loading states, and edge cases without a real backend.
Use cy.session() for authentication. Save login state once per test run, not once per test. cy.session() caches and restores cookies/localStorage automatically.
Cypress is Chromium-only for E2E. Firefox support exists but is experimental. If you need cross-browser E2E (especially Safari/WebKit), use Playwright instead.
What Is Cypress?
Cypress is an open-source end-to-end testing framework for web applications. Released in 2015, it took a different approach from Selenium: instead of using WebDriver to control a browser from outside, Cypress runs directly inside the browser alongside your application.
This architecture means:
- No network latency between test commands and browser
- Direct access to application code (window, document, store)
- Automatic waiting — no
sleep()or explicit waits needed - Real-time screenshots and videos of test execution
Cypress is the most popular E2E testing tool for JavaScript frontend applications, particularly React, Vue, and Angular apps.
What Cypress Is Good For
- Single-page applications (React, Vue, Angular, Svelte)
- Forms, modals, navigation flows
- API testing (Cypress can test REST APIs too)
- Component testing (Cypress has a component testing mode)
- Teams that want a visual, interactive test runner
What Cypress Is Not Good For
- Multi-tab testing (Cypress can't control multiple tabs)
- Cross-origin iframes (same-origin policy restrictions)
- Non-Chromium browsers in production (WebKit/Safari)
- Mobile app testing
Installing Cypress
npm install --save-dev cypress
Open the Cypress app for the first time:
npx cypress open
This launches the Cypress Launchpad, which guides you through:
- Choosing E2E Testing or Component Testing
- Selecting a browser
- Creating your first spec file
For a headless CI run:
npx cypress run
Your First Test
Cypress creates a cypress/ folder with this structure:
cypress/
e2e/ # Your test files
fixtures/ # Static test data
support/ # Shared commands and hooks
cypress.config.js
Create cypress/e2e/homepage.cy.js:
describe('Homepage', () => {
beforeEach(() => {
cy.visit('https://example.com')
})
it('displays the main heading', () => {
cy.get('h1').should('be.visible')
cy.get('h1').should('contain.text', 'Example Domain')
})
it('has a working link', () => {
cy.get('a').first().click()
cy.url().should('not.equal', 'https://example.com/')
})
})
Run it:
npx cypress run --spec cypress/e2e/homepage.cy.js
Core Concepts
The Command Queue
Cypress commands don't execute immediately. They're added to a queue and run sequentially:
cy.visit('/login') // 1. Navigate
cy.get('[name="email"]').type('...') // 2. Find input, type
cy.get('[name="password"]').type('...') // 3. Find input, type
cy.get('button[type="submit"]').click() // 4. Click submit
cy.url().should('include', '/dashboard') // 5. Assert URL changed
Each command automatically waits for the previous one to complete.
Automatic Waiting
Cypress retries commands until they succeed or timeout (default: 4 seconds). You don't write explicit waits:
// This works even if the button appears after a network request
cy.get('.success-message').should('be.visible')
// Cypress will retry cy.get() until the element exists and is visible
// No need for: await page.waitForSelector('.success-message')
Subject Chaining
Commands pass their subject to the next command:
cy.get('form') // Subject: the form element
.find('input') // Subject: inputs inside the form
.first() // Subject: first input
.type('hello') // Acts on the first input
Selectors
Best Practices
Prefer selectors that don't break when the UI changes:
// Best: data-cy attributes — explicit, stable
cy.get('[data-cy="submit-button"]').click()
cy.get('[data-testid="email-input"]').type('user@example.com')
// Good: semantic HTML
cy.get('button[type="submit"]').click()
cy.get('input[name="email"]').type('user@example.com')
// Avoid: brittle CSS classes
cy.get('.btn-primary.rounded-lg.mt-4').click() // breaks with style changes
// Avoid: XPath (verbose, slow)
cy.xpath('//button[@type="submit"]').click()
Querying Within Elements
// Find within a specific container
cy.get('.product-card').first().within(() => {
cy.get('.price').should('contain', '$')
cy.get('button').click()
})
// Find by text content
cy.contains('Add to Cart').click()
cy.contains('button', 'Submit').click() // button containing "Submit"
// Find by position
cy.get('li').first()
cy.get('li').last()
cy.get('li').eq(2) // 3rd item (0-indexed)
Assertions
// Visibility
cy.get('.modal').should('be.visible')
cy.get('.modal').should('not.exist')
// Text
cy.get('h1').should('have.text', 'Welcome back')
cy.get('.status').should('contain', 'Active')
// Value
cy.get('input[name="email"]').should('have.value', 'user@example.com')
// Attributes
cy.get('a').should('have.attr', 'href', '/about')
cy.get('button').should('be.disabled')
cy.get('button').should('not.be.disabled')
// CSS
cy.get('.alert').should('have.class', 'alert-success')
// Count
cy.get('li').should('have.length', 5)
cy.get('li').should('have.length.greaterThan', 3)
// URL
cy.url().should('include', '/dashboard')
cy.url().should('eq', 'https://app.example.com/dashboard')
// Chaining multiple assertions
cy.get('.user-card')
.should('be.visible')
.and('contain', 'John Doe')
.and('not.have.class', 'inactive')
Actions
// Click
cy.get('button').click()
cy.get('button').dblclick()
cy.get('button').rightclick()
cy.get('button').click({ force: true }) // Force click (even if hidden)
// Type
cy.get('input').type('Hello, World!')
cy.get('input').type('{enter}') // Press Enter
cy.get('input').type('{ctrl+a}{del}') // Select all, delete
cy.get('input').clear().type('New text') // Clear then type
// Forms
cy.get('select').select('Option 2')
cy.get('select').select(['A', 'B']) // Multi-select
cy.get('[type="checkbox"]').check()
cy.get('[type="checkbox"]').uncheck()
cy.get('[type="radio"]').check('option-value')
// Drag
cy.get('.draggable').drag('.droppable') // Requires cypress-drag-drop
// File upload
cy.get('input[type="file"]').selectFile('path/to/file.pdf')
cy.get('input[type="file"]').selectFile('path/to/file.pdf', { action: 'drag-drop' })
// Scroll
cy.scrollTo('bottom')
cy.get('.container').scrollTo(0, 500)
cy.get('.lazy-element').scrollIntoView()
Authentication
cy.session() — The Right Way
// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.session(
[email, password], // Cache key
() => {
cy.visit('/login')
cy.get('[name="email"]').type(email)
cy.get('[name="password"]').type(password)
cy.get('button[type="submit"]').click()
cy.url().should('include', '/dashboard')
},
{
validate() {
// Validate the session is still active
cy.getCookie('session_token').should('exist')
}
}
)
})
// In your tests
beforeEach(() => {
cy.login('user@example.com', 'password123')
})
it('shows dashboard', () => {
cy.visit('/dashboard')
cy.get('h1').should('contain', 'Dashboard')
})
cy.session() runs the login function once, caches the session (cookies, localStorage), and restores it for subsequent tests. If the session becomes invalid, it re-runs the login automatically.
Direct API Authentication (Faster)
Cypress.Commands.add('loginViaAPI', (email, password) => {
cy.request({
method: 'POST',
url: '/api/auth/login',
body: { email, password }
}).then(response => {
window.localStorage.setItem('token', response.body.token)
})
})
This skips the login UI entirely — much faster.
Intercepting Network Requests
cy.intercept() is one of Cypress's most powerful features.
Stubbing Responses
// Return fake data
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [
{ id: 1, name: 'Alice', email: 'alice@example.com' }
]
}).as('getUsers')
cy.visit('/users')
cy.wait('@getUsers') // Wait for the intercepted request
cy.get('.user-row').should('have.length', 1)
cy.get('.user-row').should('contain', 'Alice')
Simulating Errors
cy.intercept('POST', '/api/checkout', {
statusCode: 500,
body: { error: 'Payment gateway unavailable' }
})
cy.get('button[data-cy="place-order"]').click()
cy.get('[data-cy="error-message"]')
.should('be.visible')
.and('contain', 'Something went wrong')
Asserting on Requests
cy.intercept('POST', '/api/login').as('loginRequest')
cy.get('[name="email"]').type('user@example.com')
cy.get('[name="password"]').type('password123')
cy.get('button[type="submit"]').click()
cy.wait('@loginRequest').then(interception => {
expect(interception.request.body).to.deep.equal({
email: 'user@example.com',
password: 'password123'
})
expect(interception.response.statusCode).to.equal(200)
})
Delaying Responses (Test Loading States)
cy.intercept('GET', '/api/data', req => {
req.reply(res => {
res.delay(2000) // 2 second delay
res.send({ data: [] })
})
})
cy.visit('/dashboard')
cy.get('[data-cy="loading-spinner"]').should('be.visible')
cy.get('[data-cy="loading-spinner"]').should('not.exist') // Gone after load
Custom Commands
Add reusable commands to cypress/support/commands.js:
// Fill a form field
Cypress.Commands.add('fillField', (label, value) => {
cy.contains('label', label)
.then($label => {
const inputId = $label.attr('for')
cy.get(`#${inputId}`).clear().type(value)
})
})
// Check accessibility
Cypress.Commands.add('checkA11y', (context, options) => {
cy.injectAxe()
cy.checkA11y(context, options)
})
// Use in tests
cy.fillField('Email', 'user@example.com')
cy.fillField('Password', 'password123')
Fixtures
Store static test data in cypress/fixtures/:
// cypress/fixtures/user.json
{
"email": "test@example.com",
"password": "password123",
"name": "Test User"
}
// cypress/fixtures/products.json
[
{ "id": 1, "name": "Widget A", "price": 9.99 },
{ "id": 2, "name": "Widget B", "price": 19.99 }
]
// Load fixture in tests
cy.fixture('user').then(user => {
cy.get('[name="email"]').type(user.email)
cy.get('[name="password"]').type(user.password)
})
// Stub API with fixture
cy.intercept('GET', '/api/products', { fixture: 'products' })
Configuration
// cypress.config.js
const { defineConfig } = require('cypress')
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 4000, // Wait up to 4s for commands
requestTimeout: 10000, // Wait up to 10s for requests
responseTimeout: 30000,
video: false, // Disable video in CI for speed
screenshotOnRunFailure: true,
retries: {
runMode: 1, // Retry once in CI
openMode: 0 // No retries locally
},
env: {
apiUrl: 'http://localhost:3001/api'
}
}
})
// Access env variables in tests
cy.request(Cypress.env('apiUrl') + '/users')
CI/CD Integration
GitHub Actions
# .github/workflows/cypress.yml
name: Cypress Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Start application
run: npm run dev &
# Or use cypress built-in: start-server-and-test package
- name: Run Cypress tests
uses: cypress-io/github-action@v6
with:
wait-on: 'http://localhost:3000'
wait-on-timeout: 60
- uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
Parallel Execution with Cypress Cloud
- name: Run Cypress tests in parallel
uses: cypress-io/github-action@v6
with:
record: true
parallel: true
group: 'CI Tests'
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
Cypress Cloud (free tier available) distributes tests across machines and provides a dashboard with test history, screenshots, and videos.
Component Testing
Cypress also supports component testing — test React/Vue components in isolation without a full browser session:
// cypress/component/Button.cy.jsx
import Button from '../../src/components/Button'
describe('Button', () => {
it('calls onClick when clicked', () => {
const onClick = cy.stub().as('clickHandler')
cy.mount(<Button onClick={onClick}>Click me</Button>)
cy.get('button').click()
cy.get('@clickHandler').should('have.been.calledOnce')
})
it('shows loading state', () => {
cy.mount(<Button loading>Submit</Button>)
cy.get('button').should('be.disabled')
cy.get('.spinner').should('be.visible')
})
})
Run component tests:
npx cypress run --component
Debugging
Open Mode (Interactive)
npx cypress open
The Cypress Test Runner shows:
- Every command and its result
- Before/after screenshots for each step
- Network requests
- Console errors
.debug() and .pause()
cy.get('.element').debug() // Pauses, logs element to console
cy.get('.element').pause() // Pauses test, waits for you to continue
cy.log()
cy.log('About to click the submit button')
cy.get('button').click()
Screenshots
cy.screenshot('before-login')
// or automatically on failure (configured in cypress.config.js)
Cypress vs Playwright
| Feature | Cypress | Playwright |
|---|---|---|
| Architecture | Runs inside browser | Runs outside browser |
| Browsers | Chrome, Edge, Firefox* | Chrome, Firefox, WebKit |
| Safari/WebKit | No | Yes |
| Multi-tab | No | Yes |
| Cross-origin | Limited | Yes |
| API | Chainable, synchronous-style | Async/await |
| Learning curve | Low | Medium |
| Interactive runner | Excellent | Good (UI mode) |
| Component testing | Yes (Cypress CT) | Yes (experimental) |
| Best for | Web SPAs, beginners | Complex flows, cross-browser |
*Firefox support in Cypress is experimental.
Choose Cypress when:
- Building a React/Vue/Angular SPA
- Your team is new to E2E testing
- You want an excellent interactive debugging experience
- No Safari/multi-tab requirements
Choose Playwright when:
- You need Safari/WebKit testing
- You need multi-tab, multi-origin flows
- You want Python, Java, or C# support
- You're testing complex auth flows across domains
Common Patterns
Test Data Cleanup
beforeEach(() => {
// Reset via API before each test
cy.request('DELETE', '/api/test/reset')
})
Conditional Testing (Use Sparingly)
cy.get('body').then($body => {
if ($body.find('.cookie-banner').length) {
cy.get('[data-cy="accept-cookies"]').click()
}
})
Retry-ability
// This retries until all 5 items appear
cy.get('.item').should('have.length', 5)
// This retries until the text changes
cy.get('.status').should('have.text', 'Complete')
Getting Started Checklist
- Install:
npm install --save-dev cypress - Create
cypress.config.jswith yourbaseUrl - Write first test for your most critical user flow
- Add
data-cyattributes to key elements in your app - Set up
cy.session()for authentication - Add
cy.intercept()stubs for unreliable external APIs - Add GitHub Actions workflow
Testing manually every deploy? HelpMeTest turns your user flows into automated Cypress-compatible tests — no code required.