Cypress E2E Testing Tutorial for QA Automation Engineers
What is Cypress?
Cypress is a modern, JavaScript-based end-to-end (E2E) testing framework designed from the ground up to address the pain points that plague traditional automation tools like Selenium. Unlike Selenium, which runs as a separate process outside the browser and communicates via HTTP protocols, Cypress runs directly inside your browser in the same execution loop as your application code.
This architectural difference is fundamental and game-changing. When Cypress executes a command like cy.click(), it's not sending a remote HTTP request to an external driver that then simulates a click. Instead, it's running directly in your application's JavaScript context, giving it complete access to your DOM, your JavaScript objects, and your network layer. This level of access eliminates the timing issues, race conditions, and reliability problems that plague Selenium-based tests.
Why Cypress Matters for Modern QA Teams
The testing landscape has changed dramatically. Modern web applications are increasingly JavaScript-heavy, with complex state management, real-time updates, and asynchronous operations. Traditional testing tools struggle with this reality because they rely on external communication and don't understand the internal state of your application. Teams spend more time fixing brittle, flaky tests than actually testing features.
Cypress solves this by giving you direct access to your application's internals. When a test fails, you don't just see a screenshot—you can step back through your test execution using time-travel debugging and see exactly what happened at each step. Network requests are under your control via cy.intercept(), which lets you stub responses and test edge cases without hitting real APIs. This results in tests that are faster, more reliable, and easier to debug.
Who Should Use Cypress?
Cypress is ideal for: QA engineers and developers testing modern web applications built with React, Vue, Angular, or vanilla JavaScript. It's particularly powerful for teams that want reliable, maintainable test suites without the debugging headaches of traditional tools.
When to consider Selenium instead: If you need to test multiple browsers simultaneously (Cypress currently supports Chrome, Firefox, Edge, and Electron), or if you're testing legacy applications with minimal JavaScript, Selenium might be more suitable. Cypress also cannot test non-web applications (mobile apps, desktop apps), so explore Appium or other tools for those scenarios.
Cypress vs Selenium
| Feature | Cypress | Selenium |
|---|---|---|
| Language | JavaScript / TypeScript | Java, Python, C#, JS… |
| Setup | Very easy (npm install) | Needs WebDriver setup |
| Speed | Fast (in-browser) | Moderate (out-of-browser) |
| Debugging | Excellent (time-travel) | Good (logs/screenshots) |
| API Testing | Built-in cy.request() | Not built-in |
| Browser Support | Chrome, Firefox, Edge, Electron | All major browsers |
Basic Cypress Test Structure
describe('TechWorld Labs Login Test', () => { beforeEach(() => { cy.visit('https://example.com/login') }) it('should login with valid credentials', () => { cy.get('#email').type('[email protected]') cy.get('#password').type('securePass123') cy.get('button[type="submit"]').click() cy.url().should('include', '/dashboard') cy.contains('Welcome').should('be.visible') }) it('should show error for wrong password', () => { cy.get('#password').type('wrongpass') cy.get('button[type="submit"]').click() cy.get('.error-msg').should('contain', 'Invalid credentials') }) })
Top Interview Questions — Cypress
What is Cypress and how does it differ from Selenium?
Cypress runs inside the browser in the same JS loop as your app. Selenium uses a WebDriver that communicates over HTTP from outside the browser.
What is cy.intercept() used for?
cy.intercept() stubs, spies on, or mocks network requests — making tests deterministic and fast without hitting real APIs.
Explain the Cypress retry mechanism.
Cypress automatically retries commands like cy.get() and assertions until they pass or the timeout (default 4s) expires.
What is the difference between cy.get() and cy.find()?
cy.get() queries the entire DOM. cy.find() queries within a previously yielded element — it's a scoped child selector.
Prerequisites
Before installing Cypress, ensure you have the following tools set up on your machine. The good news is that most developers already have these installed.
To check if Node.js is installed, open your terminal and run node -v and npm -v. You should see version numbers (e.g., v16.13.0) for both commands.
Step-by-Step Installation & Setup
Setting up Cypress is remarkably straightforward compared to Selenium. The installation process takes just a few minutes, and you'll be ready to write your first test immediately.
Create project folder
Open your terminal (Command Prompt, PowerShell, or Bash) and create a new directory for your test project. This will contain all your test files, configuration, and dependencies.
Initialize npm
Navigate into your project folder and run npm init -y to automatically create a package.json file. This file tracks your project dependencies and npm scripts.
Install Cypress
Run npm install cypress --save-dev to install Cypress as a development dependency. This downloads the entire Cypress application (~1-2 GB) to your node_modules folder. The first installation may take a few minutes.
Open Cypress for the first time
Run npx cypress open to launch the Cypress Launchpad. This interactive GUI lets you choose between E2E and Component testing, select a browser, and create your first test file.
# Create project mkdir my-cypress-project && cd my-cypress-project # Init npm npm init -y # Install Cypress npm install cypress --save-dev # Open Cypress GUI npx cypress open # Run headlessly (CI) npx cypress run
npx cypress open, the Launchpad appears. Choose E2E Testing to begin.Writing Your First Test
Cypress tests are written in JavaScript using a familiar syntax borrowed from Mocha and Chai testing frameworks. This means if you're already comfortable with JavaScript, you'll pick up Cypress testing almost immediately—no new language to learn.
By convention, test files live in the cypress/e2e/ directory and must end with .cy.js or .cy.ts (if using TypeScript). The .cy extension tells Cypress to treat this file as a test file. When you run npx cypress run, Cypress automatically discovers all files matching this pattern and executes them.
Tests are organized using two core functions: describe() for grouping related tests (test suites), and it() for individual test cases. This hierarchical structure makes it easy to organize large test suites and understand what each test is checking.
describe('My First Cypress Test', () => { it('Visits TechWorld Labs homepage', () => { cy.visit('https://thetechworldlabs.com') cy.title().should('include', 'TechWorld Labs') cy.get('nav').should('be.visible') cy.contains('Tutorials').click() cy.url().should('include', '/tutorials') }) })
Test Anatomy
describe()
Groups related test cases. Acts as the test suite — can be nested.
it()
Defines one test case. Each it() tests a single specific behaviour.
cy.visit()
Navigates the browser to a URL — almost always the first command.
.should()
Asserts expected state. Cypress retries until it passes or times out.
my-project/ ├── cypress/ │ ├── e2e/ # Test files (.cy.js) │ ├── fixtures/ # Test data (JSON files) │ ├── support/ │ │ ├── commands.js # Custom commands │ │ └── e2e.js # Global setup / imports │ └── downloads/ # Files downloaded during tests ├── cypress.config.js # Configuration (baseUrl, timeouts…) └── package.json
baseUrl, viewportWidth, defaultCommandTimeout, and environment variables.Querying Elements in Cypress
Selecting HTML elements is the foundation of any test. Cypress provides multiple ways to find elements, each suited to different scenarios. Understanding when to use each method will help you write stable, maintainable tests that don't break when your UI changes.
| Method | Best Used For | Example |
|---|---|---|
cy.get() | CSS selectors, IDs, or data attributes | cy.get('#submit') or cy.get('[data-cy="btn"]') |
cy.contains() | Finding by visible text (useful for buttons, links) | cy.contains('Login') or cy.contains('Save Changes') |
cy.find() | Finding child elements within a parent | cy.get('table').find('tr') |
cy.first() | Selecting the first element from a list | cy.get('li').first() |
cy.eq(n) | Selecting a specific element by index | cy.get('li').eq(2) (gets the 3rd item) |
Why data-cy Attributes Matter
Beginners often make the mistake of using fragile selectors like class names or element types. For example, cy.get('.btn') might work today, but if your designer adds a new button and reorders the CSS, your test breaks even though the actual functionality hasn't changed.
The professional approach is to use dedicated data-cy attributes specifically for testing. These act as a contract between your test and your application: "As long as this element has data-cy="submit-btn", the test will work." This decouples your tests from implementation details like styling, making them more resilient to refactoring.
data-cy="..." attributes for test selectors. They don't change when you refactor CSS or restructure your DOM, keeping your tests stable through iterations.// Preferred — data-cy attribute cy.get('[data-cy="login-btn"]').click() // Chaining selectors cy.get('.form-group') .find('input[type="email"]') .type('[email protected]')
| Assertion | Checks |
|---|---|
.should('be.visible') | Element is visible on page |
.should('exist') | Element exists in DOM |
.should('have.text', '…') | Exact text content |
.should('contain', '…') | Partial text match |
.should('have.value', '…') | Input field value |
.should('have.class', '…') | CSS class is present |
.should('be.disabled') | Element is disabled |
.should('have.length', n) | Number of matched elements |
cy.get('.alert') .should('be.visible') .and('have.class', 'alert-success') .and('contain', 'Saved successfully')
Click Actions
| Command | Description |
|---|---|
.click() | Single left click |
.dblclick() | Double click |
.rightclick() | Right-click (context menu) |
.click({ force: true }) | Click even if element is hidden or overlapped |
.click({ multiple: true }) | Click all matched elements |
.click('topRight') | Click a specific position on the element |
Keyboard & Input
// Type text cy.get('#search').type('cypress automation') // Special key sequences cy.get('#name').type('Hello{selectAll}{del}') cy.get('input').type('{ctrl}a').type('{backspace}') cy.get('form input').type('admin{enter}') // submit on enter cy.get('#email').type('{tab}') // move focus to next field // Clear and type slowly (like a real user) cy.get('#amount').clear().type('99.99', { delay: 50 })
Forms: Checkbox, Radio & Select
// Checkbox cy.get('#agree-terms').check() cy.get('#newsletter').uncheck() cy.get('#agree-terms').should('be.checked') // Radio button cy.get('input[type="radio"][value="monthly"]').check() // Native <select> dropdown cy.get('select#country').select('Nepal') cy.get('select#plan').select('pro') // by value cy.get('select#size').select(['S', 'M']) // multi-select
Hover, Drag & Scroll
// Hover (trigger mouseover event) cy.get('.tooltip-trigger').trigger('mouseover') cy.get('.tooltip').should('be.visible') // Real hover (install cypress-real-events) // import 'cypress-real-events' in e2e.js cy.get('.dropdown-menu').realHover() // Drag and drop using cypress-real-events cy.get('[data-cy="drag-item"]').realMouseDown() cy.get('[data-cy="drop-zone"]').realMouseMove(0, 0).realMouseUp() // Scroll commands cy.scrollTo('bottom') cy.get('#footer').scrollIntoView() cy.scrollTo(0, 500)
// Define a reusable login command Cypress.Commands.add('login', (email, password) => { cy.visit('/login') cy.get('#email').type(email) cy.get('#password').type(password) cy.get('button[type="submit"]').click() }) // Use it in any test file cy.login('[email protected]', 'pass123')
| Hook | When it runs | Common use |
|---|---|---|
before() | Once before all tests in describe | One-time DB seed, login |
beforeEach() | Before every it() | Navigate to page, set state |
afterEach() | After every it() | Clear cookies, take screenshot on fail |
after() | Once after all tests | DB cleanup, logout |
describe('Dashboard Tests', () => { before(() => { cy.login('[email protected]', 'pass') }) beforeEach(() => { cy.visit('/dashboard') }) afterEach(() => { cy.clearCookies() }) after(() => { cy.request('POST', '/api/cleanup') }) })
Understanding Cypress Waiting & Retry Logic
One of Cypress's most powerful features is its built-in retry mechanism. Unlike Selenium, where you often write explicit waits like WebDriverWait(driver, 10).until(...), Cypress is intelligent about waiting. It understands that web applications are asynchronous and that elements might not be available immediately.
When you run a Cypress command like cy.get('.user-name'), Cypress doesn't just look for the element once and fail if it doesn't exist. Instead, it continuously retries until the element appears or the timeout (default 4 seconds) expires. This automatic retry mechanism eliminates the vast majority of flaky tests caused by timing issues.
However, not all waits are automatic. Some operations require explicit strategies: waiting for network requests to complete, waiting for data to load, or waiting for animations to finish. Cypress provides specific methods for each scenario, and choosing the right one is key to writing stable tests.
How Cypress Waits (Built-in Retry)
Cypress automatically retries most commands and assertions until they pass or the defaultCommandTimeout (4 seconds by default) expires. You rarely need explicit waits for DOM state changes.
| Strategy | When to use | Example |
|---|---|---|
| Assertion retry | Waiting for DOM state | cy.get('.spinner').should('not.exist') |
| Network alias wait | After a page action triggers an XHR | cy.wait('@getUsers') |
| Timeout override | Slow operations (file generation, reports) | cy.get('.pdf-ready', { timeout: 15000 }) |
cy.wait(ms) | Last resort — external delay you can't observe | cy.wait(1000) |
// Define intercept alias before action cy.intercept('POST', '/api/checkout').as('checkout') cy.get('[data-cy="buy-btn"]').click() // Wait until the checkout request completes cy.wait('@checkout').then((interception) => { expect(interception.response.statusCode).to.eq(200) }) cy.get('.order-confirmation').should('be.visible')
// Wait for loading spinner to disappear cy.get('.loading-overlay').should('not.exist') // Wait for element to become enabled cy.get('button[type="submit"]').should('not.be.disabled').click() // Extend timeout for a specific command only cy.get('[data-cy="report-table"]', { timeout: 20000 }) .should('have.length.greaterThan', 0) // Increase global timeout in cypress.config.js // defaultCommandTimeout: 10000
cy.wait(ms) for elements or network calls — it makes tests slow and brittle. Reserve it only for true external delays (e.g. a third-party email service that takes 1–2 s to deliver).{
"email": "[email protected]",
"password": "test1234",
"role": "admin"
}
cy.fixture('user').then((user) => { cy.get('#email').type(user.email) cy.get('#password').type(user.password) cy.get('button[type="submit"]').click() })
cy.request({ method: 'POST', url: 'https://api.example.com/users', body: { name: 'Suraj', role: 'QA' } }).then((response) => { expect(response.status).to.eq(201) expect(response.body.name).to.eq('Suraj') })
cy.intercept('GET', '/api/products', { fixture: 'products.json' }).as('getProducts') cy.visit('/products') cy.wait('@getProducts') cy.get('.product-card').should('have.length', 3)
Why Page Object Model Matters
Imagine you're testing a login page with 50 different test cases. The login form has an email input, password input, and a submit button. Your tests reference these selectors repeatedly:
cy.get('#email'), cy.get('#password'), cy.get('button[type="submit"]')
Now, your developer refactors the form and changes the ID from #email to #user-email. Suddenly, all 50 tests break. You have to go through and update the selector in every single test. This is a maintenance nightmare.
The Page Object Model solves this by centralizing all selectors and actions for a page into a single class. You have one place to update when the UI changes. Your tests become cleaner, more readable, and dramatically easier to maintain.
class LoginPage { visit() { cy.visit('/login') } enterEmail(email) { cy.get('#email').type(email) } enterPassword(pass) { cy.get('#password').type(pass) } submit() { cy.get('[type="submit"]').click() } login(email, pass) { this.visit() this.enterEmail(email) this.enterPassword(pass) this.submit() } } export default new LoginPage()
import loginPage from '../topics/LoginPage' it('logs in successfully', () => { loginPage.login('[email protected]', 'pass123') cy.url().should('include', '/dashboard') })
Spy vs Stub vs Alias
| Pattern | What it does | Example |
|---|---|---|
| Spy | Observe a request, let it pass through | cy.intercept('GET', '/api/users').as('getUsers') |
| Stub | Return fake response, block real network | cy.intercept('GET', '/api/users', { fixture: 'users.json' }) |
| Modify | Intercept and alter the live response | cy.intercept('GET', '/api/users', (req) => { req.reply(res => { ... }) }) |
// Spy on a request and wait for it to complete cy.intercept('GET', '/api/products').as('getProducts') cy.visit('/products') cy.wait('@getProducts').its('response.statusCode').should('eq', 200) cy.get('.product-list').should('be.visible')
// Return fixture data instead of hitting real API cy.intercept('GET', '/api/products', { fixture: 'products.json' }).as('stubProducts') cy.visit('/products') cy.wait('@stubProducts') cy.get('.product-card').should('have.length', 3)
// Match with wildcards and query params cy.intercept({ method: 'GET', url: '/api/users/**' }, (req) => { req.reply({ statusCode: 200, body: [{ id: 1, name: 'Test User' }] }) }).as('getUser') // Assert on request body for POST cy.intercept('POST', '/api/login').as('loginRequest') cy.wait('@loginRequest').its('request.body').should('deep.include', { email: 'user@test.com' })
cy.wait('@alias') instead of cy.wait(2000). Waiting for a named request is deterministic — it finishes as soon as the response arrives.cypress.config.js — Key Options
| Option | Default | Purpose |
|---|---|---|
baseUrl | null | Prefix for cy.visit() and cy.request() |
defaultCommandTimeout | 4000ms | How long Cypress retries commands |
viewportWidth / Height | 1280 × 720 | Default browser window size |
specPattern | cypress/e2e/**/*.cy.{js,jsx,ts,tsx} | Glob for test files |
retries | 0 | Auto-retry failing tests |
video | false | Record video during cypress run |
screenshotOnRunFailure | true | Auto-screenshot on failure |
const { defineConfig } = require('cypress') module.exports = defineConfig({ e2e: { baseUrl: 'https://staging.example.com', defaultCommandTimeout: 6000, viewportWidth: 1440, viewportHeight: 900, retries: { runMode: 2, openMode: 0 }, video: false, env: { apiUrl: 'https://api.staging.example.com', adminEmail: 'admin@example.com' } } })
// Read from cypress.config.js env block const apiUrl = Cypress.env('apiUrl') // Override via CLI (e.g. for different environments) // npx cypress run --env apiUrl=https://prod.example.com // Or via .env file with cypress-dotenv plugin cy.request(`${apiUrl}/users`).then((res) => { expect(res.status).to.eq(200) })
CYPRESS_* OS environment variables (e.g. CYPRESS_API_KEY=abc123) — Cypress automatically picks them up as Cypress.env('API_KEY').Setup TypeScript in Cypress
Install TypeScript
npm install typescript --save-dev
Create tsconfig.json
Configure TypeScript for the cypress/ folder.
Rename files
Change .cy.js → .cy.ts and commands.js → commands.ts
Add type definitions
Extend Cypress.Chainable for custom commands.
{
"compilerOptions": {
"target": "ES6",
"lib": ["ES6", "DOM"],
"types": ["cypress", "node"],
"strict": false,
"esModuleInterop": true
},
"include": ["**/*.ts"]
}
// cypress/support/commands.ts Cypress.Commands.add('login', (email: string, password: string) => { cy.request({ method: 'POST', url: '/api/login', body: { email, password } }) .then((res) => { cy.setCookie('auth_token', res.body.token) }) }) // cypress/support/index.d.ts — type declaration declare namespace Cypress { interface Chainable { login(email: string, password: string): Chainable<void> } }
import loginPage from '../topics/LoginPage' describe('Login', () => { it('logs in via API then visits dashboard', () => { cy.login('admin@test.com', 'pass123') cy.visit('/dashboard') cy.get('[data-cy="welcome-msg"]').should('be.visible') }) })
The Authentication Problem in E2E Testing
Almost every real-world application requires authentication. The challenge is that if you're testing feature X (e.g., "user can create a post"), you don't want to spend 5-10 seconds logging in via the UI before testing that feature. With 100 tests, repeating login in each test means 500 seconds just spent on authentication — that's over 8 minutes of wasted test execution time.
The solution is to handle authentication efficiently: log in once per test session (or once per test), cache the authentication state, and reuse it. Cypress provides three powerful patterns for this, each suited to different scenarios. Choosing the right pattern can cut your test execution time in half.
Pattern 1 — Programmatic API Login (Fastest)
This pattern logs in directly via your API without touching the UI at all. You make an API call to /api/login, receive a token, and set it in localStorage or as a cookie. This is the fastest approach (completes in milliseconds) and is ideal when your API is reliable and stable.
// Skip the UI entirely — hit the login API directly Cypress.Commands.add('loginByApi', (email, password) => { cy.request('POST', '/api/auth/login', { email, password }) .then((resp) => { window.localStorage.setItem('authToken', resp.body.token) }) }) // In test: beforeEach(() => { cy.loginByApi('user@test.com', 'pass123') cy.visit('/dashboard') })
Pattern 2 — cy.session() (Cache & Reuse)
// cy.session() runs the setup only once per session key // Subsequent calls restore cookies/storage from cache Cypress.Commands.add('login', (email, password) => { cy.session([email, password], () => { cy.visit('/login') cy.get('#email').type(email) cy.get('#password').type(password) cy.get('[type="submit"]').click() cy.url().should('include', '/dashboard') }, { validate() { cy.request('/api/profile').its('status').should('eq', 200) } }) })
Pattern 3 — JWT / Token Injection
// Intercept every request and inject a Bearer token cy.intercept('**', (req) => { req.headers['Authorization'] = `Bearer ${Cypress.env('JWT_TOKEN')}` }) // Or set it on localStorage before visiting cy.window().then((win) => { win.localStorage.setItem('token', Cypress.env('JWT_TOKEN')) }) cy.visit('/app')
Parameterized Tests with forEach
const loginScenarios = [ { email: 'admin@test.com', password: 'pass1', expectedUrl: '/admin' }, { email: 'user@test.com', password: 'pass2', expectedUrl: '/dashboard' }, { email: 'viewer@test.com', password: 'pass3', expectedUrl: '/read-only' } ] loginScenarios.forEach(({ email, password, expectedUrl }) => { it(`redirects ${email} to ${expectedUrl}`, () => { cy.visit('/login') cy.get('#email').type(email) cy.get('#password').type(password) cy.get('[type="submit"]').click() cy.url().should('include', expectedUrl) }) })
Fixture-Driven Data Tests
[
{ "name": "Laptop", "price": 1200, "category": "Electronics" },
{ "name": "Desk", "price": 350, "category": "Furniture" },
{ "name": "Headset", "price": 89, "category": "Electronics" }
]
cy.fixture('products').then((products) => { products.forEach((product) => { cy.request('POST', '/api/products', product) .its('status').should('eq', 201) }) }) // cy.each() — iterate over DOM elements cy.get('.product-card').each(($card) => { cy.wrap($card).find('.price').should('be.visible') cy.wrap($card).find('.add-to-cart').should('not.be.disabled') })
context() or describe() inside forEach to group related dataset scenarios together in the test report.Browser Alerts & Confirms
// Cypress auto-accepts alert() — no action needed // To assert the alert text: cy.on('window:alert', (text) => { expect(text).to.eq('Item deleted!') }) cy.get('[data-cy="delete-btn"]').click() // Override confirm() to return false (cancel) cy.on('window:confirm', () => false) cy.get('[data-cy="delete-btn"]').click() cy.get('.item-row').should('still.exist')
iframes
// Install: npm install cypress-iframe --save-dev // In e2e.js: import 'cypress-iframe' cy.frameLoaded('#payment-iframe') cy.iframe('#payment-iframe') .find('input[name="cardNumber"]') .type('4111111111111111') // Without plugin — using cy.wrap on contentDocument cy.get('iframe').its('0.contentDocument.body') .should('not.be.empty') .then(cy.wrap) .find('button[type="submit"]') .click()
New Tabs & Windows
// Cypress runs in one tab — remove target="_blank" to test in same window cy.get('a[target="_blank"]') .invoke('removeAttr', 'target') .click() cy.url().should('include', '/new-page') // Or stub window.open and assert URL cy.window().then((win) => { cy.stub(win, 'open').as('windowOpen') }) cy.get('.external-link').click() cy.get('@windowOpen').should('be.calledWithMatch', 'https://docs.example.com')
Cookie Commands
| Command | Purpose |
|---|---|
cy.getCookie('name') | Get a single cookie |
cy.getCookies() | Get all cookies |
cy.setCookie('name', 'value') | Set a cookie before test |
cy.clearCookie('name') | Remove one cookie |
cy.clearCookies() | Remove all cookies |
beforeEach(() => { // Get a real token via API, then set the cookie cy.request('POST', '/api/login', { email: 'user@test.com', password: 'pass' }).then((res) => { cy.setCookie('auth_token', res.body.token) }) cy.visit('/dashboard') // already authenticated })
localStorage & sessionStorage
// Set localStorage before visiting cy.window().then((win) => { win.localStorage.setItem('theme', 'dark') win.localStorage.setItem('lang', 'en') }) // Assert a value cy.window().its('localStorage') .invoke('getItem', 'theme') .should('eq', 'dark') // Clear all storage between tests cy.clearLocalStorage() cy.clearAllSessionStorage()
testIsolation: true (default) clears cookies, localStorage, and sessionStorage before every test automatically.Viewport Testing
// Preset sizes: 'iphone-6', 'ipad-2', 'samsung-s10', etc. cy.viewport('iphone-6') cy.visit('/') cy.get('.hamburger-menu').should('be.visible') cy.get('.desktop-nav').should('not.be.visible') // Custom width × height cy.viewport(1920, 1080) cy.get('.desktop-nav').should('be.visible') // Test multiple viewports in a loop ['iphone-6', 'ipad-2', [1280, 720]].forEach((size) => { it(`renders on ${size}`, () => { cy.viewport(...(Array.isArray(size) ? size : [size])) cy.visit('/') cy.get('main').should('be.visible') }) })
Screenshots
// Full page screenshot cy.screenshot('checkout-step-2') // Element-scoped screenshot cy.get('.invoice-table').screenshot('invoice') // Screenshot on every test failure (add to afterEach) afterEach(function () { if (this.currentTest.state === 'failed') { cy.screenshot(`FAILED-${this.currentTest.title}`) } })
cypress/screenshots/ and videos to cypress/videos/. Both are auto-uploaded in Cypress Cloud for remote debugging.Shadow DOM with cy.shadow()
// Access elements inside a shadow root cy.get('my-custom-button') .shadow() .find('button') .click() // Nested shadow roots cy.get('payment-widget') .shadow() .find('card-input') .shadow() .find('input') .type('4111111111111111') // Enable shadow DOM piercing globally (cypress.config.js) // includeShadowDom: true — then cy.get() pierces shadow roots automatically cy.get('my-element input').should('be.visible') // works with includeShadowDom: true
Dynamic Elements & Loading States
// Wait for skeleton/loader to disappear first cy.get('.skeleton-loader').should('not.exist') cy.get('.data-table').should('be.visible') // Lazy-loaded images — trigger scroll to load them cy.get('[data-cy="product-grid"]').scrollIntoView() cy.get('.product-img').should('have.attr', 'src').and('not.be.empty') // Infinite scroll — load more items cy.scrollTo('bottom') cy.get('.item-card').should('have.length.greaterThan', 10) // Retry on stale DOM — Cypress handles this automatically via .should() cy.get('[data-cy="user-count"]') .should('not.have.text', 'Loading...') .invoke('text') .then(parseFloat) .should('be.greaterThan', 0)
cypress.config.js is the cleanest approach for Web Component-heavy apps — it removes the need to chain .shadow() on every query.File Upload with cy.selectFile()
// Upload a file from cypress/fixtures/ cy.get('input[type="file"]') .selectFile('cypress/fixtures/resume.pdf') cy.get('.upload-status').should('contain', 'Uploaded successfully') // Drag-and-drop upload (drop zone) cy.get('.drop-zone') .selectFile('cypress/fixtures/report.csv', { action: 'drag-drop' }) // Upload multiple files cy.get('input[type="file"]') .selectFile(['cypress/fixtures/a.jpg', 'cypress/fixtures/b.jpg'])
File Download Verification
// Click the download button cy.get('[data-cy="export-btn"]').click() // File lands in cypress/downloads/ cy.readFile('cypress/downloads/report.csv') .should('contain', 'OrderID,Amount') // Verify JSON download content cy.readFile('cypress/downloads/users.json') .its('length') .should('be.greaterThan', 0)
cy.task() — Bridge to Node.js
const { defineConfig } = require('cypress') const db = require('./support/db') module.exports = defineConfig({ e2e: { setupNodeEvents(on, config) { on('task', { // Seed a test user directly in the DB async seedUser(user) { await db.users.insert(user) return null }, // Get latest OTP email from mailhog/inbox async getOTP(email) { const msg = await mailhog.getLatest(email) return msg.body.match(/\d{6}/)[0] } }) } } })
cy.task('seedUser', { name: 'Jane', email: 'jane@test.com' }) cy.task('getOTP', 'jane@test.com').then((otp) => { cy.get('#otp-input').type(otp) cy.get('[data-cy="verify-btn"]').click() })
Popular Cypress Plugins
| Plugin | Purpose |
|---|---|
@cypress/code-coverage | Instrument and collect JS code coverage |
cypress-iframe | Helpers for interacting with iframes |
cypress-axe | Accessibility (a11y) testing with axe-core |
cypress-real-events | Real native browser events (hover, drag) |
@shelex/cypress-allure-plugin | Allure report integration |
cypress-mochawesome-reporter | HTML test reports |
The Flaky Test Problem
Flaky tests are the bane of every QA team's existence. A flaky test passes sometimes and fails sometimes, without any code changes. It drives developers crazy because they can't reproduce the failure, making it almost impossible to fix. Common causes include hardcoded waits that aren't long enough on slow servers, race conditions in your test logic, or tests that depend on state from previous tests.
Cypress addresses flakiness at multiple levels. First, it has automatic retry logic built-in that eliminates many timing issues. Second, it offers test isolation that clears browser state between tests so tests don't interfere with each other. Third, it allows you to configure automatic retries at the global level (useful in CI) or per-test.
Retry Mechanisms in Cypress
Cypress provides two levels of retries: command-level retries (automatic retry of commands like cy.get() and assertions) and test-level retries (automatically re-running an entire failing test). Understanding the difference is crucial.
Configuring Retries
module.exports = defineConfig({ e2e: { retries: { runMode: 2, // retry 2× in CI (npx cypress run) openMode: 0 // no retries in interactive GUI } } })
it('submits the payment form', { retries: { runMode: 3 } }, () => { // This test will retry up to 3 times in CI cy.get('[data-cy="pay-btn"]').click() cy.get('.success-toast').should('be.visible') })
Common Causes of Flakiness & Fixes
| Cause | Bad Pattern | Fix |
|---|---|---|
| Hardcoded waits | cy.wait(3000) | Use cy.wait('@alias') or assertion retry |
| Race conditions | Checking element before XHR finishes | Intercept + wait for the network call |
| Shared state | Tests rely on previous test's data | Enable testIsolation: true, seed data per-test |
| Animations | Clicking a moving element | .should('not.have.class', 'animating') first |
| Unstable selectors | cy.get('.btn:nth-child(3)') | Use [data-cy="submit-btn"] |
testIsolation: true is on by default. Cypress clears all browser state between every it() — cookies, localStorage, and sessionStorage — keeping tests independent.E2E Testing vs Component Testing
| Aspect | E2E Testing | Component Testing |
|---|---|---|
| Scope | Full app through a browser | Single component, isolated |
| Speed | Slower (full page load) | Very fast (no routing) |
| Setup | cy.visit(url) | cy.mount(<Component />) |
| Best for | User flows, integrations | UI logic, props, events |
Setup for React
# Run Cypress Launchpad and select Component Testing npx cypress open # Or configure manually npm install @cypress/react vite --save-dev
module.exports = defineConfig({ component: { devServer: { framework: 'react', bundler: 'vite' }, specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}' } })
import Button from './Button' describe('Button component', () => { it('renders label and fires onClick', () => { const onClick = cy.stub().as('clickHandler') cy.mount(<Button label="Save" onClick={onClick} />) cy.get('button').should('have.text', 'Save') cy.get('button').click() cy.get('@clickHandler').should('have.been.calledOnce') }) it('shows disabled state', () => { cy.mount(<Button label="Submit" disabled />) cy.get('button').should('be.disabled') }) })
Setup cypress-axe
npm install cypress-axe axe-core --save-dev
import 'cypress-axe'
Running Accessibility Checks
it('has no accessibility violations on login page', () => { cy.visit('/login') cy.injectAxe() cy.checkA11y() // fails if any WCAG violations found }) // Check only a specific part of the page cy.injectAxe() cy.checkA11y('#main-form') // Configure which rules to apply cy.checkA11y(null, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } }) // Log violations without failing the test cy.checkA11y(null, null, (violations) => { violations.forEach((v) => cy.task('log', `A11Y: ${v.id} — ${v.description}`)) }, true)
Common WCAG Violations Cypress-axe Catches
| Rule | What it checks |
|---|---|
color-contrast | Text/background contrast ratio meets WCAG AA |
image-alt | All <img> have descriptive alt text |
label | All form inputs have associated <label> |
button-name | Buttons have accessible names |
link-name | Anchor tags have descriptive text |
aria-required-attr | ARIA roles have required attributes |
Mochawesome HTML Reports
npm install cypress-mochawesome-reporter --save-dev
module.exports = defineConfig({ reporter: 'cypress-mochawesome-reporter', reporterOptions: { charts: true, reportPageTitle: 'My Test Report', embeddedScreenshots: true, inlineAssets: true }, e2e: { setupNodeEvents(on, config) { require('cypress-mochawesome-reporter/plugin')(on) } } })
import 'cypress-mochawesome-reporter/register'
Allure Reports
npm install @shelex/cypress-allure-plugin allure-commandline --save-dev
// cypress/support/e2e.js import '@shelex/cypress-allure-plugin' // Run tests and generate report: // npx cypress run --env allure=true // npx allure generate allure-results --clean -o allure-report // npx allure open allure-report
CYPRESS_RECORD_KEY to CI.Continuous Testing: The Real Business Value
Running tests locally on your machine is great, but the real value comes from running tests automatically in CI/CD pipelines. Every time a developer pushes code or opens a pull request, tests run automatically. If tests fail, the pull request blocks and developers know immediately that something is broken. This catches bugs before they reach production.
Cypress is built with CI/CD in mind. It can run headlessly (without a visible browser window), record videos of failures, generate reports, and even run tests in parallel across multiple machines. This means you can have hundreds of tests that complete in just a few minutes instead of 30+ minutes.
Setting Up Cypress in GitHub Actions
GitHub Actions is a free CI/CD platform built into GitHub. If your project is already on GitHub, setting up Cypress testing is incredibly straightforward. The example below shows a basic configuration that runs your Cypress tests on every push and pull request.
name: Cypress E2E Tests on: [push, pull_request] jobs: cypress-run: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: cypress-io/github-action@v6 with: start: npm start wait-on: 'http://localhost:3000' record: true env: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
jobs:
cypress-run:
runs-on: ubuntu-latest
strategy:
matrix:
containers: [1, 2, 3]
steps:
- uses: actions/checkout@v3
- uses: cypress-io/github-action@v6
with:
record: true
parallel: true
group: 'E2E Tests'
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
npx cypress run runs headlessly by default in CI. Add --browser chrome to target a specific browser, or --spec "cypress/e2e/auth/**" to run only a subset of tests.Best Practices
data-cy instead of class/id selectors for stability.Common Pitfalls & Solutions
Tests fail in CI but pass locally
Root cause: Missing cy.wait() for API calls or timing issues. Solution: Use cy.intercept() to stub API responses and ensure predictable test behavior across environments.
Element not clickable error
Root cause: Element hidden behind modal, covered by overlay, or not yet visible. Solution: Use cy.get().should('be.visible') before clicking, or cy.get().click({ force: true }) as last resort.
Flaky tests - random failures
Root cause: Race conditions, timing issues, or inadequate waits. Solution: Use cy.intercept() with wait(), avoid hard-coded waits, enable retry mechanism in cypress.config.js.
Tests take too long to run
Root cause: Visiting full page load repeatedly. Solution: Use cy.visit() with onBeforeLoad to skip unnecessary assets, use cy.session() to skip login flow, or run tests in parallel.
Frequently Asked Questions
Can I use Cypress to test non-web applications?
No, Cypress only automates web browsers. For mobile apps, use Appium or Detox. For desktop apps, use UFT or TestComplete.
How do I handle file uploads in Cypress?
Use cy.selectFile() to select a file from your machine, or cy.get('input[type="file"]').selectFile('path/to/file') to upload.
Is Cypress suitable for testing APIs?
Yes! Use cy.request() to make API calls, cy.intercept() to stub responses, and assert on status codes and response body.
Can I run Cypress tests in parallel?
Yes, use the Cypress Dashboard with `npx cypress run --record --parallel` to run tests across multiple machines simultaneously.
How do I test Shadow DOM elements in Cypress?
Use cy.get() with .shadow() method (Cypress 13+), or pierce the shadow DOM with CSS combinators like `cy.get('host >>> element')`.
What's the difference between cy.get() and cy.contains()?
cy.get() selects by selector/attribute. cy.contains() finds elements by text content, useful when selectors are unstable.
Advanced Tips
Cypress.Commands.add('login', (email, password) => { ... }).cy.debug() to pause execution, cy.pause() for manual stepping, or --headed --no-exit flags to keep browser open after tests.