Cypress
Developer-friendly, all-in-one test runner with time-travel debugging and automatic retries.
What is Cypress and why use it
Cypress is a JavaScript end-to-end testing tool that runs inside the browser alongside your app. Its command chains automatically retry until assertions pass, and its interactive runner lets you time-travel through every command with DOM snapshots. It is loved for its ergonomics and fast feedback loop.
- All-in-one: runner, assertion library (Chai), and mocking built in — no extra wiring.
- Automatic retry-ability: cy.get(...).should(...) re-queries until it passes or times out.
- Time-travel debugging: hover any command in the runner to see the app state at that moment.
- Great network control: cy.intercept() stubs/observes requests with almost no boilerplate.
- Trade-offs to know: runs in the browser (Chromium-family, Firefox, WebKit-experimental), one origin per test by design, and no multi-tab.
Prerequisites
- Node.js 18+ (node -v).
- The TestForge app running at http://localhost:3100.
- Basic JavaScript. Cypress uses a chained, jQuery-like syntax you will pick up quickly.
Installation and setup
mkdir testforge-cypress && cd testforge-cypress
npm init -y
npm install --save-dev cypress
# Open the interactive app (scaffolds config + example specs on first run)
npx cypress openSet a baseUrl so specs use relative paths like cy.visit("/login").
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3100',
setupNodeEvents(on, config) {
return config;
},
},
});Your first test
describe('Catalog', () => {
it('shows the heading and product cards', () => {
cy.visit('/catalog');
cy.contains('h1', 'Catalog').should('be.visible');
cy.contains('button', 'Add to cart').should('be.visible');
});
});# Interactive runner (watch it execute, time-travel)
npx cypress open
# Headless run in CI
npx cypress run --spec cypress/e2e/catalog.cy.jsCommands are asynchronous but you write them synchronously
cy.get() does not return the element immediately — it enqueues a command. Never assign const el = cy.get(...) and use it later; chain instead: cy.get(...).click().
Selecting elements and retry-ability
cy.get() and cy.contains() automatically retry until the element exists and the following assertion passes. The official recommendation is to select by a dedicated data-* attribute for resilience.
cy.get('[data-testid="cart-count"]'); // best: app-owned hook
cy.contains('button', 'Log in'); // by text + tag
cy.get('#email'); // by id
cy.get('input[name="password"]'); // by attribute
// Scope queries with within()
cy.get('.card').first().within(() => {
cy.contains('button', 'Add to cart').click();
});Do not chain off commands after an action that re-renders
After a click that replaces the DOM, re-query with a fresh cy.get() rather than reusing an earlier subject, or Cypress may act on a detached element.
Assertions
Assertions attach with .should() (retried) or expect() inside .then() (single-shot). Prefer .should() so Cypress keeps retrying the whole chain.
cy.get('[data-testid="cart-count"]').should('have.text', '1');
cy.contains('button', 'Add to cart').should('be.enabled');
cy.url().should('include', '/checkout');
cy.get('[role="alert"]').should('contain', 'out of stock');
cy.get('.card').should('have.length', 12);Network control with cy.intercept()
cy.intercept() is a superpower: wait on real requests, assert payloads, or stub responses to force edge cases (like an out-of-stock error) deterministically.
// Wait for the real cart POST after clicking Add to cart
cy.intercept('POST', '**/api/cart/items').as('addToCart');
cy.contains('button', 'Add to cart').first().click();
cy.wait('@addToCart').its('response.statusCode').should('eq', 201);
// Stub a failure to test the error toast without touching the backend
cy.intercept('POST', '**/api/cart/items', {
statusCode: 409,
body: { error: { code: 'OUT_OF_STOCK', message: 'Item is out of stock' } },
}).as('addFail');
cy.contains('button', 'Add to cart').first().click();
cy.wait('@addFail');
cy.contains('Item is out of stock').should('be.visible');Custom commands: a reusable login
Wrap repeated flows in Cypress.Commands.add(). For speed, log in through the API once and set the token, instead of driving the UI every time.
// UI login
Cypress.Commands.add('loginByUi', (email, password) => {
cy.visit('/login');
cy.get('#email').type(email);
cy.get('#password').type(password);
cy.contains('button', 'Log in').click();
cy.contains(email).should('be.visible');
});
// Faster: log in via the API, then seed the token the app expects
Cypress.Commands.add('loginByApi', (email, password) => {
cy.request('POST', 'http://localhost:3000/api/auth/login', { email, password })
.its('body')
.then((body) => {
window.localStorage.setItem('refreshToken', body.refreshToken);
});
});Log in via API for speed, via UI to test the login itself
Use loginByApi as a fast precondition for tests about other features; keep at least one UI login test that exercises the real form.
Running headless and in CI
name: cypress
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- uses: cypress-io/github-action@v6
with:
start: npm run dev
wait-on: 'http://localhost:3100'cypress run records screenshots on failure and a video of the whole run by default — great CI artifacts for triage.
Best practices
- Select by data-* attributes, not CSS classes or text that changes.
- Let .should() retry — avoid manual waits; never cy.wait(3000) on a timer, wait on an alias instead.
- Keep tests independent: reset state via API calls or cy.intercept stubs, not via a previous test.
- Do not over-use the UI for setup; seed state through cy.request for speed.
- One behavior per test; assert on user-visible outcomes.
The command queue and built-in retry-ability
Cypress commands do not execute the moment you call them. Each cy.* call enqueues a command onto an internal queue, and Cypress drains that queue asynchronously after the current test function has finished running. This is why you write chains that look synchronous but never use async/await — the test body just builds the plan, and the runner executes it in order.
it('adds an item to the cart', () => {
cy.visit('/catalog'); // enqueued, not run yet
cy.contains('button', 'Add to cart').first().click(); // enqueued
cy.get('[data-testid="cart-count"]').should('have.text', '1'); // enqueued
});
// The whole function returns immediately; Cypress then runs the queue in order.Because commands are enqueued, you cannot capture a return value synchronously. cy.get() yields a subject into the chain, not a live element, so const el = cy.get(...) is meaningless. To reach into a yielded value use .then(), which runs after the previous command resolves.
// Wrong: cy.get does not return the element
// const count = cy.get('[data-testid="cart-count"]'); // this is a chainable, not a node
// Right: work with the subject inside .then()
cy.get('[data-testid="cart-count"]').then(($el) => {
expect($el.text()).to.equal('1'); // one-shot, no retry
});Retry-ability is what makes Cypress resilient to timing. A query command (cy.get, cy.contains) plus a trailing .should() forms a retryable unit: Cypress re-runs the query and re-checks the assertion on a loop until it passes or the command times out (4s by default). You almost never need a manual wait because the assertion is the wait.
// This re-queries the DOM until the count reads '1' or it times out.
// No cy.wait(500) needed — the .should() drives the retry loop.
cy.contains('button', 'Add to cart').first().click();
cy.get('[data-testid="cart-count"]').should('have.text', '1');
// Multiple assertions retry together as a group.
cy.get('[role="status"]')
.should('be.visible')
.and('contain', 'Added to cart');Only the last query in a chain is retried
Cypress retries the final query command before a .should(), not the whole chain. If you chain cy.get(a).find(b).should(...), only .find(b) re-runs each tick — so keep the retried query as close to the assertion as possible, and avoid actions (like .click()) mid-chain when you want retry-ability.
Network control with cy.intercept: stubbing, spying, and fixtures
cy.intercept() sits between the app and the network. Registered without a response it spies — it lets the real request through to the TestForge API at http://localhost:3000/api while giving you an alias to wait on and assert against. Registered with a response object it stubs — the request never reaches the backend and you control the payload, status, and timing.
// SPY: let the real checkout request happen, then assert on it.
cy.intercept('POST', '**/api/checkout').as('checkout');
cy.contains('button', 'Place order').click();
cy.wait('@checkout').then((interception) => {
expect(interception.response.statusCode).to.eq(201);
expect(interception.request.body).to.have.property('items');
});// STUB: force a payment failure without touching the backend.
cy.intercept('POST', '**/api/checkout', {
statusCode: 402,
body: { error: { code: 'PAYMENT_DECLINED', message: 'Card was declined' } },
}).as('checkoutFail');
cy.contains('button', 'Place order').click();
cy.wait('@checkoutFail');
cy.contains('Card was declined').should('be.visible');Aliases turn an interception into something you can wait on. cy.wait("@alias") blocks the queue until that request fires and resolves, then yields the interception so you can assert on request and response. This replaces flaky timer-based waits — you wait for the event, not for a guessed number of milliseconds.
For larger stub bodies, keep the JSON in cypress/fixtures and load it by name. This keeps specs readable and lets you reuse a canned cart across tests.
{
"id": "cart-123",
"items": [
{ "sku": "TF-WIDGET-01", "name": "Widget", "quantity": 2, "price": 1999 }
],
"subtotal": 3998
}// Serve the fixture as the cart response.
cy.intercept('GET', '**/api/cart', { fixture: 'cart.json' }).as('getCart');
cy.visit('/cart');
cy.wait('@getCart');
cy.contains('Widget').should('be.visible');
cy.get('[data-testid="cart-count"]').should('have.text', '2');
// Or load a fixture explicitly to build a request/assertion.
cy.fixture('cart.json').then((cart) => {
expect(cart.items).to.have.length(1);
});Register the intercept before the request fires
cy.intercept only catches requests made after it is set up. Declare it before cy.visit() or the click that triggers the call, otherwise the real request slips through and cy.wait("@alias") times out.
cy.session and custom commands: cache the login
Logging in through the UI in every test is slow and brittle. cy.session() caches the browser state (cookies, localStorage, sessionStorage) produced by a setup function under a key, then restores it in later tests instead of re-running the login. Combine it with a cy.login() custom command so every spec gets a fast, authenticated starting point.
Cypress.Commands.add('login', (email, password) => {
cy.session(
[email, password],
() => {
// Setup runs once per unique session key, then the state is cached.
cy.request('POST', 'http://localhost:3000/api/auth/login', {
email,
password,
})
.its('body')
.then((body) => {
window.localStorage.setItem('refreshToken', body.refreshToken);
});
},
{
// Guard the cached session is actually valid before reusing it.
validate() {
cy.request({
url: 'http://localhost:3000/api/auth/me',
failOnStatusCode: false,
})
.its('status')
.should('eq', 200);
},
cacheAcrossSpecs: true,
}
);
});Import the commands file once (Cypress does this via cypress/support/e2e.js by default) and call cy.login() in a beforeEach. The first test pays the login cost; the rest restore the cached session almost instantly.
describe('Checkout', () => {
beforeEach(() => {
cy.login('shopper@testforge.dev', 'Password123!');
cy.visit('/cart');
});
it('completes an order', () => {
cy.contains('button', 'Place order').click();
cy.contains('Order confirmed').should('be.visible');
});
});Always give cy.session a validate function
Without validate(), Cypress trusts a stale cached session and your tests fail confusingly when the token has expired. The validate callback re-checks the session (e.g. hitting /api/auth/me) and forces a fresh login if it is no longer valid.
Interview questions & answers
Cypress commands look synchronous, yet the docs tell you not to use async/await. What is actually happening under the hood?
Each cy.* call does not run immediately — it enqueues a command onto an internal queue, and the test function returns right away. Cypress then drains that queue asynchronously, running each command in order and waiting for it to resolve before starting the next. Because the commands are promise-like but not real promises, mixing in async/await breaks the queue ordering, which is why you chain with .then() instead.
Explain retry-ability and how it reduces flake.
A query command such as cy.get() or cy.contains() paired with a trailing .should() is retried as a unit: Cypress re-runs the query and re-evaluates the assertion on a loop until it passes or the command times out. This means the assertion itself is the wait, so tests do not race ahead of an element that is still rendering or a request that is still in flight. It removes the need for arbitrary cy.wait(3000) timers, which are the classic source of flakiness.
What is the difference between using cy.intercept to spy versus to stub?
Spying means registering cy.intercept with only a matcher and an alias — the real request still hits the server, but you gain the ability to cy.wait on it and assert on the actual request and response. Stubbing means passing a response object or fixture, so Cypress short-circuits the request and returns your canned data without ever reaching the backend. Spy when you want to verify real integration behavior; stub when you need a deterministic edge case like a 402 payment failure or an empty state.
How does cy.session speed up a suite, and why is the validate function important?
cy.session runs a setup function once per unique session key and caches the resulting cookies and storage, then restores that state in subsequent tests instead of re-executing the login flow. This turns a multi-second UI login into a near-instant restore for every test after the first. The validate callback re-checks that the cached session is still good — for example by hitting /api/auth/me — so an expired token forces a fresh login rather than silently poisoning every downstream test.
What are the well-known limitations of Cypress a senior engineer should be able to name?
Cypress runs inside a single browser tab and cannot control multiple tabs or windows, so flows that open a new tab need a workaround like asserting on the target attribute. Historically it enforced a same-origin-per-test model and could not visit multiple superdomains in one test, though cy.origin() later relaxed this. It also runs only browser-family engines (Chromium, Firefox, WebKit-experimental), has no native mobile app support, and executes in the browser rather than out-of-process, which shapes how you think about network and cross-origin work.
How do fixtures and aliases fit together in a Cypress test?
A fixture is a static file, usually JSON in cypress/fixtures, that you load with cy.fixture() or reference directly from cy.intercept via { fixture: "cart.json" } to serve as a stubbed response. An alias, created with .as("name"), is a handle you attach to an intercept or an element so you can refer to it later. Together they let you stub a request with fixture data and then cy.wait("@name") for that request to fire, yielding the interception for assertions.
When would you reach for Cypress over Playwright or Selenium, and when not?
Cypress shines for front-end teams wanting a fast, ergonomic feedback loop with time-travel debugging, built-in retry-ability, and first-class network stubbing, all in JavaScript. Reach for Playwright when you need true multi-tab or multi-origin flows, parallel multi-browser coverage including real WebKit, or out-of-process control; reach for Selenium when you need broad legacy browser and language support or an established grid. In short, Cypress trades some breadth of control for a smoother developer experience.
A test passes locally but is flaky in CI. How do you diagnose and fix it?
First I look at the failure screenshot and video Cypress captures on CI runs to see the actual state at the moment of failure. The usual culprits are racing ahead of async work, so I replace any fixed cy.wait timers with assertion-driven retries or cy.wait on an intercept alias, and I make sure intercepts are registered before the request fires. I also isolate the test by seeding state through cy.request or stubs rather than depending on a previous test, and I check for detached-element reuse after a re-render.
When should you use .should() versus expect() inside .then()?
Use .should() as your default because it is retried — Cypress keeps re-querying and re-checking until it passes, which absorbs timing differences. Use expect() inside a .then() when you need to run one-shot logic against a yielded subject, such as reading a value out of a response body or asserting on a computed value, accepting that it runs once with no retry. A good rule is: assert on the DOM with retryable .should(), and use expect() for imperative checks on data you have already resolved.
What do you need to consider to run Cypress reliably in CI?
You need the app running and reachable before tests start, so you use a wait-on step against the base URL (for TestForge that is http://localhost:3100) before invoking cypress run. Run headless, and lean on the screenshots-on-failure and full-run video that Cypress produces by default as triage artifacts. For speed and stability, cache the browser and dependencies, split specs across parallel machines if the runner supports it, and keep tests independent so a shared machine or ordering does not cause cross-test contamination.
Where to go next
- Official docs: docs.cypress.io
- Study cy.intercept() thoroughly — deterministic network control is Cypress at its best.
- Explore Cypress Component Testing for testing React components in isolation.
- Practice: automate TestForge login + add-to-cart + checkout, stubbing a payment failure to assert the retry flow.