Overview
Per cy-overview (opens in new window):
"Cypress is described as 'a next generation front end testing tool built for the modern web.'"
Differentiators (cy-overview (opens in new window)):
When to use
For cross-browser including WebKit, see playwright-testing.
How to use
Step 1 - Install
npm install --save-dev cypress
npx cypress open # first run scaffolds the projectThe interactive setup creates cypress.config.ts + cypress/ directory.
Per the changelog (opens in new window), Cypress 16.0.0 (Sep 1, 2026) "requires Node.js 22.x, 24.x, or >=26.x". Upgrading a 15.x suite? See "Upgrading to Cypress 16" below before bumping the version.
Step 2 - Configure
// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.{ts,tsx}',
video: true,
screenshotOnRunFailure: true,
retries: { runMode: 2, openMode: 0 },
},
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
},
});Step 3 - Author E2E tests
// cypress/e2e/checkout.cy.ts
describe('Checkout flow', () => {
beforeEach(() => {
cy.visit('/login');
cy.get('[data-testid="email"]').type('user@example.com');
cy.env(['USER_PASSWORD']).then(({ USER_PASSWORD }) => {
cy.get('[data-testid="password"]').type(USER_PASSWORD, { log: false });
});
cy.get('[data-testid="signin-btn"]').click();
cy.contains('Welcome').should('be.visible');
});
it('completes checkout end-to-end', () => {
cy.visit('/products/BOOK-001');
cy.contains('button', /add to cart/i).click();
cy.get('[data-testid="cart-count"]').should('have.text', '1');
cy.visit('/checkout');
cy.env(['TEST_CARD_NUMBER']).then(({ TEST_CARD_NUMBER }) => {
cy.get('[name="card"]').type(TEST_CARD_NUMBER, { log: false });
});
cy.contains('button', /place order/i).click();
cy.contains('Order confirmed', { timeout: 10000 }).should('be.visible');
});
});Per cy-overview (opens in new window), assertions auto-wait - no cy.wait(2000) needed.
Never type a password or card number as a literal. Per cy-env (opens in new window), cy.env() (Cypress 15.10+) yields the requested keys, and per cy-env-guide (opens in new window) Cypress strips the CYPRESS_ prefix, so CI sets CYPRESS_USER_PASSWORD from its secret store. { log: false } keeps the value out of the Command Log.
Step 4 - Use cypress-testing-library
For accessibility-first selectors:
npm install --save-dev @testing-library/cypress// cypress/support/commands.ts
import '@testing-library/cypress/add-commands';
// Now in tests:
cy.findByRole('button', { name: /sign in/i }).click();
cy.findByLabelText('Email').type('user@example.com');findByRole (from cypress-testing-library) is the Cypress equivalent of Playwright's getByRole - preferred for accessibility-aware testing.
Step 5 - Custom commands
// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
}
}
}
Cypress.Commands.add('login', (email, password) => {
cy.session([email, password], () => {
cy.visit('/login');
cy.findByLabelText('Email').type(email);
cy.findByLabelText('Password').type(password, { log: false });
cy.findByRole('button', { name: /sign in/i }).click();
cy.url().should('not.include', '/login');
});
});
// Usage in tests:
beforeEach(() => {
cy.env(['USER_PASSWORD']).then(({ USER_PASSWORD }) => {
cy.login('user@example.com', USER_PASSWORD);
});
});cy.session(...) caches the auth state across tests - reuse the login result, avoid re-running the login flow.
Step 6 - Stub and wait on the network with cy.intercept
Per cy-intercept (opens in new window), cy.intercept will "Spy and stub network requests and responses." Aliasing a route and waiting on it replaces every fixed cy.wait(ms):
// Spy: let the real request through, but wait for it and assert on it
cy.intercept('POST', '/api/orders').as('createOrder');
cy.contains('button', /place order/i).click();
cy.wait('@createOrder').its('response.statusCode').should('eq', 201);
// Stub: answer from a fixture file (cypress/fixtures/products.json)
cy.intercept('GET', '/api/products*', { fixture: 'products.json' }).as('products');
// Stub an error to test the failure UI
cy.intercept('GET', '/api/cart', { statusCode: 500, body: {} }).as('cartDown');
cy.visit('/cart');
cy.wait('@cartDown');
cy.contains('Something went wrong').should('be.visible');Per cy-intercept (opens in new window), cy.wait('@alias') yields the request and response, so .its('request.body') and .its('response.statusCode') assert on the exact call the UI made. Register the intercept before the action that fires the request, or the request is missed.
Step 7 - Fixtures, Node tasks and a frozen clock
Fixtures. Per cy-fixture (opens in new window), cy.fixture loads "a fixed set of data located in a file" from cypress/fixtures by default. Pass it straight to an intercept with { fixture: 'users' }, or read it for form input:
cy.fixture('users').then((users) => {
cy.findByLabelText('Email').type(users[0].email);
});Node tasks. Per cy-task (opens in new window), "cy.task() runs code in Node.js from your test" - seeding a database, reading files, calling a CLI. Handlers live in setupNodeEvents, and per the same page a handler must return a value or null; returning undefined fails the command:
// cypress.config.ts
import { defineConfig } from 'cypress';
import { seedDefaults } from './scripts/db';
export default defineConfig({
e2e: {
setupNodeEvents(on) {
on('task', {
async 'db:seed'() {
await seedDefaults();
return null;
},
});
},
},
});
// In a spec
beforeEach(() => {
cy.task('db:seed');
});Clock. Per cy-clock (opens in new window), cy.clock "overrides native global functions related to time allowing them to be controlled synchronously via cy.tick()". Freeze the date before cy.visit, then move time:
cy.clock(new Date(2026, 0, 31)); // month is 0-indexed
cy.visit('/billing');
cy.contains('Renews on February 1, 2026').should('be.visible');
cy.tick(30 * 60 * 1000); // fire the 30-minute idle timer
cy.contains('Session expired').should('be.visible');Step 8 - Log in through another origin with cy.origin
Per cy-origin (opens in new window), cy.origin "runs Cypress commands on a page at a different origin than the one your test is running in" - an SSO page or an auth subdomain. Values cross into the callback only through the args option, and per the same page cy.session() wraps the whole sign-in flow; called inside the cy.origin callback it throws:
Cypress.Commands.add('loginSso', (username: string, password: string) => {
cy.session(['sso', username], () => {
cy.visit('/');
cy.contains('a', 'Log in').click();
cy.origin(
'https://auth.example.com',
{ args: { username, password } },
({ username, password }) => {
cy.get('#username').type(username);
cy.get('#password').type(password, { log: false });
cy.contains('button', 'Sign in').click();
},
);
cy.url().should('include', '/dashboard');
});
});Per the changelog (opens in new window) (16.1.1), subdomains count as different origins by default, so app.example.com to auth.example.com also needs cy.origin.
Step 9 - Run
# Open the GUI (interactive; great for development)
npx cypress open
# Headless (CI)
npx cypress run
# Single spec
npx cypress run --spec "cypress/e2e/checkout.cy.ts"
# Specific browser
npx cypress run --browser firefox
npx cypress run --browser chromeStep 10 - Time-travel debugger
Per cy-overview (opens in new window): "Hover over commands in the Command Log to see exactly what happened at each step."
In cypress open mode:
This is Cypress's killer feature - debugging by visually replaying the test.
Step 11 - Cypress Cloud + CI
Recording and parallel runs go through Cypress Cloud (paid; OSS alternative currents-integration in the qa-test-reporting plugin), and the GitHub Actions job wires cypress-io/github-action + screenshot artifacts on failure. Commands and the full workflow YAML: references/ci-and-cloud.md.
Upgrading to Cypress 16
Breaking changes in 16.0.0 that hit most suites, per the changelog (opens in new window):
| Change in 16.0.0 | What to do |
|---|---|
| Node.js 20 and 25 support removed | Run on Node.js 22, 24 or 26+ locally and in CI. |
Cypress.env() removed | Sensitive values: cy.env() (Step 3). Non-sensitive values read in the browser: Cypress.expose(). The env key in per-test config overrides becomes expose. |
cy.exec() and execTimeout removed | Move shell calls into a cy.task() handler (Step 7) and use taskTimeout. |
| Chrome, Chromium and Edge intercept on the native browser network | cy.intercept no longer reports req.httpVersion, and intercepted responses have no compression headers; drop assertions on either. The deprecated forceHttp1 option keeps the old path while migrating. |
cy.type() default keystrokeDelay changed from 10 to 0 | Tests that relied on slow typing to let a debounce fire should assert on the result instead. |
viewportWidth, viewportHeight, blockHosts can no longer be set via Cypress.config() mid-test | Set them in the config file or with cy.viewport(). |
| Component testing minimums: Angular 21, Vite 8; Next.js 14 dropped | Upgrade the framework before Cypress. |
Worked example
A team has a hand-written checkout.cy.ts that logs in from scratch in every test and sleeps cy.wait(3000) before asserting the cart count. It flakes about 1 run in 5 on CI.
Result: the flow runs faster (login cached, no fixed sleep) and the flake disappears because every wait is now assertion-driven.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
cy.wait(2000) between actions | Defeats Cypress's auto-wait; flaky. | Trust assertions; chain commands. |
| Waiting on a fixed time for an API call | Too short on slow CI, wasted time on fast runs. | cy.intercept(...).as('x') then cy.wait('@x') (Step 6). |
Registering cy.intercept after the click that sends the request | The request fires before the route exists and is never matched. | Intercept first, then act (Step 6). |
| Seeding data through the UI in every test | Slow, and a UI bug breaks unrelated tests. | cy.task('db:seed') (Step 7). |
cy.get('.button-class') (CSS class) | Brittle; defeats findByRole patterns. | cypress-testing-library + data-testid (Steps 3-4). |
| Cross-test state via global variables | Tests order-dependent. | cy.session() for auth; per-test fresh state. |
| Mixing Cypress + plain xUnit assertions | Confusing; two assertion styles. | Cypress chains throughout. |
| Running Cypress against production | Cypress can mutate state; pollutes prod data. | Local / staging only. |
Limitations
References
Operational detail split out of cypress-testing. The core authoring loop (config, specs, custom commands, the time-travel debugger) stays in SKILL.md; this file holds the recording / parallelization and the CI wiring.
Cypress Cloud (paid; optional)
# Record run to Cypress Cloud
npx cypress run --record --key <CYPRESS_RECORD_KEY>
# Parallel
npx cypress run --record --parallelCloud provides:
OSS alternative: currents-integration (in the qa-test-reporting plugin) covers similar analytics for both Cypress + Playwright.
CI integration (GitHub Actions)
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# node 24 for install/start commands; the action itself runs node24
- uses: actions/setup-node@v7
with:
node-version: 24
- uses: cypress-io/github-action@v7
with:
start: npm start
wait-on: 'http://localhost:3000'
browser: chrome
record: true
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
- uses: actions/upload-artifact@v7
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshotsPer the github-action README (opens in new window), v7 "is the action's recommended major version", record: true records to Cypress Cloud, and "The record key is passed to the action as an environment variable CYPRESS_RECORD_KEY". The README also points to actions/setup-node for pinning the Node.js version that the start and install commands use; per the Cypress changelog (opens in new window), Cypress 16 needs Node.js 22, 24 or 26+.
- Cypress runs green locally, opaque in CI
- Import test lies, export test will not even load
- Inbox checks fail on the first look and never look again
- Orders spec only passes in file order
- Five of the six price panel states are untestable from the app
- Every computed expectation comes out undefined or NaN
- Cart suite is slow and flakes
- Search suite stayed green through a breaking API change
- Checkout spec goes blind the moment we leave our own domain
- Trial banner tests need two weeks of real time