Cypress Essentials
Cypress Essentials What Cypress Is and When to Use It Cypress is an end-to-end testing framework that runs directly inside the browser alongside the application…
Cypress Essentials
What Cypress Is and When to Use It
Cypress is an end-to-end testing framework that runs directly inside the browser alongside the application under test, rather than driving the browser remotely over a wire protocol the way Selenium or plain WebDriver-based tools do. That in-browser execution model is what gives Cypress its two signature traits: automatic waiting/retrying on assertions, and direct access to intercept and control network traffic without a separate proxy.
Reach for Cypress for browser-based E2E and integration tests — user flows through a real UI, API integration checks driven from the UI, and component testing for isolated React/Vue/Angular components. It is not a fit for testing native mobile apps (use Appium) or for pure backend/unit testing where spinning up a browser adds nothing.
A Cypress test file is called a "spec". Specs run inside the Cypress Test Runner (interactive, for local development) or headlessly via cypress run (for CI). Everything a test does — visiting a page, finding elements, asserting — goes through the global cy object.
Commands, Chaining, and Automatic Retries
Cypress commands are asynchronous but written to read synchronously — cy.get(...) does not return a value directly; it enqueues a command and yields a "chainable" that the next command in the chain receives as its subject. This queuing is why you cannot mix Cypress commands with plain async/await assignment (const el = cy.get(...) does not give you the element) — instead use .then() to step into the resolved value when you truly need it.
The other core behavior is automatic retrying. Any command that queries the DOM (cy.get, cy.find, .should, .contains, etc.) retries against a fresh read of the DOM until it either succeeds or the default command timeout (4 seconds) elapses. This is what lets Cypress tests assert against data that hasn't rendered yet without a manual wait — the retry loop absorbs the render delay, which is also why sprinkling cy.wait(1000) throughout a suite is almost always unnecessary and just slows every run down by that fixed amount.
// cypress/e2e/checkout.cy.js
describe('Checkout flow', () => {
beforeEach(() => {
cy.visit('/cart')
})
it('completes a purchase with a valid card', () => {
// Commands are chained — each yields a subject the next command acts on
cy.get('[data-cy=cart-item]').should('have.length', 2)
cy.get('[data-cy=checkout-button]').click()
cy.get('input[name=email]').type('shopper@example.com')
cy.get('input[name=cardNumber]').type('4242424242424242')
cy.get('input[name=expiry]').type('12/28')
cy.get('input[name=cvc]').type('123')
cy.get('[data-cy=place-order]').click()
// Cypress automatically retries this assertion until it passes or times out —
// no manual wait needed for the confirmation page to render
cy.get('[data-cy=order-confirmation]').should('be.visible').and('contain', 'Thank you')
cy.url().should('include', '/orders/confirmation')
})
it('shows a validation error for an expired card', () => {
cy.get('[data-cy=checkout-button]').click()
cy.get('input[name=cardNumber]').type('4000000000000069')
cy.get('[data-cy=place-order]').click()
cy.get('[data-cy=card-error]').should('contain', 'expired')
})
})Fixtures
Fixtures are static JSON (or text/binary) files under cypress/fixtures/ used as canned test data — a mocked API response, seed data for a form, an image upload payload. Load one with cy.fixture('products.json') or pass it directly as the response body to cy.intercept(), keeping test data out of the spec file and reusable across multiple tests.
Network Stubbing with cy.intercept()
cy.intercept() is Cypress's network layer — it can spy on requests without changing behavior, stub a response entirely (skip the real backend), or run a handler function to inspect/modify the request and craft a dynamic response. Aliasing an intercept with .as('name') and then cy.wait('@name') is the standard way to synchronize a test with a specific network call instead of guessing at a timeout.
// Network stubbing with cy.intercept() — control the server's response
// so tests aren't at the mercy of a real backend's data or latency.
describe('Product list', () => {
it('renders products from the API', () => {
cy.intercept('GET', '/api/products*', { fixture: 'products.json' }).as('getProducts')
cy.visit('/products')
cy.wait('@getProducts') // wait for the aliased request, not a fixed delay
cy.get('[data-cy=product-card]').should('have.length', 3)
})
it('shows an empty state when the API returns no products', () => {
cy.intercept('GET', '/api/products*', { body: [] }).as('getEmptyProducts')
cy.visit('/products')
cy.wait('@getEmptyProducts')
cy.get('[data-cy=empty-state]').should('be.visible')
})
it('retries the request once after a transient 500', () => {
let callCount = 0
cy.intercept('GET', '/api/products*', (req) => {
callCount++
if (callCount === 1) {
req.reply({ statusCode: 500 })
} else {
req.reply({ fixture: 'products.json' })
}
}).as('getProductsFlaky')
cy.visit('/products')
cy.wait('@getProductsFlaky')
cy.wait('@getProductsFlaky')
cy.get('[data-cy=product-card]').should('have.length', 3)
})
it('asserts on the outgoing request body', () => {
cy.intercept('POST', '/api/cart', (req) => {
expect(req.body).to.deep.include({ productId: 'sku-42', quantity: 1 })
req.reply({ statusCode: 201 })
}).as('addToCart')
cy.visit('/products')
cy.get('[data-cy=add-to-cart]').first().click()
cy.wait('@addToCart')
})
})Stubbing lets a UI test run deterministically against edge cases (empty list, 500 error, slow response) that are hard to reproduce reliably against a real backend.
Asserting on the outgoing request body/headers inside an intercept handler catches regressions in what the frontend actually sends, not just what it renders.
Prefer aliases + cy.wait('@alias') over cy.wait(<ms>) — waiting on a fixed duration is either wastefully slow or, under load, still too short.
Custom Commands
When the same multi-step interaction — logging in, adding an item to a cart, filling a multi-field form — shows up across many specs, wrap it as a custom command in cypress/support/commands.js. This keeps specs focused on what's unique to that test and gives you one place to update when the underlying flow changes. cy.session() additionally caches expensive setup like login across tests within a run, so only the first test that needs a session actually pays for it.
// cypress/support/commands.js
// Custom commands wrap repeated flows so tests read like user actions,
// not a replayed sequence of low-level DOM steps.
Cypress.Commands.add('login', (email, password) => {
cy.session([email, password], () => {
cy.visit('/login')
cy.get('input[name=email]').type(email)
cy.get('input[name=password]').type(password)
cy.get('[data-cy=login-submit]').click()
cy.url().should('include', '/dashboard')
})
})
Cypress.Commands.add('addToCartBySku', (sku) => {
cy.intercept('POST', '/api/cart').as('addToCart')
cy.get(`[data-cy=product-${sku}] [data-cy=add-to-cart]`).click()
cy.wait('@addToCart')
})
// cypress/support/index.d.ts — TypeScript users get autocomplete/type-checking
// for custom commands by augmenting the global Cypress.Chainable interface.
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>
addToCartBySku(sku: string): Chainable<void>
}
}
}
// Usage in a spec — cy.session() caches the login across tests in the same run,
// so only the first test actually exercises the login form.
describe('Cart', () => {
beforeEach(() => {
cy.login('shopper@example.com', 'correct-horse-battery-staple')
cy.visit('/products')
})
it('adds an item using the custom command', () => {
cy.addToCartBySku('sku-42')
cy.get('[data-cy=cart-count]').should('have.text', '1')
})
})CI Integration
Cypress runs headlessly in CI via cypress run, typically against a build served by the same pipeline (start-server-and-test is a common helper to boot the app and wait for it to be reachable before Cypress starts). The Cypress Cloud dashboard (optional, paid) adds parallelization across CI machines, automatic retries of flaky tests with video/screenshot artifacts, and historical run analytics — but a suite can run entirely without it using just the open-source runner.
Run specs in parallel across multiple CI machines/containers by splitting spec files, not by running one giant spec — Cypress Cloud can also auto-balance this by prior run timing.
Capture screenshots on failure (on by default) and videos of full runs to make CI failures debuggable without reproducing locally.
Use
cypress run --recordonly with a project key configured; without one, CI logs a warning but the run still completes.
Common Pitfalls
Assigning a command's result with a plain variable —
const btn = cy.get(...)captures the chainable object, not the element; use.then(($btn) => { ... })when you need the resolved value.Adding
cy.wait(<ms>)to "fix" flakiness instead of waiting on the actual condition (an alias, a visible assertion) — it treats the symptom and still leaves a race under different timing.Chaining assertions off a query that itself needs to retry, without re-querying — once an element is aliased with
.as(), re-fetch it viacy.get('@alias')rather than holding a stale reference across an action that could re-render the DOM.Testing against real third-party services (payment providers, email) in every run instead of stubbing them — slow, flaky, and can have side effects like real charges.
Sharing mutable state between tests via
it.onlydebugging left in, or relying on test execution order — each test should set up its own state.