Cypress Interview Questions and Answers
Last updated:
Check out 45 of the most common Cypress interview questions, then take an AI-powered practice interview
Q1How does Cypress execute tests differently from Selenium WebDriver, and what follows from that difference?
BasicArchitecture
Answer
Selenium sends commands over HTTP (the W3C WebDriver protocol) to a driver binary, which then talks to the browser. Every click is a network round trip from your test process into the browser process. Cypress inverts this: it loads your application into an iframe inside a browser window it controls, bundles your spec file, and executes the test code in the same JavaScript event loop as the app.
There is no wire protocol between the test and the page. Everything you gain and everything you lose comes from that decision. On the gain side you get native access to window, document, localStorage and the application's own objects, so you can stub fetch and XHR with cy.intercept, freeze time with cy.clock, read Redux state directly, and get automatic waiting because Cypress can observe the app's event loop rather than polling from outside.
Cypress also ships the whole stack in one package: Mocha as the runner, Chai plus chai-jquery for assertions, Sinon for stubs, and jQuery as the selection engine, so there is no assembly step. On the loss side, your test is subject to browser rules. A test is confined to one superdomain unless you use cy.origin, there is no multi-tab control, events are synthetic DOM events rather than OS-level input, and anything that needs Node (reading a file, hitting a database, running a shell command) has to hop to the Node process through cy.task or cy.exec. Interviewers usually ask this question so they can follow up with why can Cypress not do X, and the answer they want is always traced back to the in-browser architecture.
// Cypress shares the JS realm with the app, so this just works
cy.window().its('store').invoke('getState')
.should('have.nested.property', 'cart.count', 2);
cy.window().then((win) => {
win.localStorage.setItem('ff_new_checkout', 'on');
});
// Anything outside the browser has to hop to the Node process
cy.task('db:seed', { plan: 'gold', users: 3 });
cy.exec('npm run reset:test-data');
Key Points
- Test code runs in the browser, in the same event loop as the app
- No WebDriver wire protocol, so no per-command HTTP round trip
- Direct access to window, network layer and timers enables stubbing
- Costs: one superdomain per test, no tabs, synthetic events, Node work via cy.task
- Bundles Mocha, Chai, Sinon and jQuery so nothing needs assembling
Q2Why does const el = cy.get('#total') not give you the element, and what do you write instead?
BasicCommand Queue
Answer
Cypress commands do not execute when you call them. Calling cy.get() enqueues a command into an internal queue and immediately returns a Chainable object. Cypress only drains that queue after your test function body has finished running.
So every synchronous line in your test runs first, in order, and only then does the first command actually touch the page. A variable assigned from cy.get() therefore holds a Chainable, never a DOM element. The Chainable is deliberately not a real Promise either.
It has a .then(), which is why people try to await it, but it has no .catch() or .finally() and awaiting it does not do what you expect, because the queue drains under Cypress's own scheduler. Mixing async/await with cy commands is the single most common mistake from candidates coming from Playwright or WebdriverIO, and interviewers watch for it. The correct patterns are: use .then((subject) => {}) when you genuinely need imperative access to the yielded value, use .as() to alias something you will need again, use cy.wrap(value) to pull a plain JavaScript value back into the command queue so you can assert on it, and use Cypress.$ if you want raw synchronous jQuery with no retries.
If you need a value across commands, declare the variable outside and assign it inside a .then(), but only read it inside a later command callback, never on the next synchronous line. Prefer chaining assertions over storing values at all, because chained assertions retry and stored values do not.
// WRONG: total is a Chainable, not an element, and this logs undefined
// const total = cy.get('[data-cy=total]');
// console.log(total.text());
// Right: operate on the subject inside .then()
cy.get('[data-cy=total]')
.invoke('text')
.then((text) => {
const before = Number(text.replace(/[^0-9]/g, ''));
cy.get('[data-cy=add-item]').click();
cy.get('[data-cy=total]')
.invoke('text')
.should((after) => {
expect(Number(after.replace(/[^0-9]/g, ''))).to.eq(before + 1);
});
});
// Or alias once and re-query later
cy.get('[data-cy=order-row]').as('rows');
cy.get('@rows').should('have.length', 3);
// Bring a plain value into the queue
cy.wrap({ id: 42 }).its('id').should('eq', 42);
Q3What lives in cypress.config.ts and which keys change behaviour the most?
BasicConfiguration
Answer
Since the 10.x release, configuration lives in cypress.config.ts (or .js/.mjs) at the project root and is written with the defineConfig helper, which gives you full TypeScript autocomplete. The old cypress.json is gone. The file has two independent testing-type blocks, e2e and component, plus top-level options shared by both.
The keys that matter in practice are: baseUrl, so cy.visit('/login') resolves and Cypress can fail fast with 'Cypress could not verify that this server is running'; specPattern, which controls where spec files are discovered (the default for e2e is cypress/e2e with .cy.{js,jsx,ts,tsx}); supportFile, which points at the file loaded before every spec; viewportWidth and viewportHeight, which default to 1000x660 and are a frequent cause of a test passing on a developer laptop and failing in CI because a responsive breakpoint flips; defaultCommandTimeout, the 4000 ms budget that governs most retries; retries, split into runMode and openMode; video, which defaults to off in recent versions; and env for test data that varies by environment. setupNodeEvents(on, config) is the Node-side hook where you register cy.task handlers, browser launch flags, and spec lifecycle events. Configuration resolves in a defined order: config file, then environment variables, then CLI flags, so a --config or --env passed in the pipeline always wins over the checked-in file. That precedence is what lets one config serve local, staging and production smoke runs without branching.
import { defineConfig } from 'cypress';
export default defineConfig({
viewportWidth: 1280,
viewportHeight: 800,
defaultCommandTimeout: 6000,
video: false,
screenshotOnRunFailure: true,
retries: { runMode: 2, openMode: 0 },
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.ts',
supportFile: 'cypress/support/e2e.ts',
experimentalMemoryManagement: true,
numTestsKeptInMemory: 5,
setupNodeEvents(on, config) {
on('task', {
'db:seed': (payload) => require('./scripts/seed')(payload),
});
return config;
},
},
component: {
devServer: { framework: 'react', bundler: 'vite' },
},
env: { apiUrl: 'http://localhost:4000/api' },
});
Q4What is the difference between cypress open and cypress run, and which CLI flags matter in a pipeline?
BasicTooling
Answer
cypress open launches the interactive Test Runner: you pick E2E or Component testing, pick a browser, and specs run headed with the command log, time-travel snapshots and DOM inspection available. It is a development tool and it keeps far more state in memory than a CI run does. cypress run executes specs headlessly by default, in the Electron browser unless you pass --browser, prints results to stdout, writes screenshots on failure, and exits with a non-zero code if anything failed, which is what your pipeline step reads. Defaults also differ between the two modes, most notably test retries, where the common setup is retries: { runMode: 2, openMode: 0 }, so a developer sees a failure immediately while CI gets two extra attempts.
The flags worth knowing by heart: --browser chrome or --browser electron to pin the browser, --headed to watch a run locally, --spec with a glob to run a subset, --e2e or --component to choose the testing type, --config and --env to override configuration and environment values from the pipeline, --config-file to swap configs per environment, --record --key to send results to Cypress Cloud, --parallel with --ci-build-id for spec-level load balancing across machines, --auto-cancel-after-failures to abort a doomed run early, and --reporter with --reporter-options for JUnit or mochawesome output. Interviewers often ask why a test passes in open mode but fails in run mode, and the useful answers are viewport differences, animations that the headless browser renders differently, retries masking the problem in one mode, and state left behind by a previous spec that open mode did not have.
# Local development, interactive
npx cypress open
# Full headless run in Chrome
npx cypress run --browser chrome
# One suite only, with overrides from the pipeline
npx cypress run \
--spec 'cypress/e2e/checkout/**/*.cy.ts' \
--config baseUrl=https://staging.example.in,defaultCommandTimeout=8000 \
--env apiUrl=https://staging-api.example.in/api
# Component tests only
npx cypress run --component
# Recorded, parallel across 4 machines, cancel after 3 failures
npx cypress run --record --key $CYPRESS_RECORD_KEY \
--parallel --ci-build-id $GITHUB_RUN_ID \
--auto-cancel-after-failures 3
Q5How does Cypress retry-ability work, and which of the four default timeouts applies where?
BasicRetry-ability
Answer
Cypress never sleeps for a fixed duration by default. Instead, queries and their assertions are retried until they pass or a timeout expires. The critical distinction is between queries and actions.
Queries are commands like cy.get, .find, .contains, .its, .invoke, .filter and .eq: they only look at the page and are safe to run repeatedly. Actions are commands like .click, .type, .select and .check: they change state, so Cypress will not re-run them, although it does re-check actionability (visible, not disabled, not covered, not animating) until the timeout before firing the event once. Assertions attach to the command before them and drive the retry loop, so cy.get('tr').should('have.length', 5) keeps re-running the query until the row count matches.
Since the 12.x query rewrite, a contiguous chain of queries is re-run as a group rather than only the last link, which removed a whole class of detached-DOM failures that used to plague chains like cy.get('table').find('td'). The timeouts you need to name: defaultCommandTimeout, 4000 ms, governs most command and assertion retries; pageLoadTimeout, 60000 ms, covers cy.visit and page transitions; requestTimeout, 5000 ms, is how long cy.wait waits for a matching request to start; responseTimeout, 30000 ms, is how long it waits for that response to arrive, and also applies to cy.request. Each can be overridden globally in cypress.config.ts or per command with an options object, which is the right approach for a genuinely slow operation like a report export rather than raising the global default and making every real failure take a minute to surface.
// The assertion retries the whole query chain until it passes
cy.get('[data-cy=orders]').find('tr').should('have.length', 5);
// .then() runs exactly once, no retry: a classic flake source
cy.get('[data-cy=status]').then(($el) => {
expect($el.text()).to.eq('Shipped'); // may run too early
});
// .should(callback) retries the callback until it passes or times out
cy.get('[data-cy=status]').should(($el) => {
expect($el.text()).to.eq('Shipped');
});
// Override the budget only where it is genuinely needed
cy.get('[data-cy=export-ready]', { timeout: 60000 }).should('be.visible');
cy.visit('/reports', { timeout: 90000 });
Q6What is the difference between .then(), .should(), .and(), .its() and .invoke()?
BasicChaining
Answer
.then() takes the yielded subject and hands it to your callback exactly once. It does not retry. Whatever you return from the callback becomes the new subject for the next command, and returning undefined passes the original subject through unchanged.
Use it for imperative branching, for reading a value you will compare later, or for calling other cy commands inside a closure. .should() attaches an assertion to the previous command and drives the retry loop. Passing a string form, .should('have.text', 'Paid'), reads as a Chai assertion; passing a callback, .should(($el) => {}), re-runs that callback until every expect inside it passes or the timeout fires. Because .should callbacks re-run, they must be free of side effects: putting a cy.click() inside a .should callback is a real bug that shows up as a button clicked five times. .and() is simply an alias for .should() that reads better when chaining a second assertion on the same subject. .its() drills into a property of the subject and retries, so cy.window().its('appReady') keeps checking until the flag is set. .invoke() calls a method on the subject and yields the return value, most commonly .invoke('text'), .invoke('val'), .invoke('attr', 'href') or .invoke('removeAttr', 'target').
Both .its and .invoke are queries, so they participate in retries, which is why .invoke('text').should('contain', 'Paid') is more reliable than .then(($el) => expect($el.text())...). Getting this distinction right is the single biggest lever on suite stability and interviewers know it.
// Retrying assertions: preferred
cy.get('[data-cy=invoice-status]').should('have.text', 'Paid');
cy.get('[data-cy=invoice-total]')
.invoke('text')
.should('match', /^\u20B9[0-9,]+$/);
// Chained assertions with .and
cy.get('[data-cy=download]')
.should('be.visible')
.and('have.attr', 'href')
.and('include', '/invoices/');
// .its retries until the app sets the flag
cy.window().its('__APP_READY__').should('eq', true);
// .then for imperative work only, runs once
cy.get('[data-cy=row]').then(($rows) => {
const ids = [...$rows].map((r) => r.dataset.id);
cy.log('visible ids: ' + ids.join(','));
});
// BUG: side effect inside a retrying assertion
// cy.get('[data-cy=submit]').should(($b) => { $b.trigger('click'); });
Q7What selector strategy does Cypress recommend, and why is there no built-in cy.xpath?
BasicSelectors
Answer
Cypress selects elements with jQuery's engine, so any valid CSS selector works, and the official guidance is to select on a dedicated test attribute rather than on styling or structure. The convention is data-cy, though data-test and data-testid are equally common and Cypress lets you tell the Selector Playground which attribute to prefer. The reasoning is straightforward: class names change when a designer refactors Tailwind utilities, ids get generated by component libraries, and nth-child positions shift the moment someone adds a column.
A data-cy attribute exists solely for tests, so nobody removes it by accident, and a grep for the attribute tells a developer immediately that a test depends on this node. There is no built-in XPath command because XPath encourages exactly the brittle structural selectors Cypress is trying to discourage, and jQuery's CSS engine plus .contains, .filter, .parent, .siblings and .closest cover nearly every case. A community plugin adds cy.xpath, but it is not maintained by the Cypress team and reaching for it in an interview usually signals a Selenium habit rather than a Cypress one.
Two Cypress-specific extras are worth naming. First, cy.contains(selector, text) is the idiomatic way to find a button or link by its visible label. Second, if your team already uses Testing Library on the frontend, @testing-library/cypress adds cy.findByRole, cy.findByLabelText and friends, which are query commands and therefore retry correctly, and which push tests towards the accessibility tree rather than implementation details.
// Preferred: stable test attribute
cy.get('[data-cy=checkout-submit]').click();
// Wrap it in a query command so it retries like cy.get
Cypress.Commands.addQuery('getByCy', function (id) {
const getFn = cy.now('get', `[data-cy=${id}]`);
return () => getFn();
});
// cy.contains scoped to a selector, good for labelled controls
cy.contains('button', 'Pay now').click();
// Accessibility-first, via @testing-library/cypress
cy.findByRole('button', { name: /pay now/i }).click();
cy.findByLabelText('PAN number').type('ABCDE1234F');
// Avoid: breaks the moment markup or styling changes
// cy.get('.MuiButton-root.css-1x9kq2').click();
// cy.get('div > div:nth-child(3) > span').should('be.visible');
Q8What does cy.contains() actually match, and what is its element preference order?
BasicSelectors
Answer
cy.contains() finds an element by its rendered text content, not by attribute or value. It matches on a substring by default and is case sensitive unless you pass { matchCase: false } or a case-insensitive regular expression. Two behaviours surprise people.
First, when several nested elements contain the same text, Cypress yields the deepest one, so a label wrapped in three divs resolves to the innermost node holding the text rather than the outer container. Second, Cypress applies a documented preference order when the match is ambiguous at the same depth: input[type=submit] with a matching value, then button with matching text, then a with matching text, then label with matching text, and only then any other element. That preference is why cy.contains('Submit').click() usually lands on the actual button rather than a stray span.
In practice you should almost always pass a selector as the first argument, cy.contains('button', 'Pay now'), because that scopes the search and removes the ambiguity entirely. Other details worth knowing: cy.contains ignores text inside script and style tags, it normalises whitespace so a label broken across lines in JSX still matches, it can be chained to scope the search to a subject as in cy.get('[data-cy=order-card]').contains('Delivered'), and it accepts a regular expression when you need a partial or fuzzy match. If your app is multilingual, hard-coded English strings inside cy.contains are one of the fastest ways to make a suite unmaintainable, so scope to a data attribute and assert the text separately.
// Ambiguous: yields the deepest matching element
cy.contains('Delivered');
// Scoped by selector: the idiomatic form
cy.contains('button', 'Pay now').click();
// Case-insensitive and regex matching
cy.contains('pay now', { matchCase: false });
cy.contains(/^Order #[0-9]{6}$/);
// Scoped to a parent subject
cy.get('[data-cy=order-card]').contains('Delivered').should('be.visible');
// Better for a localised app: find by attribute, assert text separately
cy.get('[data-cy=order-status]').should('have.text', 'Delivered');
Q9What does cy.visit() do under the hood, and what are the common reasons it fails?
BasicNavigation
Answer
cy.visit() navigates the application iframe to a URL and does not resolve until the page fires its load event, subject to pageLoadTimeout, which defaults to 60000 ms. If baseUrl is set in cypress.config.ts you pass a path, cy.visit('/login'), and Cypress also uses baseUrl to verify the server is reachable before the run even starts. The failure modes each have distinctive text worth memorising. 'Cypress could not verify that this server is running' means baseUrl did not respond, which in CI almost always means the app container had not finished booting; the fix is a wait-on step in the pipeline rather than a retry in the test. 'cy.visit() failed trying to load' with a response code means the server answered with a 4xx or 5xx, and you can allow that deliberately with failOnStatusCode: false when you are testing a 404 page.
A timeout with the page visibly stuck usually means some request never completes and the load event never fires, common with long-polling, analytics beacons, or a websocket handshake; stub the offending request with cy.intercept. 'Cypress detected a cross origin error' means the app redirected to a different superdomain, an SSO provider being the usual culprit, which needs cy.origin. cy.visit also accepts options that are genuinely useful: onBeforeLoad, which runs before any app script and is the correct place to stub window methods or seed localStorage, onLoad for post-load work, plus qs, headers, method and body for less common cases.
// Seed state before any application script executes
cy.visit('/dashboard', {
onBeforeLoad(win) {
win.localStorage.setItem('auth_token', Cypress.env('token'));
cy.stub(win, 'open').as('windowOpen');
},
});
// Deliberately assert an error page
cy.visit('/orders/does-not-exist', { failOnStatusCode: false });
cy.contains('Order not found').should('be.visible');
// Query string without manual encoding
cy.visit('/search', { qs: { q: 'react developer', city: 'Bengaluru' } });
// A slow first paint on a cold staging box
cy.visit('/reports', { timeout: 90000 });
Q10Which assertion libraries does Cypress bundle, and when do you use expect() instead of .should()?
BasicAssertions
Answer
Cypress bundles Chai for core assertions, chai-jquery for DOM-aware assertions such as be.visible, have.class, have.attr and have.value, and sinon-chai for spy and stub assertions like have.been.calledWith. All three are available without importing anything. There are two ways to assert.
Implicit assertions use .should() or .and() chained onto a command, and these participate in retry-ability, which is why they should be your default. Explicit assertions use expect(value).to.equal(x) or assert.equal(x, y) inside a .then() or a .should() callback, and these are for values that are not DOM subjects: a number you parsed, an object from cy.request, or a property of a network interception. The rule that matters in production: an explicit expect inside .then() runs exactly once and cannot recover from a race, whereas the same expect inside .should() re-runs until it passes.
Useful DOM assertions to have at your fingertips are should('be.visible'), should('exist') versus should('not.exist'), should('have.length', n), should('contain', text) versus should('have.text', text) for exact matching, should('have.value', v) for inputs, should('be.checked'), should('be.disabled') and should('have.attr', 'href', url). Two negatives trip people up: should('not.be.visible') requires the element to exist but be hidden, while should('not.exist') requires it to be absent from the DOM entirely, and picking the wrong one is a common source of a test that passes for the wrong reason. Cypress also lets you extend Chai with chai.use() in the support file when you need a domain-specific assertion such as toBeValidGSTIN.
// Implicit assertions: retried, use these by default
cy.get('[data-cy=cart-count]').should('have.text', '3');
cy.get('[data-cy=coupon-error]').should('not.exist');
cy.get('[data-cy=terms]').should('be.checked');
// Multiple assertions on one subject
cy.get('[data-cy=email]')
.should('be.visible')
.and('have.attr', 'type', 'email')
.and('have.value', 'ops@example.in');
// Explicit assertions on non-DOM values
cy.request('GET', '/api/orders/1').then((res) => {
expect(res.status).to.eq(200);
expect(res.body).to.have.keys(['id', 'total', 'status']);
expect(res.body.total).to.be.greaterThan(0);
});
// Retried explicit assertion
cy.get('[data-cy=balance]').should(($el) => {
expect(Number($el.text().replace(/[^0-9]/g, ''))).to.be.at.least(100);
});
Q11What are fixtures in Cypress, how does cy.fixture() load them, and when should you not use one?
BasicTest Data
Answer
A fixture is a static file of test data stored in cypress/fixtures by default, usually JSON but also CSV, plain text, images or PDFs. cy.fixture('users.json') reads the file through the Node process, caches it for the rest of the run, and yields the parsed contents, so a .json extension is automatically parsed into an object while other types are returned as a string. Binary files are read as base64 unless you pass an encoding, which matters when you are asserting on a downloaded PDF or feeding an image into cy.selectFile. The idiomatic uses are three: aliasing data so a test can reference it later with cy.fixture('user').as('user') and then this.user inside a function-scoped test, feeding a stubbed network response with cy.intercept('GET', '/api/users', { fixture: 'users.json' }), and supplying upload payloads to cy.selectFile('cypress/fixtures/pan-card.png').
Where fixtures go wrong is when they become the only source of truth for a contract that the backend keeps changing. A fixture frozen in 2024 will keep a test green long after the API added a required field, which is the exact failure mode that makes teams distrust their suite. Two mitigations are standard: generate fixtures from the real API contract or an OpenAPI schema during CI, and keep at least a thin layer of contract tests that hit the real endpoint with cy.request. For data that must be unique per run, such as an email or a GSTIN, do not use a fixture at all, generate it in the test or seed it through cy.task so parallel workers never collide.
// Load and alias, then use in a function-scoped test
beforeEach(function () {
cy.fixture('recruiter.json').as('recruiter');
});
it('logs in the seeded recruiter', function () {
cy.get('[data-cy=email]').type(this.recruiter.email);
});
// Stub a network response straight from a fixture
cy.intercept('GET', '/api/jobs*', { fixture: 'jobs-page-1.json' }).as('jobs');
// Modify a fixture before serving it
cy.fixture('jobs-page-1.json').then((jobs) => {
jobs[0].status = 'CLOSED';
cy.intercept('GET', '/api/jobs*', jobs).as('jobsClosed');
});
// Binary fixture for an upload
cy.get('input[type=file]').selectFile('cypress/fixtures/pan-card.png');
// Unique per run: never a fixture
const email = `qa+${Date.now()}@example.in`;
Q12What is the support file, and what belongs in cypress/support/e2e.ts versus commands.ts?
BasicProject Structure
Answer
The support file is loaded and evaluated before every single spec file, in the browser, before your test code runs. For end-to-end tests it defaults to cypress/support/e2e.ts and for component tests to cypress/support/component.ts. Its job is global setup that must exist in every spec without an import: registering custom commands (conventionally by importing ./commands), importing third-party command packages such as @testing-library/cypress/add-commands, cypress-axe or cypress-real-events, installing global event handlers like Cypress.on('uncaught:exception'), setting Cypress.SelectorPlayground defaults, and declaring global beforeEach hooks such as clearing cookies, seeding a feature flag, or preserving a session. commands.ts is where the command implementations themselves live, so that the support file reads as a short manifest and the implementations stay in one searchable place.
Two practical rules save teams a lot of pain. First, keep the support file cheap. Anything expensive there runs once per spec, so an unconditional cy.task('db:reset') in a global beforeEach silently adds seconds to every spec and dominates a 200-spec run.
Second, a global beforeEach that logs in through the UI is the classic slow-suite antipattern; replace it with cy.session wrapping a programmatic login. If you genuinely need a spec to skip the support file, you can set supportFile: false in the config, but in a real project that usually indicates the support file is doing too much.
// cypress/support/e2e.ts
import './commands';
import '@testing-library/cypress/add-commands';
import 'cypress-axe';
Cypress.SelectorPlayground.defaults({
selectorPriority: ['data-cy', 'data-testid', 'id', 'class'],
});
// Third-party scripts throwing in the app must not fail our tests
Cypress.on('uncaught:exception', (err) => {
if (err.message.includes('ResizeObserver loop')) return false;
return true;
});
beforeEach(() => {
cy.intercept('POST', 'https://www.google-analytics.com/**', { statusCode: 204 });
});
// cypress/support/commands.ts
Cypress.Commands.add('loginAs', (role: string) => {
cy.session(role, () => {
cy.request('POST', '/api/auth/login', Cypress.env('users')[role])
.its('body.token')
.then((token) => window.localStorage.setItem('auth_token', token));
});
});
Q13How do you write a custom Cypress command, and how do you type it correctly in TypeScript?
BasicCustom Commands
Answer
Cypress.Commands.add(name, callbackFn) registers a new cy.<name>() command that behaves like a built-in: it appears in the command log, participates in the queue, and can yield a subject for further chaining. Cypress.Commands.add(name, { prevSubject: 'element' }, fn) makes it a child command so it can be chained onto cy.get(), while prevSubject can also be 'optional', 'window', 'document' or an array of allowed types. Cypress.Commands.overwrite(name, fn) replaces an existing command, which is how teams add logging around cy.visit or force a default timeout on cy.get.
Since the 12.x line there is also Cypress.Commands.addQuery, which registers a retrying query rather than a one-shot command, and that is the correct choice for anything that only reads the DOM, because a plain add() command does not retry as a unit. TypeScript typing is where candidates stumble. You have to augment the Cypress.Chainable interface in a .d.ts file that the project's tsconfig includes, declaring the argument types and the yielded subject type.
Without that, cy.loginAs('recruiter') is a compile error and editors give no autocomplete. Two design rules matter in review: a custom command should not contain assertions that belong to the test, because a shared command that asserts makes failures read as if the wrong test broke, and a custom command should be genuinely reusable, not a wrapper around a single screen, which is better expressed as a plain helper function that returns a chain.
// cypress/support/commands.ts
Cypress.Commands.add('getByCy', (id: string, options = {}) =>
cy.get(`[data-cy=${id}]`, options),
);
// Child command, chained onto an element
Cypress.Commands.add(
'clickIfVisible',
{ prevSubject: 'element' },
($el: JQuery<HTMLElement>) => {
if ($el.is(':visible')) cy.wrap($el).click();
return cy.wrap($el);
},
);
// Overwrite a built-in to add a default
Cypress.Commands.overwrite('visit', (originalFn, url, options) =>
originalFn(url, { failOnStatusCode: false, ...options }),
);
// cypress/support/index.d.ts
declare global {
namespace Cypress {
interface Chainable<Subject = any> {
getByCy(id: string, options?: Partial<Loggable & Timeoutable>): Chainable<JQuery<HTMLElement>>;
clickIfVisible(): Chainable<JQuery<HTMLElement>>;
loginAs(role: 'recruiter' | 'candidate'): Chainable<void>;
}
}
}
export {};
Q14How do you handle checkboxes, dropdowns, file uploads and special keystrokes in Cypress?
BasicInteractions
Answer
Each control has a purpose-built command and each has a gotcha. .check() and .uncheck() work only on input[type=checkbox] and input[type=radio] and will throw if you point them at a styled div or a label, which is exactly what most component libraries render; in that case click the label or the underlying input with { force: true }. .select() works only on a native select element and accepts the option text, the value, or an array for a multiple select, so a React Select or MUI Select needs a click plus cy.contains on the rendered listbox instead. .type() fires realistic keydown, keypress, input and keyup events per character and supports special sequences in braces: {enter}, {esc}, {backspace}, {selectall}, {del}, {downarrow}, {ctrl}, {shift}, and modifier combinations such as {ctrl}a. Two type() options matter: delay, which defaults to 10 ms per character and can be set to 0 to speed up long inputs, and parseSpecialCharSequences: false when you need to type a literal string containing braces, such as a JSON payload. .clear() is shorthand for .type('{selectall}{del}'). For uploads, cy.selectFile() has been built in since the 9.3 release and replaced the old cypress-file-upload plugin entirely; it accepts a fixture path, a Buffer, or an object with contents, fileName and mimeType, and it supports { action: 'drag-drop' } for dropzone components. For anything that needs true hover, native focus behaviour or real key events, Cypress synthetic events are not enough and the cypress-real-events plugin, which drives the browser through the DevTools Protocol, is the standard answer.
// Native controls
cy.get('[data-cy=terms]').check();
cy.get('[data-cy=notify]').uncheck();
cy.get('select[data-cy=city]').select('Bengaluru');
cy.get('select[data-cy=skills]').select(['React', 'Cypress']);
// Custom dropdown from a component library
cy.get('[data-cy=city-select]').click();
cy.get('[role=listbox]').contains('Bengaluru').click();
// Keystrokes and sequences
cy.get('[data-cy=search]').type('cypress sdet{enter}');
cy.get('[data-cy=bio]').type('{selectall}{del}');
cy.get('[data-cy=payload]').type('{"plan":"gold"}', { parseSpecialCharSequences: false });
cy.get('[data-cy=notes]').type('a'.repeat(500), { delay: 0 });
// Uploads
cy.get('input[type=file]').selectFile('cypress/fixtures/resume.pdf');
cy.get('[data-cy=dropzone]').selectFile(
{ contents: Cypress.Buffer.from('name,email\n'), fileName: 'bulk.csv', mimeType: 'text/csv' },
{ action: 'drag-drop' },
);
// Real hover, not a synthetic event
cy.get('[data-cy=tooltip-trigger]').realHover();
Q15When do you use cy.request() and when do you use cy.intercept()? They are not alternatives.
BasicNetwork
Answer
cy.request() makes an HTTP call from the Node process, outside the browser. It never touches the application under test, is not subject to CORS or the same-origin policy, does not appear in the app's network tab, and by default fails the test on a non-2xx status unless you pass failOnStatusCode: false. Cypress does automatically send cookies from the browser's cookie jar and writes any Set-Cookie response back into it, which is exactly what makes programmatic login work.
Use cy.request for setup and teardown (create the order you are about to view, delete the tenant you created), for API contract checks, and for logging in without driving the login form. cy.intercept() does something categorically different: it installs a route matcher in the browser's network layer so that requests the application itself makes can be observed, delayed, modified or replaced with a stub. Use it to assert that a request was fired with the right payload, to serve deterministic data so a test does not depend on a staging database, to simulate a 500 or a network error, and to wait on a specific response instead of guessing with a fixed sleep. The two are complementary and a strong answer says so explicitly: cy.request builds the world before the test, cy.intercept controls and observes what the app does inside it. One limitation to name: cy.intercept handles fetch and XHR, but it does not intercept WebSocket traffic or requests made by a service worker in the general case, so realtime features usually need a different strategy such as stubbing at the client library level.
// Setup from outside the browser: fast and deterministic
cy.request('POST', '/api/test/orders', { plan: 'gold', amount: 4999 })
.its('body.id')
.as('orderId');
// Programmatic login: cookies land in the browser jar automatically
cy.request({
method: 'POST',
url: '/api/auth/login',
body: { email: Cypress.env('email'), password: Cypress.env('password') },
failOnStatusCode: false,
}).then((res) => {
expect(res.status).to.eq(200);
});
// Observe and control what the app itself requests
cy.intercept('POST', '/api/orders').as('createOrder');
cy.get('[data-cy=place-order]').click();
cy.wait('@createOrder').then(({ request, response }) => {
expect(request.body).to.include({ amount: 4999 });
expect(response.statusCode).to.eq(201);
});
Q16How is a Cypress spec structured with Mocha, and why is it.only dangerous in CI?
BasicTest Structure
Answer
Cypress uses Mocha's BDD interface, so a spec is describe or context blocks containing it or specify tests, with before, beforeEach, after and afterEach hooks. Hooks nest: an outer beforeEach runs before an inner one, and after hooks run innermost first. Two Cypress-specific behaviours matter.
First, arrow functions break this-based fixture aliases, so if you use cy.fixture('x').as('x') and then this.x, both the hook and the test must be function() {} rather than () => {}. Second, since the 12.x release testIsolation defaults to true for e2e, so before each test Cypress clears cookies, localStorage and sessionStorage and resets the page to about:blank; state cannot leak from one test to the next inside a spec, which invalidates the older pattern of writing a long chain of dependent tests that each continue where the last one stopped. If you deliberately need that chain, you set testIsolation: false on the describe block and accept the trade-off. .only and .skip come from Mocha: describe.only or it.only runs just that block, which is invaluable while developing.
The danger is committing it. Cypress will happily run one test out of eighty, the pipeline goes green, and nobody notices that the regression suite has been silently disabled, sometimes for weeks. The standard mitigations are an ESLint rule (mocha/no-exclusive-tests or the Cypress ESLint plugin) failing the build on a committed .only, and a grep step in CI. For selecting subsets on purpose, use a tagging plugin or --spec globs rather than .only.
describe('Recruiter checkout', () => {
beforeEach(function () {
cy.fixture('recruiter.json').as('recruiter'); // function(), not arrow
cy.loginAs('recruiter');
cy.visit('/plans');
});
it('shows the gold plan price', function () {
cy.contains('[data-cy=plan-card]', 'Gold').should('contain', '4,999');
});
// Only when a chain of dependent steps is genuinely required
describe('multi-step wizard', { testIsolation: false }, () => {
it('step 1 saves company details', () => {
cy.get('[data-cy=company]').type('Acme Pvt Ltd');
cy.contains('button', 'Next').click();
});
it('step 2 continues on the same page', () => {
cy.get('[data-cy=gstin]').should('be.visible');
});
});
});
Q17How do environment values resolve in Cypress, and what is the precedence order?
BasicConfiguration
Answer
Cypress keeps two separate things: configuration (baseUrl, timeouts, viewport, retries) and environment values, which live under env and are read at runtime with Cypress.env('key'). Environment values can be supplied from four places and the precedence order, lowest to highest, is: the env block inside cypress.config.ts, then cypress.env.json at the project root, then operating system variables prefixed with CYPRESS_, then --env passed on the command line. So CYPRESS_apiUrl=https://staging-api.example.in in the pipeline overrides the committed default, and --env apiUrl=... overrides even that.
Note the prefix is stripped and the remaining key keeps its case, so CYPRESS_apiUrl becomes Cypress.env('apiUrl'). Configuration values follow a parallel path: cypress.config.ts, then CYPRESS_ prefixed variables for known config keys, then --config or --config-file on the CLI, and you can also override per suite or per test with the second argument to describe or it, or at runtime with Cypress.config('defaultCommandTimeout', 10000). The practical pattern in Indian teams running staging, pre-production and production smoke suites is to keep non-secret defaults in cypress.config.ts, keep cypress.env.json in .gitignore for local developer values, and inject secrets from the CI secret store as CYPRESS_ variables so nothing sensitive is committed. Never put a real password or record key in cypress.env.json and then commit it, because Cypress prints environment values in the run header and they end up in build logs.
// cypress.config.ts (committed defaults)
export default defineConfig({
e2e: { baseUrl: 'http://localhost:3000' },
env: { apiUrl: 'http://localhost:4000/api', otpBypass: '000000' },
});
// cypress.env.json (gitignored, local only)
// { "email": "dev@example.in", "password": "local-only" }
// In the test
cy.request(`${Cypress.env('apiUrl')}/health`).its('status').should('eq', 200);
// CI: highest precedence wins
// export CYPRESS_apiUrl=https://staging-api.example.in/api
// export CYPRESS_RECORD_KEY=$RECORD_KEY
// npx cypress run --env otpBypass=$OTP_BYPASS --config baseUrl=https://staging.example.in
// Per-suite override
describe('slow reports', { defaultCommandTimeout: 15000 }, () => { /* ... */ });
Q18What do cy.pause(), .debug() and the time-travel command log give you that a console.log cannot?
BasicDebugging
Answer
Because Cypress commands are queued and run asynchronously, a console.log placed between two commands executes before either of them touches the page, so it almost never prints what you expect. Cypress provides tools that hook into the queue instead. cy.pause() suspends the queue at that point in open mode and gives you a resume and step-forward control, so you can inspect the live DOM in DevTools with the application in exactly the state the test left it. .debug() chains onto a command, logs the current subject to the console and hits a debugger statement, so you land in DevTools with the jQuery subject available as a variable. cy.log('message') writes into the Cypress command log rather than the browser console, which is what you want when you are reading a CI artifact rather than a live console. The time-travel command log is the feature people underrate: Cypress snapshots the DOM before and after every command, so hovering any entry in the log rewinds the application to that instant, including a pinned before and after view for actions.
That is how you see the button was there but covered, rather than guessing. In run mode the snapshots are not kept, so the CI equivalents are the automatic failure screenshot, the video if you have re-enabled it (recent versions default video to off), and Test Replay if you record to Cypress Cloud, which reconstructs the DOM, console and network of the failed run in a Chromium browser after the fact. Finally, Cypress.env('DEBUG') and the DEBUG=cypress:* environment variable turn on the internal logs when the problem is in Cypress itself rather than your test.
// console.log runs before any command touches the page
// console.log(cy.get('[data-cy=total]')); // logs a Chainable, useless
// Pause the queue and inspect the live DOM (open mode)
cy.get('[data-cy=plan-card]').should('have.length', 3);
cy.pause();
cy.contains('button', 'Choose Gold').click();
// Land in DevTools with the subject in scope
cy.get('[data-cy=invoice-row]').debug();
// Write into the command log, visible in CI artifacts
cy.get('[data-cy=order-id]').invoke('text').then((id) => {
cy.log(`order under test: ${id}`);
});
// Verbose Cypress internals when the tool itself misbehaves
// DEBUG=cypress:server:* npx cypress run --browser chrome
Q19How does cy.intercept match a route, and how do you stub dynamically with req.continue and req.reply?
IntermediateNetwork
Answer
cy.intercept accepts three call shapes: (url), (method, url) and (routeMatcher, handler). The url argument is a Minimatch glob, not a regular expression, and misunderstanding that produces the most common intercept bug in real suites: '/api/jobs' does not match '/api/jobs?page=2' and does not match an absolute URL either, so cy.wait times out with 'timed out waiting 5000ms for the 1st request to the route'. Write '**/api/jobs*' or pass a RouteMatcher object with pathname, which ignores the query string entirely.
The RouteMatcher also supports method, hostname, query, headers, https, times and middleware, and every field is ANDed. The second argument decides behaviour. Omit it and the route is only observed, so the real request goes to the server and you can still assert on it.
Pass a plain object, a { statusCode, body, headers, delay, forceNetworkError } StaticResponse, or { fixture: 'jobs.json' } and the request is stubbed without leaving the browser. Pass a function and you get the request object: mutate req.headers or req.body, then either call req.continue((res) => {}) to let the real call happen and edit the response on the way back, or req.reply(...) to answer immediately. times: 1 makes a route match once, which is how you test retry logic where the first attempt fails and the second succeeds. middleware: true registers a handler that runs before other matching routes and must call req.continue. Ordering matters: when several routes match, the most recently registered one wins, so a spec-level intercept can override one declared in the support file.
// Glob, not regex: this misses /api/jobs?page=2
// cy.intercept('GET', '/api/jobs').as('jobs');
cy.intercept('GET', '**/api/jobs*').as('jobs');
// RouteMatcher object ignores the query string entirely
cy.intercept(
{ method: 'POST', pathname: '/api/orders' },
{ statusCode: 201, body: { id: 9001 } },
).as('createOrder');
// Fail only the first attempt, then let the real call through
cy.intercept('POST', '**/api/payments', { times: 1, forceNetworkError: true });
// Edit the outgoing request, then edit the real response
cy.intercept('GET', '**/api/profile', (req) => {
req.headers['x-test-run'] = Cypress.env('runId');
req.continue((res) => {
res.body.plan = 'gold';
res.setDelay(500);
});
}).as('profile');
// Middleware runs before other matching routes
cy.intercept({ url: '**/api/**', middleware: true }, (req) => {
req.on('response', (res) => {
res.headers['cache-control'] = 'no-store';
});
});
Key Points
- url strings are Minimatch globs, so add ** and * or use pathname
- No handler means observe only; the real request still goes out
- req.continue edits the real response, req.reply short-circuits it
- times limits how many requests a route handles
- The last registered matching route wins
Q20What exactly does cy.wait('@createOrder') wait for, and how do you assert on the second call to the same route?
IntermediateNetwork
Answer
cy.wait on an alias waits in two phases against two different timeouts. First it waits up to requestTimeout, 5000 ms by default, for a request matching that route to be sent. Then it waits up to responseTimeout, 30000 ms by default, for the response to arrive.
The two error messages are distinct and worth recognising: 'timed out waiting 5000ms for the 1st request to the route' means the app never fired the call, usually a glob mismatch or a button that did not actually submit, while 'timed out waiting 30000ms for the 1st response' means the request went out but nothing came back, typical of a hung staging service or an intercept handler that never calls req.reply. The command yields an Interception object with request, response, id and state, so you can assert on the exact payload the app sent, which catches whole classes of bugs that a DOM assertion misses. Waits are consumed in order: calling cy.wait('@jobs') twice matches the first and then the second request.
To wait for several routes in parallel, pass an array and the subject becomes an array of interceptions. Alias sub-properties are also queries, so cy.get('@jobs.all') yields every interception so far, which is the clean way to assert a request was not duplicated, and cy.get('@jobs.request') or '@jobs.response' pulls the latest one. The point of all this is to delete arbitrary sleeps. cy.wait(3000) is simultaneously too short on a loaded CI runner and pure waste locally, and it is the first thing a reviewer will flag.
cy.intercept('GET', '**/api/jobs*').as('jobs');
cy.visit('/jobs');
// Phase 1: requestTimeout. Phase 2: responseTimeout.
cy.wait('@jobs').its('response.statusCode').should('eq', 200);
// Second call to the same route
cy.get('[data-cy=next-page]').click();
cy.wait('@jobs').its('request.url').should('include', 'page=2');
// Several routes at once: subject is an array
cy.wait(['@jobs', '@filters']).then(([jobs, filters]) => {
expect(jobs.response.body.results).to.have.length(20);
expect(filters.response.statusCode).to.eq(200);
});
// Assert a call was not fired twice on double click
cy.get('@jobs.all').should('have.length', 2);
cy.get('@jobs.request').its('headers').should('have.property', 'authorization');
// Antipattern reviewers reject on sight
// cy.wait(3000);
Q21How does cy.session cache a login, and what is the validate function actually protecting you from?
IntermediateAuthentication
Answer
cy.session(id, setup, options) is the supported way to reuse authentication under testIsolation. The id is the cache key and may be a string or any serialisable value, so ['user', role, tenant] keeps separate sessions for a recruiter and a candidate without collisions. The first time an id is seen, Cypress clears all existing session data, runs your setup callback, then snapshots cookies, localStorage and sessionStorage across all origins.
Every later call with the same id skips setup entirely and restores that snapshot, which turns a two second login into roughly fifty milliseconds. By default the cache lives for the current spec only; cacheAcrossSpecs: true keeps it for the whole run, which is what you want in CI where a suite may have forty specs that all need the same recruiter. Two behaviours catch people out.
First, after cy.session the browser is left on a blank page, deliberately, so you must call cy.visit yourself afterwards; forgetting this produces confusing 'cannot read property of null' errors. Second, a restored session can be stale: the JWT in localStorage may have expired, or a teardown step may have deleted the user. That is precisely what validate is for.
It runs after the restore and, if it throws, fails an assertion, or yields false, Cypress discards the cache and re-runs setup once. A validate that hits a real endpoint, cy.request('/api/me') asserting a 200, is far stronger than one that merely checks a cookie exists, because it verifies the server still accepts the credential rather than that a string is present.
Cypress.Commands.add('loginAs', (role) => {
cy.session(
['user', role],
() => {
cy.request('POST', '/api/auth/login', Cypress.env('users')[role])
.its('body.token')
.then((token) => {
window.localStorage.setItem('auth_token', token);
});
},
{
cacheAcrossSpecs: true,
validate() {
// Proves the server still accepts the credential
cy.request({ url: '/api/me', failOnStatusCode: false })
.its('status')
.should('eq', 200);
},
},
);
});
beforeEach(() => {
cy.loginAs('recruiter');
cy.visit('/dashboard'); // cy.session leaves you on about:blank
});
Key Points
- The id is the cache key: include role and tenant to avoid collisions
- Setup runs once, then cookies and storage are restored from a snapshot
- cacheAcrossSpecs: true is the difference between per-spec and per-run reuse
- validate should call a real authenticated endpoint, not just check a cookie
- You must cy.visit after cy.session; the page is left blank
Q22What did testIsolation: true change in the 12.x line, and what replaced Cypress.Cookies.preserveOnce?
IntermediateTest Isolation
Answer
Before the 12.x line, cookies survived from one test to the next inside a spec, and teams exploited that: log in once in the first it block, then write a chain of tests that each continued where the previous one stopped. Cypress.Cookies.preserveOnce('session_id') and Cypress.Cookies.defaults({ preserve: [...] }) existed to keep specific cookies through the automatic clearing. Both APIs are removed.
With testIsolation defaulting to true for end-to-end testing, Cypress now runs a full reset before each test: it clears cookies for all domains, clears localStorage and sessionStorage for all origins, and navigates the application frame to about:blank. The practical consequences are concrete. Every test must start by establishing its own state, so the first command is almost always cy.visit or a cy.session restore.
Tests that depended on ordering fail immediately after an upgrade, which is the single biggest source of 'our suite broke when we moved from Cypress 9 to 13' stories. The supported replacement for cross-test login is cy.session, which is fast enough that the old ordering hack is no longer worth defending. You can opt out with testIsolation: false as a suite-level option on a describe block, and that is reasonable for a genuinely sequential multi-step wizard where re-entering four screens per assertion would triple the runtime, but it should be a deliberate, commented exception rather than a project default.
Component testing does not run test isolation at all, since there is no page navigation to reset. For explicit control there are cy.clearAllCookies, cy.clearAllLocalStorage, cy.clearAllSessionStorage and the matching getAll variants for debugging what survived.
// Removed in the 12.x line: these no longer exist
// Cypress.Cookies.preserveOnce('session_id');
// Cypress.Cookies.defaults({ preserve: ['session_id'] });
export default defineConfig({
e2e: { testIsolation: true }, // default
});
// The supported way to keep a login across tests
beforeEach(() => {
cy.loginAs('recruiter');
cy.visit('/dashboard');
});
// Deliberate, commented exception for a sequential flow
describe('KYC wizard', { testIsolation: false }, () => {
it('step 1 saves PAN', () => { /* ... */ });
it('step 2 continues on the same page', () => { /* ... */ });
});
// Explicit control when you need it
cy.clearAllCookies();
cy.clearAllLocalStorage();
cy.getAllLocalStorage().then((all) => cy.log(JSON.stringify(all)));
Q23How do you log in without driving the UI, and how do you automate a phone plus OTP login?
IntermediateAuthentication
Answer
Driving the login form in a beforeEach is the classic reason a suite that should take four minutes takes twenty five. The rule is: exactly one test drives the login UI, because the form itself is a feature worth covering, and every other test authenticates programmatically. How you do that depends on where the credential lives.
If the backend returns a JWT that the app reads from localStorage, cy.request the login endpoint, take body.token and write it into localStorage, then visit. If the backend sets an httpOnly cookie, you do not need to do anything extra: cy.request automatically sends the browser's cookies and writes any Set-Cookie response back into the jar, so the next cy.visit is already authenticated. Wrap either approach in cy.session so it is cached.
Phone plus OTP, which is how most Indian consumer products log people in, needs one more hop because the code arrives out of band. Do not automate the SMS provider. The workable options, in order of preference: a test-only bypass code enabled by an environment flag on non-production environments, a cy.task that reads the generated OTP straight from Redis or the database for a known test number, or an internal endpoint that returns the last OTP for whitelisted test numbers only.
All three must be impossible to enable in production, which usually means gating on NODE_ENV plus a secret that only CI holds. Whichever you pick, keep the credentials in CYPRESS_ prefixed environment variables injected from the pipeline secret store, and pass { log: false } to .type when typing a password so it does not land in a video or a Cloud run.
// One test proves the real form works
it('logs in through the OTP form', () => {
cy.visit('/login');
cy.get('[data-cy=phone]').type('9800000001');
cy.contains('button', 'Send OTP').click();
cy.task('otp:latest', { phone: '9800000001' }).then((otp) => {
cy.get('[data-cy=otp]').type(String(otp));
});
cy.contains('button', 'Verify').click();
cy.location('pathname').should('eq', '/dashboard');
});
// Everything else authenticates through the API
Cypress.Commands.add('loginByApi', (phone) => {
cy.session(phone, () => {
cy.request('POST', '/api/auth/otp/send', { phone });
cy.task('otp:latest', { phone }).then((otp) => {
cy.request('POST', '/api/auth/otp/verify', { phone, otp })
.its('body.token')
.then((t) => window.localStorage.setItem('auth_token', t));
});
});
});
Q24When do you need cy.origin, and what are the rules about what can go inside its callback?
IntermediateCross-origin
Answer
A Cypress test is confined to a single superdomain. The moment the application redirects somewhere else, typically accounts.google.com, login.microsoftonline.com, an Okta tenant, or a payment gateway page, the test fails with 'Cypress detected a cross origin error'. cy.origin(url, callback) is the supported escape: Cypress spins up a separate browser context bound to that origin and runs your callback there, then hands control back when you return to your own domain. The restriction that trips everyone up is serialisation.
The callback function is stringified and shipped across, so it cannot close over anything from the enclosing spec: no imported helpers, no describe-level constants, no variables captured from an outer scope. Data must be passed explicitly through the options object as { args: {...} } and args must be JSON serialisable, so no functions, DOM nodes or class instances. Custom commands registered in the support file are also unavailable inside unless you re-import them with Cypress.require inside the callback.
Assertions and standard cy commands work normally. Two further notes belong in a strong answer. First, cy.origin composes with cy.session, so you can pay the full SSO cost once per run instead of once per test, which matters because these redirects are slow. Second, the cheaper strategy is often to avoid the redirect altogether: many identity providers expose a token endpoint you can hit with cy.request, and if you can mint a valid session that way, you get a faster and far less brittle test with the SSO click path covered by a single dedicated spec.
cy.visit('/login');
cy.contains('button', 'Continue with Google').click();
cy.origin(
'https://accounts.google.com',
{ args: { email: Cypress.env('ssoEmail'), password: Cypress.env('ssoPass') } },
({ email, password }) => {
// No closure over spec scope: everything arrives via args
cy.get('input[type=email]').type(email);
cy.contains('button', 'Next').click();
cy.get('input[type=password]').type(password, { log: false });
cy.contains('button', 'Next').click();
},
);
// Back on our own superdomain
cy.location('pathname').should('eq', '/dashboard');
// Custom commands are not inherited across the boundary
cy.origin('https://accounts.google.com', () => {
Cypress.require('../support/commands');
cy.getByCy('sso-submit').click();
});
Q25What causes 'cy.click() failed because this element is detached from the DOM', and how do you fix it properly?
IntermediateFailure Modes
Answer
Cypress resolved a query to a specific DOM node, then the framework re-rendered and replaced that node with a new one before the action fired. The jQuery reference Cypress is holding now points at an element that is no longer in the document, so it refuses to click it. React, Vue and Angular all produce this whenever a state update swaps a subtree: a fetch resolving, a polling interval ticking, a virtualised list recycling rows, a Suspense boundary settling, or a form library re-mounting fields on validation.
The 12.x query rewrite fixed a large slice of the problem, because a contiguous chain of queries such as cy.get('table').find('td').first() is now re-run as a group rather than only the last link. What it cannot fix is code that deliberately freezes a reference: capturing an element in .then and later calling cy.wrap($el).click(), or looping with .each and performing an action that causes the list to re-render on every iteration. The durable fixes are all about removing the race rather than retrying harder.
Re-query with cy.get immediately before the action instead of reusing a stored subject. Wait on the thing that causes the re-render, usually cy.wait('@alias') for the request whose response repaints the list, or an assertion that the skeleton loader no longer exists. When you must iterate over rows and act on them, collect stable identifiers first, then loop over those identifiers and re-query each row by attribute. Retrying blindly with a plugin or a longer timeout hides the ordering bug and it will come back in CI under load.
// BAD: the reference is frozen before the list re-renders
// cy.get('[data-cy=job-row]').first().then(($row) => {
// cy.get('[data-cy=refresh]').click();
// cy.wrap($row).find('[data-cy=apply]').click(); // detached
// });
// GOOD 1: wait for the cause of the re-render, then re-query
cy.intercept('GET', '**/api/jobs*').as('jobs');
cy.get('[data-cy=refresh]').click();
cy.wait('@jobs');
cy.get('[data-cy=job-row]').first().find('[data-cy=apply]').click();
// GOOD 2: collect stable ids, then re-query each one
cy.get('[data-cy=job-row]')
.then(($rows) => [...$rows].map((r) => r.dataset.jobId))
.each((id) => {
cy.get(`[data-job-id="${id}"] [data-cy=shortlist]`).click();
cy.wait('@shortlist');
});
// GOOD 3: gate on the loading state disappearing
cy.get('[data-cy=skeleton]').should('not.exist');
Q26Walk through the actionability checks before .click(), and when is { force: true } legitimate?
IntermediateFailure Modes
Answer
Before firing any action command, Cypress runs a fixed sequence of checks and keeps retrying them until they all pass or the timeout expires. It confirms the element is not hidden, where hidden means display none, visibility hidden or collapse, zero width or height, an ancestor with any of those, or being clipped out of an overflow hidden container. It scrolls the element into view, honouring the scrollBehavior config which defaults to 'top'.
It confirms the element is not disabled, not detached, and not readonly for typing. It waits for animations to settle, controlled by waitForAnimations and animationDistanceThreshold. Finally it calculates the action coordinates, by default the centre of the element, and performs a hit test with document.elementFromPoint.
If a different element occupies that pixel you get 'cy.click() failed because this element is being covered by another element' and Cypress prints the covering node, which is usually the actual bug: a sticky header, a cookie or app-install banner, a toast notification that has not dismissed, an MUI or Radix backdrop that is still fading, or a transparent overlay div. Only after all checks pass does Cypress dispatch the events. { force: true } skips every one of those checks and dispatches the event directly on the element without scrolling or hit testing. That is why it is dangerous: a real user could not have clicked the element, so a forced test can pass against a broken UI.
Legitimate uses are narrow: a genuinely hidden file input behind a styled label, an element inside a virtualised container that Cypress cannot scroll correctly, or a decorative overlay you have confirmed is not a real blocker. Otherwise dismiss the banner, close the toast, or fix the z-index.
// The covering element is usually the real finding
// CypressError: cy.click() failed because this element is being covered
// by another element: <div class="sticky-header">...</div>
// Fix the cause: dismiss the blocker
cy.get('[data-cy=cookie-banner] button').click();
cy.get('[data-cy=toast]').should('not.exist');
cy.contains('button', 'Pay now').click();
// Or scroll so the sticky footer is not over the target
cy.get('[data-cy=submit]').scrollIntoView({ offset: { top: -120, left: 0 } }).click();
// Project-wide: land the element below a fixed header
export default defineConfig({
e2e: { scrollBehavior: 'center', waitForAnimations: true },
});
// Narrow, justified force: a visually hidden file input
cy.get('input[type=file]').selectFile('cypress/fixtures/resume.pdf', { force: true });
// Click a precise corner instead of the centre
cy.get('[data-cy=canvas]').click('topLeft');
cy.get('[data-cy=canvas]').click(220, 140);
Key Points
- Checks: not hidden, scrolled into view, enabled, not detached, animations settled, hit test
- The covering element named in the error is usually the actual bug
- force: true skips scrolling and hit testing, so it can pass on a broken UI
- scrollBehavior and scrollIntoView offsets fix sticky header collisions
- Coordinate arguments like click('topLeft') avoid centre-point collisions
Q27How do cy.clock and cy.tick control time, and what do they not control?
IntermediateTimers
Answer
cy.clock installs Sinon fake timers into the application window, replacing Date, setTimeout, setInterval, clearTimeout, clearInterval and, when you ask for them, performance.now and requestAnimationFrame. From that point the clock is frozen and only advances when you call cy.tick(ms), which flushes every timer scheduled inside that window synchronously. This turns a category of slow or impossible tests into fast deterministic ones: a 500 ms debounced search box, a 30 second polling interval on an interview status page, a session expiry countdown, a token refresh at minute fifty five, or relative timestamps like 'posted 5 minutes ago' that would otherwise change between runs. cy.clock(new Date('2026-04-01T09:00:00+05:30').getTime()) freezes the date itself, which is how you keep a financial-year date picker or an invoice screenshot stable.
Timing of the call is critical. cy.clock must be installed before the application schedules the timers you want to control, so call it before cy.visit, or from inside onBeforeLoad if you need it applied to a specific navigation; a clock installed afterwards leaves already scheduled intervals running on the real timer. Scope is the other half of the answer: the fake clock lives in the browser window only. It does not affect the server, so a backend that expires a token after fifteen real minutes still does, it does not control CSS transitions or animations, and it does not reach into a web worker, a service worker or a different frame. You can also restrict what gets faked with cy.clock(now, ['Date', 'setTimeout']) when replacing everything breaks a third party library, and restore the real clock with cy.clock().invoke('restore').
// Freeze the date before the app boots
cy.clock(new Date('2026-04-01T09:00:00+05:30').getTime());
cy.visit('/invoices');
cy.get('[data-cy=fy-label]').should('have.text', 'FY 2026-27');
// Debounced search: no arbitrary sleep needed
cy.intercept('GET', '**/api/search*').as('search');
cy.get('[data-cy=search]').type('react developer');
cy.tick(499);
cy.get('@search.all').should('have.length', 0);
cy.tick(1);
cy.wait('@search');
// Jump a 30s polling interval forward five ticks
cy.tick(30000 * 5);
cy.get('[data-cy=interview-status]').should('have.text', 'Completed');
// Only fake what you need, then restore
cy.clock(Date.now(), ['Date', 'setInterval']);
cy.clock().invoke('restore');
Q28What can cy.task do that no browser command can, and how do you register one without leaking connections?
IntermediateNode Integration
Answer
cy.task(event, arg) is the bridge from the browser, where your test runs, to the Node process that evaluated setupNodeEvents. The handler you register receives the argument, does anything Node can do, and must return a value or a promise; returning undefined throws 'The task handler was registered but returned undefined'. Both the argument and the return value cross a process boundary, so they must be serialisable.
That makes cy.task the answer to a whole family of interview questions about things Cypress supposedly cannot do: seed and clean a MySQL or Mongo database directly instead of clicking through setup screens, read the last OTP or a password reset token from Redis, mint a signed JWT with a private key that must never reach the browser, list the contents of the downloads folder, call a cloud provider API, or simply print to the terminal, since browser console output does not appear in a headless run. The timeout is taskTimeout, 60000 ms by default, which is generous because seeding can be slow. Two operational details separate a candidate who has run this in CI from one who has read the docs.
First, setupNodeEvents is evaluated once per spec file in its own Node process, so module level caches are not shared between specs and a connection pool opened at module scope is opened again for every spec; close it on the after:run or after:spec event, or you will exhaust database connections halfway through a parallel run. Second, tasks execute with full filesystem and network privileges, so credentials belong in environment variables read at runtime, never hard-coded next to the handler and committed.
// cypress.config.ts
import { defineConfig } from 'cypress';
import mysql from 'mysql2/promise';
let pool: mysql.Pool | null = null;
const getPool = () =>
(pool ??= mysql.createPool(process.env.TEST_DB_URL as string));
export default defineConfig({
taskTimeout: 60000,
e2e: {
setupNodeEvents(on) {
on('task', {
async 'db:seedRecruiter'({ email, plan }) {
const [res] = await getPool().execute(
'INSERT INTO recruiter (email, plan) VALUES (?, ?)',
[email, plan],
);
return (res as any).insertId; // never return undefined
},
async 'otp:latest'({ phone }) {
const [rows] = await getPool().execute(
'SELECT code FROM otp WHERE phone = ? ORDER BY id DESC LIMIT 1',
[phone],
);
return (rows as any[])[0]?.code ?? null;
},
log(message) {
console.log(message); // visible in the CI terminal
return null;
},
});
on('after:run', async () => {
await pool?.end(); // otherwise connections leak per spec
});
},
},
});
Q29What does Cypress component testing give you over Jest or Vitest with jsdom, and what does cy.mount do?
IntermediateComponent Testing
Answer
Component testing mounts a single component in a real browser instead of a simulated DOM. Cypress starts your project's own dev server, declared as component.devServer with a framework and bundler pair such as { framework: 'react', bundler: 'vite' } or 'next' or 'angular', compiles the spec through that same pipeline, and renders the component inside the standard Cypress harness. Because it is a real browser, real CSS applies: layout, media queries, computed visibility, focus rings, scroll containers and CSS transitions all behave as they will in production, which is exactly what jsdom cannot do.
Assertions like should('be.visible') therefore mean something, and Cypress actionability, time travel and the command log all work. cy.mount is not built in; you import the adapter from cypress/react, cypress/vue or cypress/angular and register it in cypress/support/component.ts. In practice nobody registers the bare adapter, because a real component needs providers: a Redux or Zustand store, a React Query client, a theme provider, an i18n provider and often a router. The idiomatic move is a custom mount that wraps children in those providers and accepts per-test overrides.
Use component tests for design system pieces, form validation branches, loading and empty and error states that are painful to reach through the full app, and for props-level contracts using cy.stub().as('onSubmit'). Do not use them for routing, authentication or anything spanning pages, which belongs in end to end. Two gotchas: testIsolation does not apply to component testing, and if your dev server config omits the path aliases the app uses, specs fail with module resolution errors that look nothing like a test bug.
// cypress/support/component.tsx
import { mount } from 'cypress/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../../src/theme';
Cypress.Commands.add('mount', (node, options = {}) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return mount(
<QueryClientProvider client={client}>
<ThemeProvider>{node}</ThemeProvider>
</QueryClientProvider>,
options,
);
});
// src/components/SalaryInput.cy.tsx
it('rejects a salary below the minimum', () => {
const onChange = cy.stub().as('onChange');
cy.mount(<SalaryInput min={300000} onChange={onChange} />);
cy.get('input').type('120000').blur();
cy.contains('Minimum is 3,00,000').should('be.visible');
cy.get('@onChange').should('not.have.been.called');
});
Q30How do you test that a CSV export or invoice PDF actually downloaded?
IntermediateFile Handling
Answer
Downloads land in the folder named by the downloadsFolder config key, cypress/downloads by default, and Cypress configures Chrome and Electron so no save dialog appears. The folder is emptied before each spec in run mode, so a test can assume it starts clean. The basic verification is cy.readFile with a generous timeout, because the file appears asynchronously and cy.readFile retries until it exists: cy.readFile('cypress/downloads/invoices.csv', { timeout: 15000 }).
For text formats you can then assert on the contents directly, checking the header row and a known record. For binary formats read with 'binary' or 'base64' encoding and assert on size and magic bytes, or push real parsing into Node with cy.task, which is where a pdf-parse or xlsx dependency belongs since it cannot run in the browser. Several practical wrinkles come up.
A download opened with target=_blank will try to open a new tab, which Cypress cannot follow, so .invoke('removeAttr', 'target') before clicking. A download generated client side from a Blob may never touch the filesystem in Electron, in which case stub the URL.createObjectURL call or assert on the intercepted API response instead. Browsers differ: Firefox historically needed extra preferences and Electron behaves differently from Chrome, so pin the browser your pipeline uses.
Often the fastest and most stable test skips the download entirely: read the href from the anchor, cy.request it, and assert on the content-disposition header, content-type and body. That covers the contract the user cares about without depending on browser download plumbing.
// Config
export default defineConfig({ downloadsFolder: 'cypress/downloads' });
// 1. Trigger and read the file
cy.get('[data-cy=export-invoices]').invoke('removeAttr', 'target').click();
cy.readFile('cypress/downloads/invoices.csv', { timeout: 20000 })
.should('contain', 'invoice_id,amount,gstin')
.and('contain', 'INV-2026-0042');
// 2. Push binary parsing into Node
cy.task('pdf:text', 'cypress/downloads/invoice-42.pdf').then((text) => {
expect(text).to.include('Tax Invoice');
expect(text).to.match(/GSTIN: [0-9A-Z]{15}/);
});
// 3. Skip the browser plumbing entirely
cy.get('[data-cy=export-invoices]')
.should('have.attr', 'href')
.then((href) => cy.request(href))
.then((res) => {
expect(res.headers['content-type']).to.include('text/csv');
expect(res.headers['content-disposition']).to.include('attachment');
});
Q31How do Cypress test retries work, and why can enabling them make quality worse?
IntermediateFlake Management
Answer
Retries are configured with retries: { runMode: 2, openMode: 0 }, meaning a failing test in a headless run gets two extra attempts while a developer in open mode sees the failure immediately. You can also override per suite or per test with a second options argument, which is the honest way to mark one genuinely unstable spec instead of raising the number globally. On retry Cypress re-runs the test body along with its beforeEach and afterEach hooks, so the test starts from a clean state rather than resuming mid-flow; a failure in a before hook is a different animal because it takes down the rest of the suite.
A test that fails then passes is reported as flaky rather than failed, and the run still exits zero. That last detail is the trap. Retries were designed to keep a pipeline moving while you fix the underlying race, but most teams treat the green build as the end of the story.
The race is still there, it is still costing developers minutes of confusion, and as the app grows it eventually fails twice in a row and lands as a mystery outage in the pipeline. The discipline that separates a mature suite is treating a flaky result as a defect with a ticket, not as a pass. Cypress Cloud surfaces this directly with flake analytics and burn-in for newly added tests, and without Cloud you can parse the JSON or JUnit output for tests whose attempt count is greater than one and fail the build above a threshold. Test Replay, available on recorded runs, is the other half: it reconstructs the DOM, console, network and command log of the failed attempt so you can debug a CI-only failure without adding console logs and pushing again.
// Global policy
export default defineConfig({
retries: { runMode: 2, openMode: 0 },
video: false, // Test Replay covers most of what videos were used for
});
// Honest, scoped exception with a ticket reference
describe('payment gateway redirect', { retries: { runMode: 4 } }, () => {
// TODO(QA-812): gateway sandbox times out ~1 in 20 runs
});
// Per test
it('uploads a 20MB resume', { retries: 3 }, () => { /* ... */ });
// Surface flake instead of hiding it
afterEach(function () {
const attempts = (this.currentTest as any)?.currentRetry?.() ?? 0;
if (attempts > 0) {
cy.task('log', `FLAKY: ${this.currentTest?.fullTitle()} passed on attempt ${attempts + 1}`);
}
});
Q32How do you interact with content inside an iframe, and why do payment gateway iframes usually resist it?
IntermediateCross-origin
Answer
cy.get cannot cross a frame boundary, because jQuery queries the top document only. The working pattern is to get the iframe element, reach into its content document through .its('0.contentDocument.body'), assert that the body is populated so the retry loop waits for the frame to load, and then cy.wrap the body so it becomes a normal Cypress subject you can chain .find and actions on. That is three or four lines you never want repeated, so teams wrap it in a custom command, and the cypress-iframe plugin packages the same idea as cy.frameLoaded and cy.iframe.
The important caveat is the browser security model, not Cypress. If the iframe is same-origin, everything above works. If it is cross-origin, which is exactly the case for a Razorpay, Stripe or PayU card frame, an embedded YouTube player or a third party chat widget, the browser refuses access to contentDocument and there is nothing Cypress can do about it from the parent frame. chromeWebSecurity: false relaxes some of this in Chromium family browsers only, but it is a blunt setting that changes how the whole run behaves and it does not cover every case.
For payment flows the realistic strategy is layered: stub the gateway with cy.intercept and drive your own success and failure callbacks so the checkout logic is fully covered, cover the gateway's own card form either with the provider's test mode in a dedicated smoke test or manually, and use cy.origin if the provider redirects to a full page rather than embedding a frame. Interviewers at fintech and marketplace companies in India ask this specifically because their checkout is almost always an embedded gateway frame.
// Reusable command for same-origin frames
Cypress.Commands.add('getIframeBody', (selector: string) =>
cy
.get(selector)
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap),
);
cy.getIframeBody('iframe[data-cy=terms-doc]')
.find('[data-cy=accept]')
.click();
// Cross-origin gateway frame: stub the gateway instead
cy.intercept('POST', '**/api/payments/order', {
statusCode: 200,
body: { orderId: 'order_TEST123', keyId: 'rzp_test_xxx' },
}).as('createPaymentOrder');
cy.window().then((win) => {
cy.stub(win as any, 'Razorpay').callsFake((opts: any) => ({
open: () => opts.handler({ razorpay_payment_id: 'pay_TEST123' }),
}));
});
cy.get('[data-cy=pay-now]').click();
cy.wait('@createPaymentOrder');
cy.contains('Payment successful').should('be.visible');
Q33Do you use the Page Object Model with Cypress, and what is the App Actions alternative?
IntermediateTest Architecture
Answer
The Page Object Model earns its keep in Selenium because selectors are expensive to maintain and every interaction needs explicit waits, so centralising both in a class removes real duplication. Cypress removes both of those problems: retry-ability handles the waiting, and a data-cy attribute is stable enough that a selector rarely changes. What is left is often a class whose methods wrap one command each, add a layer of indirection to every failure trace, and encourage returning this to chain, which fights the command queue rather than using it.
The Cypress team's recommended alternative is App Actions: set up state by talking to the application or its API directly, and drive the UI only for the behaviour the test is actually about. Creating an order for a test that verifies the order detail page should be a cy.request or a cy.task, not eleven clicks through a wizard that some other test already covers. That makes tests shorter, faster and independent, and when the wizard changes only the wizard's own spec breaks.
In real Indian services and product teams the pragmatic answer is a blend, and saying so is usually what the interviewer wants. Keep a small selectors or locators module per page so the attribute names live in one place, use custom commands for genuinely cross-cutting actions such as loginAs or seedJob, use app actions for all setup, and write the assertions in the spec itself. The two rules that hold regardless of pattern: never put assertions inside a page object or a shared command, because failures then point at the wrong owner, and never let a helper hide which page the test is on.
// App action: set up state through the API, test only what matters
it('shows the shortlisted count on the job detail page', () => {
cy.task('db:seedJob', { title: 'SDET II', shortlisted: 7 }).then((jobId) => {
cy.loginAs('recruiter');
cy.visit(`/jobs/${jobId}`);
cy.get('[data-cy=shortlisted-count]').should('have.text', '7');
});
});
// Thin locator module instead of a class hierarchy
export const jobForm = {
title: () => cy.get('[data-cy=job-title]'),
city: () => cy.get('[data-cy=job-city]'),
submit: () => cy.contains('button', 'Publish job'),
};
// The one spec that genuinely exercises the wizard UI
it('publishes a job through the form', () => {
cy.loginAs('recruiter');
cy.visit('/jobs/new');
jobForm.title().type('SDET II');
jobForm.city().type('Pune');
jobForm.submit().click();
cy.location('pathname').should('match', /^\/jobs\/\d+$/);
});
Q34How do you run Cypress reliably in Docker and GitHub Actions?
IntermediateCI/CD
Answer
Three things decide whether a Cypress pipeline is fast and stable: the image, the caches, and the startup ordering. For the image, use the official cypress/browsers or cypress/included tags pinned to an explicit version rather than latest, so a browser upgrade never lands unannounced in your pipeline; cypress/included ships the binary and browsers together, which suits a plain docker run, while cypress/browsers plus npm ci suits a repo that manages its own dependencies. For caches, remember that the Cypress binary does not live in node_modules; it is unpacked into ~/.cache/Cypress.
Cache that path alongside npm's cache keyed on the lockfile, or every job downloads a few hundred megabytes and you pay for it on every push. The cypress-io/github-action wraps install, caching, server start and wait-on correctly, and using it is usually better than hand-rolling. For ordering, never start the run until the app answers: pass start and wait-on to the action, or use a wait-on step, otherwise you get 'Cypress could not verify that this server is running' intermittently on slower runners.
Two container specifics come up constantly. Docker's default 64 MB /dev/shm makes Chrome crash under memory pressure, so pass --shm-size=2g or --ipc=host. And screenshots and videos are only useful if you upload them, so add an artifact upload step with if: failure(). Secrets go in as CYPRESS_ prefixed environment variables from the repository or organisation secret store, never in cypress.env.json, because Cypress prints resolved environment values in the run header and that header ends up in the public build log.
# .github/workflows/e2e.yml
jobs:
e2e:
runs-on: ubuntu-latest
container:
image: cypress/browsers:node-22.11.0-chrome-131.0.6778.85-1-ff-133.0-edge-131.0.2903.70-1
options: --shm-size=2g
strategy:
fail-fast: false
matrix:
containers: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm run start:test
wait-on: 'http://localhost:3000/healthz'
wait-on-timeout: 180
browser: chrome
record: true
parallel: true
group: e2e-chrome
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
CYPRESS_apiUrl: ${{ secrets.STAGING_API_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots-${{ matrix.containers }}
path: cypress/screenshots
Q35How does --parallel actually distribute specs, and how do you parallelise without Cypress Cloud?
IntermediateCI/CD
Answer
The --parallel flag does nothing on its own. It requires --record with a record key, because the orchestration happens in Cypress Cloud: each machine registers against a shared --ci-build-id, then asks the Cloud service for the next spec to run, and the service hands out specs ordered by their historical duration so the slowest ones start first. That is why the balancing is good, and it is also why the build id must be identical across every machine in the run and unique per run; reusing a build id produces the 'you passed the --parallel flag but this run already exists' error, and letting each machine compute its own id silently gives you four full copies of the suite.
Without Cloud you can still shard, you just lose duration awareness. The common approaches are a CI matrix where each job runs --spec with its own glob, or a plugin such as cypress-split that divides the discovered spec list by index and total, optionally reading a timings JSON you commit so the shards stay balanced. The constraint everyone hits is that distribution is per spec file, never per test, so a single spec containing forty tests and taking nine minutes sets the floor for the whole run no matter how many machines you add.
Breaking monolithic specs into focused files is usually the highest leverage change available. The second constraint is data: parallel workers hitting one staging database will collide on unique constraints unless every worker generates its own data, keyed on a timestamp plus the worker index, or gets its own tenant. Add --auto-cancel-after-failures so a fundamentally broken build stops burning machine minutes.
# Cloud-orchestrated, load balanced by historical duration
npx cypress run --record --key $CYPRESS_RECORD_KEY \
--parallel --ci-build-id $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT \
--group e2e-chrome --browser chrome \
--auto-cancel-after-failures 5
# No Cloud: shard by index with cypress-split
# SPLIT and SPLIT_INDEX come from the CI matrix
SPLIT=4 SPLIT_INDEX=$MATRIX_INDEX npx cypress run --browser chrome
// cypress.config.ts
import cypressSplit from 'cypress-split';
export default defineConfig({
e2e: {
setupNodeEvents(on, config) {
cypressSplit(on, config); // reads SPLIT / SPLIT_INDEX
return config;
},
},
});
// Collision-proof data for parallel workers
const worker = Cypress.env('SPLIT_INDEX') ?? '0';
const email = `qa.w${worker}.${Date.now()}@example.in`;
Key Points
- --parallel is inert without --record and a Cloud record key
- --ci-build-id must be identical across machines and unique per run
- Distribution is per spec file, so one huge spec caps your speedup
- cypress-split or matrix globs shard without Cloud, minus duration balancing
- Parallel workers need per-worker unique data or they collide on unique keys
Q36How do you get JUnit XML and a merged HTML report out of a Cypress run?
IntermediateReporting
Answer
Cypress uses Mocha's reporter interface, so --reporter and --reporter-options work as they do in Mocha, and you can set them in cypress.config.ts instead of on the command line. The detail that catches everybody the first time is that cypress run creates a fresh reporter context per spec file, so a single output path is overwritten by every spec and you end up with one file describing the last spec only. The fix is the [hash] token in the output filename, which gives each spec its own file, followed by a merge step.
For JUnit, that means mochaFile: 'results/junit-[hash].xml' plus junit-report-merger or a CI step that globs the directory; GitHub Actions and Jenkins both consume the merged XML for their native test UI. For a human readable report, mochawesome writes a JSON per spec, mochawesome-merge combines them and the marge CLI renders HTML; cypress-mochawesome-reporter packages that flow and also embeds failure screenshots, which is what most teams actually want in a build artifact. To emit both at once, cypress-multi-reporters takes a reporterEnabled list and per-reporter options, usually kept in a small reporter-config.json.
Keep two things in mind operationally. Reports are only useful if they survive the job, so upload the results directory as an artifact with if: always(), not just on failure. And if the organisation already pays for Cypress Cloud, most of this apparatus is redundant, because the Cloud run page plus Test Replay covers reporting, history and flake analytics better than a static HTML file; the reporters matter most for teams running self hosted pipelines with no Cloud subscription, which is a very common setup in Indian services companies.
// cypress.config.ts
export default defineConfig({
reporter: 'cypress-multi-reporters',
reporterOptions: { configFile: 'reporter-config.json' },
});
// reporter-config.json
// {
// "reporterEnabled": "mochawesome, mocha-junit-reporter",
// "mochawesomeReporterOptions": {
// "reportDir": "results/mochawesome",
// "overwrite": false,
// "html": false,
// "json": true
// },
// "mochaJunitReporterReporterOptions": {
// "mochaFile": "results/junit/results-[hash].xml",
// "toConsole": false
// }
// }
# Merge after the run, always, so failures still produce a report
npx cypress run --browser chrome || true
npx mochawesome-merge 'results/mochawesome/*.json' > results/merged.json
npx marge results/merged.json --reportDir results/html --inline
npx jrm results/junit/combined.xml 'results/junit/results-*.xml'
Q37A long spec fails in CI with 'the Chromium renderer process just crashed'. How do you diagnose and fix it?
AdvancedPerformance
Answer
That message means the browser tab died, almost always from memory exhaustion, and Cypress reports it, kills the spec and moves to the next one. The reason Cypress is unusually exposed here is its own debugging feature: it stores DOM snapshots for every command of every test so time travel works, governed by numTestsKeptInMemory, which defaults to 50. A spec with sixty tests against a heavy DOM can therefore hold hundreds of megabytes of serialised DOM before a single leak in the application is involved.
Diagnose in this order. Check whether it is one spec or all of them; a single offender points at spec size or a huge fixture, while everything crashing points at the runner. Check the container memory limit and whether Docker's default 64 MB /dev/shm is in play, since Chrome shares memory through it and a shared memory exhaustion looks exactly like an out of memory crash.
Watch a run with the browser's own task manager or with docker stats to see whether the growth is per test or per command. The fixes, roughly in order of payoff: set experimentalMemoryManagement: true, which changes how Cypress manages the renderer's heap in run mode, and lower numTestsKeptInMemory to 0 or 1 for CI while leaving the default locally where you actually use time travel. Pass --shm-size=2g or --ipc=host to the container, or add --disable-dev-shm-usage through the before:browser:launch event.
Split specs so no file runs more than a few minutes. Trim oversized fixtures, since a 40 MB JSON fixture is held in memory for the whole spec. Raise the runner size only after those, because a bigger machine just delays the same crash.
// cypress.config.ts
export default defineConfig({
experimentalMemoryManagement: true,
numTestsKeptInMemory: Cypress.env?.('CI') ? 0 : 50,
e2e: {
setupNodeEvents(on) {
on('before:browser:launch', (browser, launchOptions) => {
if (browser.family === 'chromium' && browser.name !== 'electron') {
launchOptions.args.push('--disable-dev-shm-usage');
launchOptions.args.push('--js-flags=--max-old-space-size=3072');
}
return launchOptions;
});
},
},
});
Key Points
- Cypress keeps DOM snapshots per command: numTestsKeptInMemory is the first dial
- experimentalMemoryManagement: true targets exactly this class of crash
- Docker's 64 MB /dev/shm default needs --shm-size=2g or --ipc=host
- --disable-dev-shm-usage via before:browser:launch is the in-config equivalent
- Splitting oversized specs beats renting a bigger CI machine
Q38Every GraphQL call hits POST /graphql. How do you intercept and stub one operation without touching the others?
AdvancedNetwork
Answer
URL and method matching cannot separate GraphQL operations, because they share both. The mechanism that solves it is a single intercept on the endpoint with a function handler that inspects req.body.operationName, and, crucially, assigns req.alias dynamically. Setting req.alias inside the handler registers that specific request under an alias you choose, so cy.wait('@gqlGetJobs') waits for exactly that operation while every other query passes through untouched.
From there you branch: call req.reply with a stubbed data payload for the operations you want deterministic, and simply return for the rest so they continue to the real server. This gives you fine grained control that route level matching cannot: stub GetJobs with an empty list to test the empty state while leaving the viewer query real, or return a GraphQL errors array with a 200 status, which is the shape Apollo and urql actually surface and a shape most teams never test. Three complications belong in a strong answer.
Query batching, where the client posts an array of operations in one request, breaks any matcher that assumes req.body is a single object, so normalise to an array first. Automatic persisted queries send only a SHA hash and no query text on the first attempt, so operationName may be present but the body shape differs; either disable APQ in the test build or match on extensions.persistedQuery.sha256Hash. And multipart uploads through graphql-upload arrive as FormData, not JSON, so req.body is a string and JSON access throws. Getting these details right is what distinguishes someone who has stubbed GraphQL in production from someone who has read a blog post.
// cypress/support/graphql.ts
export const aliasOperations = (names: string[]) => {
cy.intercept('POST', '**/graphql', (req) => {
const ops = Array.isArray(req.body) ? req.body : [req.body];
const name = ops[0]?.operationName;
if (name && names.includes(name)) req.alias = `gql${name}`;
});
};
// In the spec
beforeEach(() => {
aliasOperations(['GetJobs', 'GetViewer']);
cy.intercept('POST', '**/graphql', (req) => {
const ops = Array.isArray(req.body) ? req.body : [req.body];
if (ops[0]?.operationName !== 'GetJobs') return; // let it hit the server
req.reply({ statusCode: 200, body: { data: { jobs: { nodes: [], total: 0 } } } });
}).as('gqlGetJobsStub');
});
it('renders the empty state', () => {
cy.visit('/jobs');
cy.wait('@gqlGetJobsStub');
cy.contains('No jobs match these filters').should('be.visible');
});
// A GraphQL error is a 200 with an errors array, not a 500
// req.reply({ statusCode: 200, body: { errors: [{ message: 'FORBIDDEN' }] } });
Q39You inherit a suite where a third of runs fail somewhere different each time. What is your triage sequence?
AdvancedFlake Management
Answer
Measure before touching anything. Pull the last thirty runs, from Cypress Cloud analytics or by parsing the JUnit XML, and build a per test failure frequency. Flake is never uniform: usually eight or ten tests produce most of the noise, and knowing which ones converts an unbounded problem into a two week plan.
Then restore trust in the pipeline immediately by quarantining the worst offenders into a separate non blocking job, so the main build goes green for real reasons while you work. Next, classify each offender by cause, because the fixes are completely different: fixed sleeps and missing waits, shared mutable data colliding between tests or parallel workers, hidden order dependence, unstubbed third party scripts such as analytics, chat widgets or ad tags, animations and toasts intercepting clicks, detached DOM from re-renders, genuine application race conditions, and infrastructure problems like renderer memory or a flaky staging environment. In my experience a meaningful slice of what a team calls flake is category seven, a real race in the product, and surfacing those is how you justify the time to an engineering manager.
Fix with burn-in, not hope: after each change run that spec twenty times in a loop and require twenty passes before you call it done, because a single green run proves nothing about a one in ten race. Then make the fixes stick with guardrails: an ESLint rule banning cy.wait with a number, a blanket intercept for third party domains in the support file, a data factory that always includes a worker index and timestamp, and a weekly flake count reported alongside coverage.
# Burn-in: prove the fix, do not assume it
for i in $(seq 1 20); do
npx cypress run --spec cypress/e2e/checkout/apply-coupon.cy.ts --browser chrome || exit 1
done
// cypress/support/e2e.ts: kill third party noise for every spec
const THIRD_PARTY = [
'**/google-analytics.com/**',
'**/googletagmanager.com/**',
'**/connect.facebook.net/**',
'**/clarity.ms/**',
'**/*.hotjar.com/**',
];
beforeEach(() => {
THIRD_PARTY.forEach((pattern) =>
cy.intercept(pattern, { statusCode: 204, body: '' }),
);
});
// Data that cannot collide across parallel workers
export const uniqueEmail = () =>
`qa.w${Cypress.env('SPLIT_INDEX') ?? 0}.${Date.now()}@example.in`;
Q40When do you use Cypress.Commands.addQuery instead of Cypress.Commands.add, and what is cy.now for?
AdvancedCustom Commands
Answer
The two register fundamentally different things. Cypress.Commands.add creates a command whose body runs once when the queue reaches it. Anything it enqueues internally retries on its own, but the command as a whole is not re-executed, so a helper like cy.getByCy('row').find('[data-cy=apply]') resolves the row once and can still hand a stale node to the next link.
Cypress.Commands.addQuery registers a query, and the contract is different in a way people miss: the function you pass runs a single time at declaration to capture arguments, and it must return an inner function that Cypress calls repeatedly, synchronously, on every retry attempt, receiving the previous subject. That inner function must be pure and synchronous. No cy commands, no promises, no side effects, because it may run dozens of times per second during a retry loop.
In exchange your command becomes a first class link in a query chain, participates in the whole chain re-running together, and therefore stops producing detached DOM failures. cy.now(name, ...args) is the escape hatch that makes this practical: it invokes a built-in command's implementation immediately, outside the queue, and returns its function, which is how you compose cy.get or cy.contains inside your own query rather than reimplementing jQuery selection. Cypress.Commands.overwriteQuery does the same for replacing a built-in query, for example teaching cy.contains to ignore hidden nodes project wide. The rule of thumb for review: if the helper only reads the DOM, it must be a query; if it performs actions or needs other commands in sequence, it stays a regular command, ideally with prevSubject declared.
// A retrying query: composes the built-in get via cy.now
Cypress.Commands.addQuery('getByCy', function (id: string) {
const getFn = cy.now('get', `[data-cy="${id}"]`) as () => JQuery;
return () => getFn();
});
// Pure, synchronous filtering that re-runs on every retry attempt
Cypress.Commands.addQuery('withStatus', function (status: string) {
return (subject: JQuery) =>
subject.filter((_, el) => el.getAttribute('data-status') === status);
});
// Chains like a built-in, and the whole chain re-runs together
cy.getByCy('job-row').withStatus('SHORTLISTED').should('have.length', 3);
// Actions stay regular commands
Cypress.Commands.add(
'shortlist',
{ prevSubject: 'element' },
($row: JQuery) => {
cy.wrap($row).find('[data-cy=shortlist]').click();
cy.wait('@shortlist');
},
);
Q41How do you fold accessibility and visual regression checks into a Cypress pipeline without drowning the team?
AdvancedQuality Engineering
Answer
For accessibility, cypress-axe injects axe-core into the page with cy.injectAxe() and runs it with cy.checkA11y(context, options, callback, skipFailures). The mechanics are easy; making it survive contact with a real codebase is the interesting part. Run the check when the page is in the state you care about, after data has loaded and modals are open, because a skeleton screen has almost no violations and tells you nothing.
Scope the context to the region under test so a legacy navbar does not fail every spec. Filter with includedImpacts: ['critical', 'serious'] on day one, because turning on all four impact levels against an existing product typically produces hundreds of nodes and the team switches the job off within a week. The default failure output is a nested object that is unreadable in a CI log, so pass a violation callback that pushes a flat summary through cy.task and prints a table.
Baseline the existing debt with skipFailures or a disabled rule list, then ratchet: block only new violations. For visual regression, Cypress ships no image diffing, so you choose between a hosted service such as Percy, Applitools or Argos, which handle storage, diffing and a review workflow, and an in-repo plugin built on pixelmatch, which is free but leaves you fighting anti-aliasing and font differences between a developer's Mac and a Linux CI container. Whichever you pick, determinism is the whole game: freeze time with cy.clock, stub every network call that feeds the view, hide known dynamic regions with an injected stylesheet, and generate baselines inside the same container image that CI uses. Component tests are usually a better host for visual snapshots than end to end specs, because the surface is smaller and the state is explicit.
// cypress/support/e2e.ts
import 'cypress-axe';
Cypress.Commands.add('a11y', (context = null) => {
cy.injectAxe();
cy.checkA11y(
context,
{ includedImpacts: ['critical', 'serious'] },
(violations) => {
cy.task(
'table',
violations.map((v) => ({
id: v.id,
impact: v.impact,
nodes: v.nodes.length,
help: v.help,
})),
);
},
);
});
it('job search results are accessible', () => {
cy.intercept('GET', '**/api/jobs*', { fixture: 'jobs-page-1.json' }).as('jobs');
cy.clock(new Date('2026-04-01T09:00:00+05:30').getTime());
cy.visit('/jobs');
cy.wait('@jobs');
cy.a11y('[data-cy=results-list]');
});
Q42cy.intercept cannot see WebSocket frames. How do you test a realtime chat or live interview status?
AdvancedRealtime
Answer
This is a documented limitation, not a bug to work around with a longer timeout: cy.intercept operates on the fetch and XHR layer, so WebSocket frames and, in the general case, responses served by a service worker are invisible to it. There are four workable strategies and the right answer names the trade-off of each. First, stub at the client boundary.
If the app exposes its socket client on window in non production builds, you can cy.stub its emit to assert what the client sends, and push inbound events straight into the app's handler with cy.window().its('__socket').invoke('emit', 'message:new', payload). Fast and deterministic, but it tests your components rather than the transport. Second, replace the global WebSocket constructor before any application code runs, using mock-socket installed through cy.visit's onBeforeLoad.
This keeps the real client library in play while you control the server side of the connection. Third, drive the genuine server from Node with cy.task and a socket.io-client or ws instance acting as the second participant. This is the highest fidelity option and it is the only sane way to test a two person scenario such as a recruiter and a candidate, because Cypress cannot drive two tabs; the browser is one user and the task is the other. Fourth, for service workers, either unregister them in the test environment or gate registration behind an environment flag, because a stale worker will also serve cached bundles and produce failures that look like phantom regressions after a deploy.
// 1. Push a server event in from the test, no socket needed
cy.window().its('__socket').invoke('emit', 'interview:status', {
interviewId: 42,
status: 'COMPLETED',
});
cy.get('[data-cy=interview-status]').should('have.text', 'Completed');
// 2. Replace the transport before app code runs
import { Server } from 'mock-socket';
let mockServer: Server;
cy.visit('/chat', {
onBeforeLoad(win) {
mockServer = new Server('ws://localhost:4000/ws');
(win as any).WebSocket = require('mock-socket').WebSocket;
mockServer.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'chat', text: 'Hi from the recruiter' }));
});
},
});
cy.contains('Hi from the recruiter').should('be.visible');
// 3. The real server, with Node acting as the second user
cy.task('socket:emitAs', {
user: 'recruiter@example.in',
event: 'chat:send',
payload: { room: 'r-42', text: 'Can you join at 4pm IST?' },
});
cy.contains('Can you join at 4pm IST?').should('be.visible');
Q43A 45 minute regression suite has to fit in 10 minutes. What do you change, in what order?
AdvancedPerformance
Answer
Start with the run summary or Cloud analytics and get durations per spec and per test, because optimisation without measurement usually targets the wrong thing. Then work down the payoff list. Logging in through the UI in a beforeEach is almost always the largest single cost; replacing it with a programmatic login wrapped in cy.session with cacheAcrossSpecs turns two or three seconds per test into tens of milliseconds, and on a two hundred test suite that alone can cut ten minutes.
Next, convert setup from UI clicking to app actions: create the job, order or candidate with cy.request or cy.task instead of walking a wizard that another spec already covers. Third, stub third party scripts, since analytics, chat and tag manager requests add real latency on every page load and occasionally hang. Fourth, delete duplicated coverage; suites accumulate five tests that each traverse the same checkout to assert a different label, and one test with five assertions is strictly better.
Fifth, move component level assertions out of end to end and into component tests, which start in a fraction of the time. Sixth, split monolithic specs, because parallel distribution is per spec file and one nine minute spec sets the floor no matter how many machines you add. Only then add machines.
Supporting changes that matter: run the application under test as a production build rather than a dev server with hot reloading, cache the Cypress binary at ~/.cache/Cypress along with node_modules, keep video off, and lower numTestsKeptInMemory in CI. What not to do: shrinking timeouts to make failures fail faster just converts slow tests into flaky ones. Finally, split the tiers, a fast smoke suite on every pull request and the full regression nightly, so the ten minute budget applies where it actually blocks people.
// Before: two seconds of UI login per test
// beforeEach(() => {
// cy.visit('/login');
// cy.get('[data-cy=email]').type(email);
// cy.get('[data-cy=password]').type(password);
// cy.contains('button', 'Sign in').click();
// cy.location('pathname').should('eq', '/dashboard');
// });
// After: cached across the whole run
beforeEach(() => {
cy.loginAs('recruiter'); // cy.session + cacheAcrossSpecs
cy.task('db:resetJobs'); // app action instead of UI setup
cy.visit('/jobs');
});
// Tier the pipeline by tag
// npx cypress run --env grepTags=@smoke # on every PR
// npx cypress run # full suite, nightly
it('places an order', { tags: ['@smoke', '@money-path'] }, () => { /* ... */ });
// CI: cache the binary, not just node_modules
// - uses: actions/cache@v4
// with:
// path: |
// ~/.npm
// ~/.cache/Cypress
// key: cy-${{ hashFiles('package-lock.json') }}
Key Points
- Measure per spec durations before changing anything
- cy.session plus programmatic login is usually the single biggest win
- App actions via cy.request and cy.task replace UI setup
- Split monolithic specs before adding parallel machines
- Cache ~/.cache/Cypress, not just node_modules
- Never buy speed by shrinking timeouts; that buys flake
Q44Cypress or Playwright for a new suite in 2026? Make the technical argument both ways.
AdvancedTooling
Answer
Both auto-wait, so any answer built on 'Playwright waits and Cypress does not' fails immediately. The real differences follow from architecture. Playwright drives browsers out of process over CDP and its own protocols, which gives it things Cypress structurally cannot offer: multiple tabs and browser contexts in one test, genuinely free navigation across origins, real WebKit and Firefox engines rather than a Chromium family plus Electron, worker level parallelism built into the runner with no paid service, and a request context for API testing.
Its trace viewer is excellent after the fact. Cypress runs inside the browser, and that buys the best interactive development loop in the category: time travel snapshots, a live command log, the ability to pause and poke at the real DOM mid test, plus component testing that runs your components through your project's own bundler across React, Vue, Angular and Svelte, which keeps one tool and one syntax for both levels. cy.intercept remains more ergonomic than most people expect, and in the Indian hiring market the pool of engineers who already write Cypress is considerably larger, which is a genuine engineering input, not a soft one. My decision rule: greenfield work that needs multi tab, multi user, WebKit coverage, or free parallelism goes to Playwright.
A product team with a design system that wants component and end to end testing in one tool, with the strongest local debugging experience, stays on Cypress. And an existing suite of several hundred passing Cypress specs should not be rewritten for a marginal gain, because migration costs months and the flake reappears in a new shape; the honest move is to fix the flake you have and add Playwright only for the scenarios Cypress genuinely cannot express.
Q45What does the Cypress event system let you do, and why is a blanket uncaught:exception handler an antipattern?
AdvancedExtensibility
Answer
There are two event buses and mixing them up is a common interview slip. Cypress.on runs in the browser and is registered in the support file: test:before:run and test:after:run, window:before:load and window:before:unload, command:start and command:end, fail, and uncaught:exception. The on argument handed to setupNodeEvents runs in Node: before:browser:launch, before:spec, after:spec, after:run, task and file:preprocessor.
Between them you can add Chrome flags or load an extension at launch, stub window.open, navigator.geolocation or window.matchMedia for every spec before any application script executes, delete the video of a passing spec in after:spec so artifacts stay small, close database pools in after:run, and add context to a failure with Cypress.on('fail'). The uncaught:exception handler deserves its own answer. By default, an unhandled error thrown by the application fails the current test, which is usually correct behaviour: your product just crashed.
The common shortcut is a bare Cypress.on('uncaught:exception', () => false) in the support file, which makes every application crash invisible for the entire suite. That is how teams end up with a green pipeline over a broken app. The disciplined version matches on the specific message, returns false only for that, logs it so it stays visible, and carries a comment naming the third party library and the ticket to remove it.
The classic legitimate cases are the benign 'ResizeObserver loop completed with undelivered notifications' warning and errors thrown inside a vendor chat or analytics bundle you do not control. Everything else should fail the test.
// cypress/support/e2e.ts
// BAD: hides every application crash in the entire suite
// Cypress.on('uncaught:exception', () => false);
// GOOD: narrow, logged, and documented
const IGNORED = [
/ResizeObserver loop (limit exceeded|completed with undelivered)/,
/Script error\.?$/, // cross-origin vendor bundle, QA-1194
];
Cypress.on('uncaught:exception', (err) => {
if (IGNORED.some((re) => re.test(err.message))) {
Cypress.log({ name: 'ignored error', message: err.message });
return false;
}
return true; // let real crashes fail the test
});
Cypress.on('window:before:load', (win) => {
cy.stub(win, 'open').as('windowOpen');
win.localStorage.setItem('cookie_consent', 'accepted');
});
// cypress.config.ts, Node side
setupNodeEvents(on) {
on('after:spec', (spec, results) => {
if (results?.video && results.stats.failures === 0) {
require('fs').unlinkSync(results.video); // keep only failure videos
}
});
}
Frequently Asked Questions
What salary can a Cypress automation engineer expect in India?
Postings usually band by total automation experience rather than by Cypress alone. Freshers and engineers with under two years, typically in a manual to automation transition, see around 4 to 8 LPA, more at product companies than at large services firms. With three to five years of solid JavaScript or TypeScript plus a suite you actually own end to end, 9 to 16 LPA is the common range in Bengaluru, Pune, Hyderabad and Gurugram. SDET and lead roles at product companies, where you are expected to own CI, flake budgets and test infrastructure rather than only write specs, run from roughly 18 LPA upward, and strong SDETs at well funded product companies go higher. Two things move you up a band faster than more years: real CI ownership, meaning you can talk about parallelisation, Docker images, caching and pipeline runtimes, and the ability to write application code, because a tester who can fix the component under test is valued differently from one who files a bug.
How long does it take to get interview ready with Cypress if I already know JavaScript?
With working JavaScript, three to four weeks of consistent evening practice is usually enough for a mid level round. Week one: install, project structure, cypress.config.ts, selectors, the command queue and why await does not work, assertions, and retry-ability. Week two: cy.intercept and cy.wait on aliases, fixtures, cy.session and programmatic login, custom commands with TypeScript typings. Week three: the failure modes interviewers actually probe, detached from DOM, covered by another element, cross origin, plus cy.task, cy.clock and component testing. Week four: put it in a pipeline, because the CI half is where most candidates thin out. Build one public repository against a real site or your own small app with roughly thirty specs, a GitHub Actions workflow, sharded runs and a report artifact. Without JavaScript, add four to six weeks first; candidates who try to learn Cypress while still shaky on promises, closures and array methods stall in the live coding round.
What do interviewers expect from a fresher versus someone with four years of experience?
A fresher is assessed on fundamentals and coachability. You should be able to write a login and a search test live, pick sensible data-cy selectors, explain that commands are queued rather than awaited, use .should instead of a bare .then for assertions, and read a failure message and say what it means. Nobody expects you to have run a parallel pipeline. At four years, the questions move to ownership. Expect to be asked how you cut a suite's runtime, how you handled flake and what your flake rate is today, how you authenticate without the UI, how you seed and clean data across parallel workers, and how your reports and artifacts reach the team. You will also be asked to defend an architectural choice: page objects versus app actions, when you would stub versus hit the real API, and whether you would pick Cypress again for the same project. Concrete numbers from your own suite, spec count, runtime, flake rate, are worth more than any framework vocabulary.
Is Cypress still worth learning in 2026 now that Playwright has so much momentum?
Yes, with clear eyes about why. Playwright has taken a large share of greenfield projects, particularly where multi tab, multi user or WebKit coverage matters and where free worker level parallelism is attractive. But an enormous amount of shipped software already runs on Cypress suites that companies are maintaining and extending, not rewriting, because migrating several hundred specs is a multi month project with no user visible benefit. Cypress also remains strong where Playwright is weaker: component testing through the project's own bundler and the interactive debugging loop, which is why frontend teams often keep it. The practical stance for a candidate is to be genuinely good at one and conversant in the other. Learn Cypress properly, then spend a week building a small Playwright suite so you can compare them from experience rather than from blog posts. In interviews, the ability to say which tool you would pick for a specific scenario and why lands far better than loyalty to either, and it is increasingly a question that gets asked directly.
Should I learn Selenium as well, or is Cypress enough for the Indian job market?
Selenium with Java still dominates a large slice of the Indian market, especially in enterprise services accounts, banking and insurance clients, and GCC teams maintaining suites that were built over a decade. Those roles are numerous and they are not going away quickly. Cypress and Playwright dominate the product company side and anything with a modern JavaScript frontend. Your choice should follow the kind of company you want to join. If you are targeting product companies and startups, Cypress plus TypeScript, with Playwright as a second, is the right investment. If you are targeting large services organisations, Java plus Selenium plus TestNG plus a build tool will open more doors and Cypress becomes a differentiator on top. Knowing both is genuinely useful, and the concepts transfer more than people assume: waits, selector strategy, test data management, CI and reporting are the same problems. What does not transfer is the mental model of the command queue, which is why Selenium engineers moving to Cypress so often write await cy.get in their first interview.
What kind of portfolio proof actually helps in a Cypress interview?
One repository that a reviewer can open and understand in five minutes beats a certificate. Make it a real suite against a real application, ideally something with authentication, a list with filters, a form and a checkout or submission flow. Include the things that separate a professional suite from a tutorial: a cypress.config.ts with sensible timeouts and retries, programmatic login through cy.session, a couple of custom commands with proper TypeScript declarations, cy.intercept used for both stubbing and assertion, at least one component test, a GitHub Actions workflow with caching and sharding, and a merged report published as an artifact. Write a short README that states the spec count, the runtime, and what you did to keep it stable. If you have fixed a genuine flake, describe the root cause and the fix in a paragraph, because that single story tends to carry more weight in an interview than the rest of the repository combined. A public run history where the pipeline is consistently green is the strongest signal of all.
Introduction
Cypress runs your test code inside the same browser and the same JavaScript event loop as the application under test. That single architectural fact explains almost everything else about the tool: automatic waiting, time-travel snapshots, direct access to window, document and localStorage, first-class network stubbing, and equally the restrictions around multiple tabs, native OS events and cross-origin navigation. Since the 10.x rewrite, one binary covers both end-to-end and component testing, configured through cypress.config.ts instead of the old cypress.json. Recent major releases have focused on flake control, Test Replay, and memory behaviour in CI rather than new command syntax, so the API you learn now stays useful.
Interviews for QA automation and SDET roles in India rarely stop at what cy.get does. Hiring managers at product companies probe the command queue and why async/await does not work, which commands actually retry, cy.intercept versus cy.request, cy.session and testIsolation, cy.origin for SSO logins, and the exact failure text engineers see in pipelines: detached from the DOM, covered by another element, the Chromium renderer process just crashed. Services and GCC teams staffing large regression suites add parallelisation, reporting and Docker images to that list. Candidates who can name the config key or command that fixes a problem, not merely describe the symptom, get the offer.
This page covers 45 Cypress interview questions written for the 2026 market, ordered basic first, then intermediate, then advanced. Most of them carry a runnable code example using current APIs: defineConfig, cy.intercept, cy.session, cy.origin, cy.selectFile, cy.task, cy.clock and the component-testing mount adapters. The answers spend far more time on real behaviour than on definitions, because that is where these interviews are decided: what breaks on a CI runner but never locally, which of the four default timeouts applies, how to make a flaky suite honest again, and how to argue for or against Cypress when Playwright is on the table.
Ready to practice Cypress interviews?
Don't just read, practice these Cypress questions live with an AI interviewer that asks follow-ups and scores your answers.