Cypress is a powerful end-to-end testing framework built for the modern web. Unlike traditional testing tools that run outside the browser, Cypress executes directly inside the browser alongside your application, giving it native access to the DOM, network requests, and every JavaScript object. This architecture eliminates the flakiness caused by network latency between a test runner and a browser driver. This guide covers everything you need to write reliable, maintainable Cypress tests from initial setup through CI deployment.
Cypress runs inside the browser for fast, flake-free E2E tests. Install with npm install cypress, write tests using cy.visit/cy.get/cy.contains, mock API responses with cy.intercept, use fixtures for test data, add custom commands for reusable logic, leverage component testing for isolated UI validation, and integrate into CI with cypress run. Cypress provides automatic waiting, time-travel debugging, real-time reload, and built-in screenshot/video capture on failure.
- Cypress runs directly in the browser, eliminating the WebDriver layer and providing native DOM access with automatic waiting.
- Use data-cy attributes for selectors instead of CSS classes or IDs to create tests that are resilient to UI changes.
- cy.intercept() lets you stub, spy on, and modify network requests to test edge cases without a running backend.
- Fixtures (cypress/fixtures/) externalize test data into JSON files, keeping tests clean and data reusable across specs.
- Custom commands (Cypress.Commands.add) encapsulate common workflows like login, reducing duplication across test files.
- Cypress Component Testing validates UI components in isolation with real browser rendering, complementing E2E tests.
Why Cypress for E2E Testing?
Cypress was designed from the ground up for end-to-end testing of web applications. It operates within the same run loop as your application, which means it has synchronous access to everything happening in the browser. There is no network hop between the test runner and the browser, no WebDriver protocol overhead, and no external process orchestrating commands. The test code and the application code share the same JavaScript execution context.
This architecture provides several practical advantages: automatic waiting for DOM elements, network requests, and animations without explicit waits or sleeps; time-travel debugging where you can hover over each command to see the application state at that exact moment; real-time reloading that re-runs tests instantly when you save a file; and consistent, deterministic test execution that eliminates the flaky tests plaguing Selenium-based setups.
Cypress vs Playwright vs Selenium
Understanding how Cypress compares to other popular testing frameworks helps you make an informed choice for your project.
| Feature | Cypress | Playwright | Selenium |
|---|---|---|---|
| Architecture | In-browser | CDP/WebSocket | WebDriver protocol |
| Browsers | Chrome, Firefox, Edge, WebKit | Chromium, Firefox, WebKit | All major browsers |
| Language | JavaScript/TypeScript | JS/TS, Python, Java, C# | Many languages |
| Auto-waiting | Built-in (all commands) | Built-in (all actions) | Manual waits required |
| Network mocking | cy.intercept (built-in) | page.route (built-in) | External tools needed |
| Time-travel debug | Yes (GUI snapshots) | Trace viewer | No |
| Multi-tab support | Limited (workarounds) | Full support | Full support |
| Component testing | Built-in | Experimental | No |
| Parallel execution | Cypress Cloud (paid) | Built-in (free) | Selenium Grid |
Cypress excels at developer experience, debugging, and component testing. Playwright wins on multi-browser support, multi-tab scenarios, and free parallelism. Choose Cypress when developer experience and debugging speed are priorities; choose Playwright when you need cross-browser coverage or multi-tab workflows.
Installation and Project Setup
Cypress is installed as a dev dependency and ships with its own bundled Electron browser for instant setup. It can also run tests against Chrome, Firefox, Edge, and WebKit (experimental).
# Install Cypress
npm install --save-dev cypress
# Open Cypress interactive runner
npx cypress open
# Run tests headlessly (CI mode)
npx cypress run
# Run with specific browser
npx cypress run --browser chrome
npx cypress run --browser firefox
# Run a single spec file
npx cypress run --spec "cypress/e2e/login.cy.ts"
# Project structure after first open:
# cypress/
# e2e/ β test spec files
# fixtures/ β test data (JSON)
# support/
# commands.ts β custom commands
# e2e.ts β global hooks/setup
# cypress.config.ts β configurationConfiguration
The cypress.config.ts file (or .js) is the central configuration point. It controls timeouts, viewport size, base URL, environment variables, and plugin setup.
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
specPattern: "cypress/e2e/**/*.cy.{ts,js}",
supportFile: "cypress/support/e2e.ts",
// Timeouts
defaultCommandTimeout: 10000,
requestTimeout: 15000,
responseTimeout: 30000,
pageLoadTimeout: 60000,
// Viewport
viewportWidth: 1280,
viewportHeight: 720,
// Retry configuration
retries: {
runMode: 2, // CI retries
openMode: 0, // No retries in interactive mode
},
// Screenshots and videos
screenshotOnRunFailure: true,
video: false, // Disable in CI for speed
screenshotsFolder: "cypress/screenshots",
videosFolder: "cypress/videos",
// Environment variables
env: {
apiUrl: "http://localhost:4000/api",
coverage: false,
},
setupNodeEvents(on, config) {
// Register plugins here
// on("task", { ... })
return config;
},
},
component: {
devServer: {
framework: "react",
bundler: "vite", // or "webpack"
},
specPattern: "src/**/*.cy.{ts,tsx}",
},
});Writing Your First Test
Cypress test files live in the cypress/e2e directory and use the .cy.ts or .cy.js extension. Tests follow the familiar Mocha-style describe/it structure. Every command in Cypress is asynchronous but uses a chainable API that reads synchronously.
// cypress/e2e/homepage.cy.ts
describe("Homepage", () => {
beforeEach(() => {
cy.visit("/");
});
it("displays the hero section", () => {
cy.get("[data-cy=hero-title]")
.should("be.visible")
.and("contain", "Welcome");
cy.get("[data-cy=hero-subtitle]")
.should("exist");
});
it("navigates to the about page", () => {
cy.get("[data-cy=nav-about]").click();
cy.url().should("include", "/about");
cy.get("h1").should("contain", "About Us");
});
it("submits the contact form", () => {
cy.get("[data-cy=contact-name]").type("Jane Doe");
cy.get("[data-cy=contact-email]").type("jane@example.com");
cy.get("[data-cy=contact-message]").type("Hello!");
cy.get("[data-cy=contact-submit]").click();
cy.get("[data-cy=success-message]")
.should("be.visible")
.and("contain", "Thank you");
});
it("loads products from the API", () => {
cy.get("[data-cy=product-card]")
.should("have.length.greaterThan", 0);
cy.get("[data-cy=product-card]")
.first()
.find("[data-cy=product-name]")
.should("not.be.empty");
});
});Login Test Example
// cypress/e2e/auth/login.cy.ts
describe("Authentication", () => {
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-button]").click();
// Should redirect to dashboard
cy.url().should("include", "/dashboard");
cy.get("[data-cy=welcome-message]")
.should("contain", "Welcome back");
});
it("shows error for invalid credentials", () => {
cy.get("[data-cy=email-input]").type("user@example.com");
cy.get("[data-cy=password-input]").type("wrongPassword");
cy.get("[data-cy=login-button]").click();
cy.get("[data-cy=error-message]")
.should("be.visible")
.and("contain", "Invalid credentials");
cy.url().should("include", "/login");
});
it("validates required fields", () => {
cy.get("[data-cy=login-button]").click();
cy.get("[data-cy=email-error]")
.should("contain", "Email is required");
cy.get("[data-cy=password-error]")
.should("contain", "Password is required");
});
});Selectors and Best Practices
Reliable selectors are the foundation of maintainable tests. CSS classes and IDs change frequently during refactoring, breaking tests unnecessarily. Cypress recommends using dedicated data attributes.
The selector priority from most to least resilient:
- data-cy, data-testid, or data-test attributes (best: purpose-built for testing)
- Accessible roles and labels via cy.contains() or cy.findByRole()
- HTML element tags with specific attributes
- CSS class names or IDs (least resilient: change with design updates)
// BAD: fragile selectors
cy.get(".btn-primary"); // CSS class changes
cy.get("#submit"); // ID may be generated
cy.get("div > form > button"); // DOM structure changes
cy.get(":nth-child(3)"); // Position changes
// GOOD: resilient selectors
cy.get("[data-cy=submit-button]"); // Purpose-built
cy.get("[data-testid=user-name]"); // Testing Library convention
cy.contains("Submit Order"); // Text content
cy.findByRole("button", { name: /submit/i }); // Accessible role
// Add data-cy to your components:
// <button data-cy="submit-button">Submit</button>
// <input data-cy="search-input" type="text" />
// <div data-cy="product-card">...</div>Essential Cypress Commands
Cypress provides a rich set of built-in commands for interacting with your application. Every command automatically retries until the assertion passes or the timeout expires.
// --- Navigation ---
cy.visit("/products"); // Navigate to URL
cy.visit("/products?page=2"); // With query params
cy.go("back"); // Browser back
cy.go("forward"); // Browser forward
cy.reload(); // Reload page
// --- Querying ---
cy.get("[data-cy=item]"); // CSS selector
cy.contains("Add to Cart"); // Text content
cy.get("ul").find("li"); // Scoped query
cy.get("li").first(); // First element
cy.get("li").last(); // Last element
cy.get("li").eq(2); // Nth element (0-indexed)
cy.get("li").filter(".active"); // Filter by selector
// --- Actions ---
cy.get("input").type("hello"); // Type text
cy.get("input").clear(); // Clear input
cy.get("input").type("hello{enter}"); // Type + press Enter
cy.get("button").click(); // Click
cy.get("button").dblclick(); // Double click
cy.get("button").rightclick(); // Right click
cy.get("select").select("Option 1"); // Select dropdown
cy.get("input[type=checkbox]").check(); // Check checkbox
cy.get("input[type=checkbox]").uncheck(); // Uncheck
cy.get("input[type=file]")
.selectFile("cypress/fixtures/doc.pdf"); // File upload
cy.get("element").scrollIntoView(); // Scroll to element
// --- Assertions ---
cy.get("h1").should("exist"); // Exists in DOM
cy.get("h1").should("be.visible"); // Visible on screen
cy.get("h1").should("contain", "Welcome"); // Contains text
cy.get("h1").should("have.text", "Welcome Home"); // Exact text
cy.get("input").should("have.value", "hello"); // Input value
cy.get("li").should("have.length", 5); // Count elements
cy.get("button").should("be.disabled"); // Disabled state
cy.get("div").should("have.css", "color", "rgb(255, 0, 0)");
cy.url().should("include", "/dashboard"); // URL check
cy.title().should("eq", "My App"); // Page titleCustom Commands
Custom commands let you encapsulate repeatable workflows into reusable functions. They are defined in cypress/support/commands.ts and become available as cy.yourCommand() in every test file.
// cypress/support/commands.ts
// Login command β reusable across all test files
Cypress.Commands.add("login", (email: string, password: string) => {
cy.session([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-button]").click();
cy.url().should("include", "/dashboard");
});
});
// API login β faster, bypasses UI
Cypress.Commands.add("loginByApi", (email: string, password: string) => {
cy.request("POST", "/api/auth/login", { email, password })
.then((response) => {
window.localStorage.setItem("token", response.body.token);
});
});
// Get by data-cy shortcut
Cypress.Commands.add("getByCy", (selector: string) => {
return cy.get('[data-cy="' + selector + '"]');
});
// TypeScript declarations
// cypress/support/index.d.ts
declare namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
loginByApi(email: string, password: string): Chainable<void>;
getByCy(selector: string): Chainable<JQuery<HTMLElement>>;
}
}
// Usage in tests:
// cy.login("admin@example.com", "password123");
// cy.getByCy("submit-button").click();Fixtures and Test Data
Fixtures are external data files (usually JSON) stored in the cypress/fixtures directory. They keep test data separate from test logic, making tests cleaner and data reusable across multiple spec files.
// cypress/fixtures/users.json
{
"admin": {
"email": "admin@example.com",
"password": "adminPass123",
"role": "admin"
},
"regular": {
"email": "user@example.com",
"password": "userPass456",
"role": "user"
}
}
// cypress/fixtures/products.json
[
{ "id": 1, "name": "Widget A", "price": 29.99, "stock": 150 },
{ "id": 2, "name": "Widget B", "price": 49.99, "stock": 75 },
{ "id": 3, "name": "Widget C", "price": 99.99, "stock": 0 }
]
// Using fixtures in tests
describe("Product Listing", () => {
beforeEach(() => {
// Load fixture and use it to stub the API
cy.fixture("products").then((products) => {
cy.intercept("GET", "/api/products", {
statusCode: 200,
body: products,
}).as("getProducts");
});
cy.visit("/products");
cy.wait("@getProducts");
});
it("displays all products", () => {
cy.get("[data-cy=product-card]").should("have.length", 3);
});
it("shows out-of-stock badge", () => {
cy.get("[data-cy=product-card]")
.last()
.find("[data-cy=out-of-stock-badge]")
.should("be.visible");
});
// Shorthand: cy.fixture as intercept body
it("can use fixture shorthand", () => {
cy.intercept("GET", "/api/products", {
fixture: "products.json",
}).as("getProducts");
});
});Network Interception with cy.intercept()
cy.intercept() is one of Cypress's most powerful features. It lets you stub API responses, spy on network traffic, and simulate error conditions without needing a running backend server. This makes tests faster, more reliable, and capable of testing edge cases that are difficult to reproduce with a real API.
// --- Stubbing API responses ---
cy.intercept("GET", "/api/users", {
statusCode: 200,
body: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }],
}).as("getUsers");
cy.visit("/users");
cy.wait("@getUsers"); // Wait for the request
cy.get("[data-cy=user-row]").should("have.length", 2);
// --- Simulating errors ---
cy.intercept("POST", "/api/orders", {
statusCode: 500,
body: { error: "Internal server error" },
}).as("createOrder");
cy.get("[data-cy=place-order]").click();
cy.wait("@createOrder");
cy.get("[data-cy=error-toast]")
.should("contain", "Something went wrong");
// --- Simulating network delay ---
cy.intercept("GET", "/api/dashboard", (req) => {
req.reply({
delay: 3000, // 3 second delay
statusCode: 200,
body: { stats: { users: 100 } },
});
}).as("getDashboard");
cy.visit("/dashboard");
cy.get("[data-cy=loading-spinner]").should("be.visible");
cy.wait("@getDashboard");
cy.get("[data-cy=loading-spinner]").should("not.exist");
// --- Spying (observe without modifying) ---
cy.intercept("POST", "/api/analytics").as("analytics");
cy.get("[data-cy=buy-button]").click();
cy.wait("@analytics").then((interception) => {
expect(interception.request.body).to.have.property("event", "purchase");
expect(interception.response.statusCode).to.equal(200);
});
// --- Modifying requests ---
cy.intercept("GET", "/api/products*", (req) => {
// Add auth header to all product requests
req.headers["Authorization"] = "Bearer test-token";
req.continue();
});
// --- Conditional responses ---
let callCount = 0;
cy.intercept("GET", "/api/status", (req) => {
callCount += 1;
if (callCount === 1) {
req.reply({ status: "pending" });
} else {
req.reply({ status: "complete" });
}
}).as("checkStatus");Component Testing
Cypress Component Testing lets you mount and test individual UI components in isolation, using a real browser instead of a simulated DOM like jsdom. This catches rendering issues, CSS problems, and interaction bugs that unit tests miss. Component tests run much faster than E2E tests because they do not require a full application server.
// src/components/Button.cy.tsx
import Button from "./Button";
describe("<Button />", () => {
it("renders with default props", () => {
cy.mount(<Button>Click Me</Button>);
cy.get("button")
.should("contain", "Click Me")
.and("not.be.disabled");
});
it("fires onClick handler", () => {
const onClick = cy.stub().as("clickHandler");
cy.mount(<Button onClick={onClick}>Submit</Button>);
cy.get("button").click();
cy.get("@clickHandler").should("have.been.calledOnce");
});
it("renders disabled state", () => {
cy.mount(<Button disabled>Disabled</Button>);
cy.get("button")
.should("be.disabled")
.and("have.css", "opacity", "0.5");
});
it("renders different variants", () => {
cy.mount(<Button variant="danger">Delete</Button>);
cy.get("button")
.should("have.css", "background-color", "rgb(239, 68, 68)");
});
});
// src/components/SearchBar.cy.tsx
import SearchBar from "./SearchBar";
describe("<SearchBar />", () => {
it("calls onSearch after debounce", () => {
const onSearch = cy.stub().as("searchHandler");
cy.mount(<SearchBar onSearch={onSearch} debounceMs={300} />);
cy.get("[data-cy=search-input]").type("cypress testing");
cy.get("@searchHandler").should("not.have.been.called");
// Wait for debounce
cy.wait(350);
cy.get("@searchHandler")
.should("have.been.calledWith", "cypress testing");
});
it("clears input on escape key", () => {
cy.mount(<SearchBar onSearch={cy.stub()} />);
cy.get("[data-cy=search-input]")
.type("test{esc}")
.should("have.value", "");
});
});CI/CD Integration
Running Cypress in CI requires a headless browser and proper caching configuration. Cypress provides official Docker images and GitHub Actions for seamless CI integration.
GitHub Actions
# .github/workflows/cypress.yml
name: Cypress Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
cypress-run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Cypress run
uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm start
wait-on: "http://localhost:3000"
wait-on-timeout: 120
browser: chrome
record: false
- name: Upload screenshots on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
retention-days: 7Docker
# Dockerfile.cypress
FROM cypress/included:13.6.0
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Run tests
CMD ["npx", "cypress", "run", "--browser", "chrome"]
# docker-compose.yml
# services:
# app:
# build: .
# ports:
# - "3000:3000"
# cypress:
# image: cypress/included:13.6.0
# depends_on:
# - app
# environment:
# - CYPRESS_baseUrl=http://app:3000
# volumes:
# - ./cypress:/app/cypress
# - ./cypress.config.ts:/app/cypress.config.tspackage.json Scripts
{
"scripts": {
"cy:open": "cypress open",
"cy:run": "cypress run",
"cy:run:chrome": "cypress run --browser chrome",
"cy:run:firefox": "cypress run --browser firefox",
"cy:component": "cypress run --component",
"cy:headed": "cypress run --headed",
"test:e2e": "start-server-and-test start http://localhost:3000 cy:run",
"test:e2e:dev": "start-server-and-test dev http://localhost:3000 cy:open"
}
}Best Practices
Follow these practices to write tests that are reliable, fast, and maintainable as your application grows.
- Use data-cy attributes for all selectors. Never rely on CSS classes, element structure, or generated IDs that may change.
- Keep tests independent. Each test should set up its own state using cy.intercept(), cy.session(), or API calls. Never depend on the order of tests.
- Avoid cy.wait() with fixed milliseconds. Use cy.intercept() aliasing and cy.wait("@alias") to wait for specific network responses.
- Use beforeEach for common setup. Login flows, page navigation, and data seeding should happen in beforeEach, not repeated in each test.
- Write small, focused tests. Each it() block should test one specific behavior. Avoid multi-step mega-tests that are hard to debug.
- Use cy.session() for authentication. It caches session data across tests, eliminating the need to log in before every spec file.
- Organize fixtures by feature. Use directories like cypress/fixtures/users/, cypress/fixtures/products/ to keep test data manageable.
- Run tests in CI with video disabled unless debugging. Videos slow down CI runs and consume storage. Enable them only when investigating failures.
- Retry failed tests in CI. Use retries: { runMode: 2 } to handle transient CI environment issues without masking real bugs.
- Use TypeScript for tests. Type inference on cy commands, custom commands, and fixtures catches errors before tests run.
Anti-Patterns to Avoid
// ANTI-PATTERN: Fixed waits
cy.wait(5000); // Never do this
// CORRECT: Wait for specific condition
cy.intercept("GET", "/api/data").as("getData");
cy.wait("@getData");
// ANTI-PATTERN: Testing implementation details
cy.get("div.MuiButton-root.MuiButton-contained");
// CORRECT: Test user-facing behavior
cy.get("[data-cy=submit-button]");
// ANTI-PATTERN: Shared state between tests
let savedId;
it("creates item", () => { savedId = "..."; });
it("uses item", () => { cy.visit("/item/" + savedId); });
// CORRECT: Each test sets up its own state
it("edits an item", () => {
// Create via API, then test
cy.request("POST", "/api/items", { name: "Test" })
.then((res) => {
cy.visit("/item/" + res.body.id);
});
});
// ANTI-PATTERN: Giant multi-step test
it("does everything", () => {
// login, navigate, create, edit, delete...
// 100 lines of commands
});
// CORRECT: Focused tests with shared setup
describe("Item management", () => {
beforeEach(() => {
cy.login("admin@test.com", "password");
cy.visit("/items");
});
it("creates an item", () => { /* ... */ });
it("edits an item", () => { /* ... */ });
it("deletes an item", () => { /* ... */ });
});Frequently Asked Questions
How does Cypress differ from Selenium?
Cypress runs inside the browser in the same execution loop as your application, while Selenium sends commands to the browser over the WebDriver protocol from an external process. This means Cypress has native access to the DOM, network layer, and JavaScript context. It automatically waits for elements, does not require explicit waits or sleep statements, and provides time-travel debugging. Selenium supports more languages and browsers but requires more setup and is more prone to flaky tests.
Can Cypress test applications that require login?
Yes. Cypress provides cy.session() which caches authentication state across tests. You can log in once programmatically (via API calls or UI interaction), and Cypress will restore the session for subsequent tests. This is much faster than logging in through the UI before every test. You can also use cy.intercept() to mock authentication responses for specific test scenarios.
Does Cypress support multiple browsers?
Cypress supports Chrome, Chromium, Edge, Firefox, and WebKit (experimental). You can specify the browser with --browser chrome or --browser firefox when running tests. In CI, you can run tests in parallel across multiple browsers. Note that WebKit support is experimental and may not cover all test scenarios. Electron (a Chromium variant) is the default browser and requires no additional setup.
How do I handle flaky tests in Cypress?
First, identify the root cause. Common causes are: race conditions with network requests (fix with cy.intercept and aliases), animation interference (set waitForAnimations: true in config), and shared state between tests (fix by making each test independent). Use the retries configuration for CI environments. Enable video recording and screenshots on failure to diagnose issues. The Cypress Dashboard (Cloud) provides analytics to identify consistently flaky tests.
Can Cypress test API endpoints directly?
Yes. cy.request() makes HTTP requests directly without going through the browser UI. This is useful for API testing, setting up test data, and authenticating before UI tests. Unlike cy.visit(), cy.request() does not render a page. It returns the response body, headers, and status code for assertions. You can chain multiple cy.request() calls to test complete API workflows.
How do I test file uploads with Cypress?
Use the cypress-file-upload plugin or the native selectFile command (Cypress 9.3+). The selectFile command works with standard file input elements: cy.get("input[type=file]").selectFile("path/to/file.pdf"). For drag-and-drop uploads, use .selectFile("file.pdf", { action: "drag-drop" }). You can also create files programmatically using cy.fixture() to load test files from the fixtures directory.
What is the difference between cy.intercept() and cy.request()?
cy.intercept() intercepts network requests that the browser makes naturally during page interaction. It can stub responses, delay requests, or spy on traffic. cy.request() makes HTTP requests directly from the test code, bypassing the browser entirely. Use cy.intercept() when testing how your UI handles different API responses. Use cy.request() for setting up test data, authenticating, or testing APIs without a UI.
How do I integrate Cypress with TypeScript?
Cypress has built-in TypeScript support since version 10. Create a tsconfig.json in your cypress directory with the appropriate compiler options. Install @types/cypress if needed. For custom commands, extend the Cypress namespace in a global.d.ts file to get type inference. All spec files can use .cy.ts extension. The cypress.config.ts file itself can be written in TypeScript without additional configuration.