This page is for testers and developers facing a Cypress round, from a first automation job to a senior role that owns the suite. Most rounds start with how Cypress runs inside the browser and why its commands are queued, then test selectors, retry-ability and waits, network stubbing with cy.intercept, fixtures and test data, and custom commands. Stronger rounds add component tests, CI and parallel runs, flaky tests and a fair comparison with other tools. Each question shows what the interviewer is checking, the shape of a good answer and a short answer you can say out loud.
Search all questions by round, difficulty and level, or save the ones you want to practise.
In the browser: your spec runs in the same browser as the app, in its own frame, so it can reach the DOM, window and storage directly.
Node side: a Node process runs alongside for what a browser can't do: files, tasks, and proxying network traffic.
Contrast: a WebDriver tool runs the test outside the browser and sends each command through a driver.
Trade-off: built-in waiting and great debugging, but JavaScript or TypeScript only and one tab at a time.
"When I run a Cypress test, my spec is bundled and loaded into the browser itself, in a frame next to the application under test. So the test runs in the same run loop as the app and can touch the DOM, the window object and local storage directly. Behind that there's a Node process that handles what a browser sandbox can't: reading files, running tasks like seeding a database, and sitting in the middle of network traffic, which is how intercepting requests works. A WebDriver tool is different. The test runs in a separate process and sends each command to a driver, which tells the browser what to do. Being inside gives Cypress automatic waiting, fast feedback and the snapshot debugging in the runner. The cost is that tests are JavaScript or TypeScript only, and things like controlling a second tab are outside what it's built for."
Saying Cypress is built on Selenium or WebDriver, or not knowing there is a Node process working behind the browser.
Queued: each cy command is added to a queue and runs later, in order; it returns a chainable, not the element.
Not a promise: you can't await it or read its result in the next plain line of code.
Getting values: use .invoke() and .then() to yield the value, or save it with .as() and read it back.
"Cypress commands don't run when the line executes. Calling cy.get just adds a step to a queue and returns a chainable object, and the queue runs after the test function has finished registering everything. So title isn't an element or a string, and if I log it I get the chainable, not the heading. It also isn't a real promise, so await won't do what people expect. To use the value, I stay inside the chain. I call invoke with text, then use then to get the actual string in a callback, and anything that depends on it goes inside that callback. If I need it in a later step, I save it with as and read it back with cy.get and the alias name, or with this and the alias inside a normal function, not an arrow function."
cy.get('h1')
.invoke('text')
.then((title) => {
cy.get('[data-cy=breadcrumb]').should('contain', title.trim())
})
cy.get('[data-cy=order-id]').invoke('text').as('orderId')
cy.get('@orderId').then((id) => {
cy.visit('/orders/' + id.trim())
})
Using async and await on cy commands, or assigning the result of cy.get to a variable and treating it as the element.
Cause: the test leans on state left by an earlier one: a login, created data, or the page it ended on.
What resets: with test isolation on, Cypress clears the page, cookies, local storage and session storage before each test.
Fix: each test sets up what it needs in beforeEach, through API calls or cy.session, never through an earlier test.
"That's the classic sign of tests depending on each other. Say the first test logs in and creates a product, and the second one edits that product. Run together, it works. Run the second one alone and there's nothing to edit. Newer Cypress versions actually push against this: with test isolation on, it clears the page, cookies, local storage and session storage before every test, so leaning on the previous test's browser state fails fast. The fix is to make each test stand on its own. I move shared setup into a beforeEach, create the data through an API call or a task instead of through the UI, and cache the login with cy.session so it stays quick. Then any test can run alone, in any order, and I can split specs across machines later without surprises."
Fixing it by forcing test order or by merging the tests into one long test.
New tab: Cypress drives one tab, so check the link's href and target, or remove the target and follow it in the same tab.
Other origin: wrap commands for the second site in cy.origin, used sparingly.
Everywhere else: log in programmatically and cache it with cy.session.
Principle: test your app's side of the handshake, not the provider's page.
"Both run into how Cypress works. It controls one tab, so for the help link I don't try to switch windows. I check the link has the right href and target, and if I need to see the page it opens, I remove the target attribute and click it so it loads in the same tab, or I just cy.request the URL and check it responds. For the third-party sign-in, Cypress has cy.origin, which lets me run commands against a second origin inside a callback, so a real login through that page is possible. But I'd do that in one or two tests only. Everywhere else I'd log in programmatically, for example by getting a token through the identity provider's API or a test endpoint, and cache it with cy.session. Clicking through someone else's page in every test is slow, and when they change it or rate-limit it, my whole suite goes red."
cy.get('[data-cy=help-link]')
.should('have.attr', 'target', '_blank')
.invoke('removeAttr', 'target')
.click()
cy.url().should('include', '/help')
Claiming Cypress switches between tabs like a WebDriver tool, or making every test click through the third-party login page.
Cypress: in the browser; JavaScript or TypeScript; strong debugging, retries, stubbing and component tests; one tab.
Playwright: drives browsers from outside; several languages; multiple tabs and contexts; Chromium, Firefox and WebKit.
Selenium: the WebDriver standard; widest language and browser reach, including real Safari; more to build yourself.
Pick Cypress: a JavaScript front-end team, single-tab flows, Chrome-family and Firefox coverage.
"They solve the same problem in different ways. Cypress runs inside the browser, which gives it very good debugging with the time-travel runner, automatic retries, easy network stubbing and component testing, but it's JavaScript or TypeScript only and it drives one tab at a time. Playwright drives browsers from outside through their automation protocols, supports several languages, handles multiple tabs and isolated browser contexts, covers WebKit, and runs tests in parallel on one machine out of the box. Selenium is the WebDriver standard: the widest language and browser reach, including real Safari and large grids, but you assemble more of the framework yourself. I'd pick Cypress for a front-end team working in JavaScript that wants quick, readable tests and component tests, where the flows stay in one tab and Chrome-family and Firefox cover the users. If real Safari or multi-tab flows are central, I'd look elsewhere."
Declaring one tool best for everything, or repeating old limits without checking what current versions support.
Map needs to limits: real Safari coverage and multi-window flows are where Cypress is weakest.
Options: Cypress for most tests plus another route for Safari and the payment step, or a different tool.
Decide with a spike: automate the hardest flow before committing.
Be honest: never report Safari coverage from Chrome-only runs.
"I'd respect the team's instinct if they know Cypress well, but these two requirements hit its weak spots. Cypress mainly covers Chrome-family browsers and Firefox, and its WebKit option has been experimental, which still isn't the same as real Safari on a real device, and it drives one tab, so a payment step in a new window needs a workaround. I wouldn't settle it in a meeting. I'd run a short spike: automate checkout in Cypress, stubbing or bypassing the payment window, and in one alternative that handles WebKit and multiple pages, then compare how honest, stable and quick each is. A mixed answer is often fine: Cypress for component tests and most flows, which developers will happily write, plus a small set of checks on real Safari, through another tool or a device cloud, for the journeys iPhone users depend on. What I wouldn't do is claim Safari coverage from Chrome-only runs."
Picking or rejecting Cypress by habit without checking it against the Safari and new-window requirements.
Best: dedicated test attributes like data-cy or data-test, agreed with developers.
Good: visible text through cy.contains when the text is what the user relies on.
Avoid: generated class names, long CSS paths, index-based picks and ids that change per build.
"I ask developers to add dedicated test attributes, like data-cy equals submit-order, to the elements we test. Those attributes exist only for tests, so nobody changes them when restyling, and anyone who touches one can see a test depends on it. Where the visible text is the point, like a button labelled Place order, I use cy.contains, because if that text changes the test probably should notice. What I avoid is anything tied to styling or structure: generated class names, long paths like a div inside a div inside the third span, or eq with an index that shifts when a new row appears. The selector playground in the Cypress runner suggests selectors and prefers data attributes too, so it's a fair starting point, but I still check that what it picks makes sense."
Relying on long CSS paths or generated class names and then calling the resulting breakage flakiness.
cy.get: searches from the document root, even when chained after another command.
.find: searches only inside the previous subject.
cy.contains: matches by text, and a selector argument says which element should hold that text.
Scoping: find the row by something unique, then use .find() or .within() inside it.
"cy.get always searches from the root of the document, even if I chain it after another command, which surprises people. The one exception is inside a within block. find searches only inside whatever the previous command yielded. contains matches on text, and I can give it a selector too, so contains with tr and an email address gives me the table row that holds that email, not just the cell. To click Delete for one user, I find the row by the email, then search inside it, either with find or with within, which scopes every command in its callback to that row. That way, if the table gets sorted or a new user appears above, the test still clicks the right button, because it never relied on a row number."
cy.contains('tr', 'sam@example.com')
.find('[data-cy=delete-user]')
.click()
// same thing, scoped with within
cy.contains('tr', 'sam@example.com').within(() => {
cy.get('[data-cy=delete-user]').click()
})
Clicking by row index like eq(3), so the test breaks as soon as the data order changes.
Queries retry: cy.get, .find, .contains and similar re-run until the assertions after them pass or time out.
Assertions drive it: .should keeps the query before it retrying against a fresh page.
Actions don't retry: .click and .type run once, after waiting for the element to be actionable.
Pattern: query, assert the state, then act; no checks hidden in .then.
"Retry-ability is how Cypress avoids most explicit waits. Queries like get, find and contains don't just look once. If there's an assertion after them, Cypress keeps re-running the query and re-checking the assertion until it passes or the command timeout runs out, and even without an explicit should, a query retries until the element exists. In recent versions the chain of queries is re-run together, so if the page re-renders, it picks up the fresh element. Actions like click and type are different. They don't repeat, because clicking twice could have side effects, but before acting they wait until the element is visible, enabled and not covered. So my pattern is: query, assert it's in the state I expect, then act. And I don't put assertions inside a plain then callback, because then runs once and doesn't retry."
Saying every Cypress command retries, or adding waits to fix timing that retry-ability already handles.
Problem: a fixed wait is too long on a fast run and too short on a slow one, so the suite is slow and still flaky.
Wait on state: assert on what the user would see and let retry-ability do the waiting.
Wait on network: alias the request with cy.intercept and wait on the alias.
"A fixed wait is a guess. On a fast run it wastes three seconds every time, and across hundreds of tests that adds up. On a slow CI machine three seconds isn't enough, and the test fails anyway, so it's slow and still flaky. Most of the time I don't need a wait at all, just an assertion on the thing I'm waiting for, like the spinner should not exist, or the table should contain the new row, and Cypress retries until that's true. When the test really depends on a request finishing, I set up cy.intercept for that call before the action, give it an alias and then wait on the alias. That waits exactly as long as the request takes, and I can check the status code as well, so a failure tells me the API broke rather than just timing out."
cy.intercept('POST', '/api/orders').as('createOrder')
cy.get('[data-cy=place-order]').click()
cy.wait('@createOrder').its('response.statusCode').should('eq', 201)
cy.contains('[data-cy=toast]', 'Order placed').should('be.visible')
Raising the fixed wait until the test goes green.
.should(cb): re-runs the callback and the query before it until nothing throws or the timeout hits.
.then(cb): runs once with whatever was yielded; no retry.
Rule: .should for checks on a changing page, .then for using a settled value; keep .should callbacks free of cy commands and side effects.
"Both take a callback, but they behave very differently. With should, Cypress keeps calling my callback, and re-running the query before it, until the callback stops throwing or the timeout runs out. With then, my callback runs exactly once with whatever was there at that moment. So for anything that might not be ready yet, should is the right one. A good example is checking that a price list, which loads asynchronously, is sorted from low to high. There's no built-in assertion for that, so I write a callback that reads every price and expects the list to equal a sorted copy of itself. Because Cypress may call that callback many times, I keep it pure: just reading values and expect statements, no cy commands and nothing that changes state."
cy.get('[data-cy=price]').should((prices) => {
const values = [...prices].map((el) => Number(el.innerText.replace(/[^0-9.]/g, '')))
const sorted = [...values].sort((a, b) => a - b)
expect(values).to.deep.equal(sorted)
})
Using .then for a check on data that is still loading, then blaming Cypress when it fails now and then.
Why it's risky: the DOM can change right after you check, so 'if visible, click' races the app.
Best: make the state fixed by setting the consent cookie or storage key before the app loads.
Separate test: one test clears it and checks the banner itself.
Last resort: wait for a signal that the page has settled, then inspect the DOM once in .then.
"My first move is to remove the condition, not code around it. Conditional testing is risky in Cypress because the page can change right after I look. The banner might be about to render when I check, so the test skips it and then fails on a covered button. So I make the state fixed. If the banner is driven by a consent cookie or a local storage key, I set that before the app loads, for example in the onBeforeLoad option of cy.visit, and the banner never shows. Then I write one separate test that starts without consent and checks the banner appears and works. If I truly can't control it, say it comes from a third-party script, I wait for something that proves the page has settled, then read the body once inside then and branch on whether the banner exists. But that's a last resort, and I'd note it as a known risk."
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('cookieConsent', 'accepted')
},
})
cy.get('[data-cy=cookie-banner]').should('not.exist')
Wrapping steps in if-visible checks so the test passes whether or not the feature works.
Register first: set up the intercept before the action that fires the request.
Stub: match method and path, reply with a fixture file or an inline body.
Wait and assert: alias it, wait on it, then check what the user sees.
Behind it: the Cypress proxy sees the browser's requests and answers matching ones itself.
"I register the intercept before cy.visit, because the request fires as the page loads, and an intercept only catches requests made after it exists. I match GET on the products path and tell Cypress to reply with a fixture file, so the app gets a known list of three products without the real backend being involved. I alias it, wait on the alias, and then check the page shows three cards and the first product's name. Behind the scenes, the browser's traffic goes through a proxy run by the Cypress Node process, so when a request matches a route, the proxy answers it instead of passing it on. That's why it works for fetch and XHR alike, and the app has no idea it's stubbed. The payoff is a fast, repeatable test of the front end, including edge cases like an empty list that are hard to arrange with real data."
it('shows the product list', () => {
cy.intercept({ method: 'GET', pathname: '/api/products' }, { fixture: 'products.json' }).as('getProducts')
cy.visit('/shop')
cy.wait('@getProducts')
cy.get('[data-cy=product-card]').should('have.length', 3)
cy.get('[data-cy=product-card]').first().should('contain', 'Desk Lamp')
})
Registering the intercept after cy.visit and then adding a fixed wait when the stub doesn't seem to apply.
Spy: cy.intercept with only a matcher lets the request reach the real server and records it.
Assert: wait on the alias and check request.body, headers and the response status.
Change if needed: a handler can alter the request or response with req.continue or req.reply.
"If I call cy.intercept with just the method and URL and no response, it acts as a spy. The request goes to the real server, and Cypress records both the request and the response. So I set it up, fill the form, submit, and then wait on the alias. The wait yields the interception, and I can assert on request.body to check the payload has the right fields, maybe that the email was trimmed or the date was sent in the right format, and on the response status to check the server accepted it. This catches bugs the screen hides, like a field that's shown but never sent. If I want to see how the app handles something unusual while still hitting the real backend, I can pass a handler and use req.continue with a callback to change the response before the app receives it."
cy.intercept('POST', '/api/signup').as('signup')
cy.get('[data-cy=email]').type(' lee@example.com ')
cy.get('[data-cy=submit]').click()
cy.wait('@signup').then(({ request, response }) => {
expect(request.body.email).to.equal('lee@example.com')
expect(response.statusCode).to.equal(201)
})
Only checking the success message on screen and assuming the right data reached the server.
Error status: reply with statusCode: 500 and a realistic body, then check the message and retry.
Network failure: forceNetworkError: true makes the request fail like a dropped connection.
Slow: add a delay to the reply, then check the loading state and double-submit protection.
Realism: error bodies copied from what the real API sends.
"This is where stubbing earns its keep, because these states are hard to cause on a real server on demand. For a server error, I intercept the call and reply with statusCode 500 and the error body the backend really sends, then check the user sees a sensible message instead of a blank screen, and that retry works. For a dead connection, intercept supports forceNetworkError, which makes the request fail the way it does when the network drops. For slowness, I add a delay to the stubbed reply, then assert the spinner shows, the submit button is disabled so it can't be pressed twice, and everything settles once the response lands. I keep those stubbed bodies in fixtures that match the real API's error format, otherwise I'm testing a response the app will never get."
cy.intercept('GET', '/api/orders', {
statusCode: 500,
body: { error: 'Internal error' },
}).as('orders')
cy.visit('/orders')
cy.wait('@orders')
cy.get('[data-cy=error-message]').should('be.visible')
// in another test: a slow but successful reply
cy.intercept('GET', '/api/orders', { fixture: 'orders.json', delay: 2000 })
Only ever testing the happy path because the real backend never fails in the test environment.
What happened: tests green, bug found later.
Why stubs hid it: the fixture no longer matched the real API.
Change: some real-backend tests for key journeys, and fixtures checked against the live shape.
"We had a product list page covered by stubbed tests that were always green. Then the backend team renamed a field from price to unitPrice, and in staging every product showed an empty price. Our fixture still had the old field name, so the tests were checking the front end against an API that no longer existed. I changed two things. First, I added a small set of tests that run against the real backend with no stubs for the key journeys, browse, add to cart and checkout, so a contract change breaks something. Second, we started refreshing the main fixtures from real API responses with a script in CI, and the build fails if the shape changes and the fixture wasn't updated. Stubs stayed for edge cases like errors and empty lists, which is where they're most useful."
Claiming stubbing has no downside, or dropping stubs entirely after one incident.
What: static files, usually JSON, in the fixtures folder holding known data.
Use: cy.fixture() in a test or hook, or { fixture: 'file.json' } as an intercept reply.
Wrong tool: data that must exist in the real backend, or must be unique per run.
"Fixtures are static data files, mostly JSON, kept in the fixtures folder of the Cypress project. I use them in two ways. One is to load test input, like a set of user details, with cy.fixture in a beforeEach, and then use those values when filling forms. The other, which I use more, is as a canned API response in cy.intercept, so the front end always gets the same data. They're the wrong tool when the data has to really exist in the backend, like a user who needs to log in, because a JSON file doesn't create anything. That needs an API call or a task that seeds the database. They're also wrong for values that must be unique on each run, like a new sign-up email, where I'd generate one with a timestamp. And I keep fixtures small and close to real responses so they don't drift."
Thinking a fixture puts data into the application's database.
Through the API: cy.request calls an endpoint directly, not through the page, so it's quick and CORS doesn't apply.
Through Node: cy.task runs a function registered in setupNodeEvents, for direct database work.
Keep it clean: seed in beforeEach, make data unique or reset it, never click through the UI to build it.
"I don't create setup data through the UI, because it's slow and a bug in that screen would fail every unrelated test. I have two options. If the backend has an API for it, or a test-only endpoint, I use cy.request in a beforeEach. It makes the HTTP call directly rather than from the page, so it's quick and not tied to any screen. If I need to go straight to the database, say to reset tables or insert rows there's no API for, I use cy.task. A task is a function I register in setupNodeEvents in the config file, and it runs in the Node process, so it can use a database client. One detail that catches people: a task must return a value, a promise or null, because returning undefined makes Cypress fail it. I also make each test's data unique or reset it, so tests don't trip over each other."
// cypress.config.js
const { defineConfig } = require('cypress')
const db = require('./test/db')
module.exports = defineConfig({
e2e: {
setupNodeEvents(on, config) {
on('task', {
seedOrders({ email, count }) {
return db.createUserWithOrders(email, count)
},
})
},
},
})
// in the spec
beforeEach(() => {
cy.task('seedOrders', { email: 'kim@example.com', count: 3 })
})
Logging in and creating every record through the UI at the start of each test.
Keep one UI test: a dedicated spec still covers the real login form.
Programmatic login: everywhere else, log in with cy.request and skip the form.
Cache it: cy.session saves cookies and storage under a key, restores them later, and re-creates them if validate fails.
"I keep one or two tests that log in through the form, because the login page itself needs testing. Everywhere else, the form is wasted time. First I switch to a programmatic login: a cy.request to the login endpoint, which sets the session cookie or returns a token I can store. Then I wrap that in cy.session. It takes an id, like the user's email, and a setup function. The first time, it runs setup and saves the cookies, local storage and session storage. Next time a test asks for the same id, it restores them instead of logging in again. I also pass a validate function that checks the session still works, for example that the profile endpoint answers 200, so an expired token gets recreated instead of failing strangely. And because the page is cleared after the session step, I visit the page afterwards."
Cypress.Commands.add('login', (email, password) => {
cy.session(
email,
() => {
cy.request('POST', '/api/login', { email, password })
},
{
validate() {
cy.request('/api/me').its('status').should('eq', 200)
},
}
)
})
beforeEach(() => {
cy.login('kim@example.com', 'test-password')
cy.visit('/dashboard')
})
Turning off test isolation so one login carries through every later test.
Define: Cypress.Commands.add in the support commands file, which loads before every spec.
Make it reliable: scoped selectors and a wait on the request inside the command.
Type it: extend the Chainable interface in the Cypress namespace.
Scope: commands for user-level steps in the chain; plain functions for pure helpers.
"Say lots of tests add an item to the cart: find the product card, click add, wait for the cart request. I put that in the support commands file with Cypress.Commands.add, name it addToCart, and have it take the product name. Inside, I follow the same practices as in tests: a scoped selector, an intercept alias and a wait on it, so every caller gets a reliable step. Because commands are added at runtime, TypeScript doesn't know about them, so I add a declaration that extends the Chainable interface in the Cypress namespace. Then cy.addToCart autocompletes and gets type-checked. I don't turn everything into a command, though. If something doesn't need to be in the Cypress chain, like building a test user object, a normal imported function is simpler and easier to test."
// cypress/support/commands.ts
Cypress.Commands.add('addToCart', (productName: string) => {
cy.intercept('POST', '/api/cart').as('addToCart')
cy.contains('[data-cy=product-card]', productName)
.find('[data-cy=add-to-cart]')
.click()
cy.wait('@addToCart')
})
declare global {
namespace Cypress {
interface Chainable {
addToCart(productName: string): Chainable<void>
}
}
}
export {}
Hiding assertions and branching logic inside commands so a failure no longer shows which step broke.
Page objects: fine if thin: selectors and small methods that return cy chains.
Custom commands: fit shared steps that cross pages, like login.
App actions: set state directly through the API or the app, skipping UI covered elsewhere.
Choice: a mix, judged by readability and speed.
"I don't treat it as either-or. Page objects work in Cypress if they're thin: a module per page holding selectors and small methods that return cy chains, so tests read like user steps. They go wrong when they hold elements in fields, hide assertions or try to keep state, because Cypress commands are queued, not immediate, so a stored element is just a chainable from some earlier moment. For steps that cross pages, like login or filling a cart, custom commands fit better. The third idea is app actions: instead of clicking through screens to reach a state, I set it directly, through the API, or where the app exposes helpers in test builds, through its own functions on the window. That's much faster, and fine because that UI flow has its own test. So on a real project I'd mix: thin page modules for selectors, commands for shared steps, API or app actions for setup."
Copying a page object that stores elements in fields, which breaks with Cypress's queued commands.
Starting point: size, run time and how much the team trusted it.
Problems: the two or three biggest, with evidence.
Changes: in priority order, and why that order.
Result: run time, stability and whether people read failures again.
"At my last company I inherited about eighty specs that took over forty minutes in CI and failed often enough that developers ignored them. I spent the first week reading failures instead of writing tests. Three things stood out: every test logged in through the UI, most setup data was created by clicking through admin screens, and selectors were long CSS paths. I fixed login first, since it touched everything, with a programmatic login command wrapped in cy.session. Then I moved data setup to API calls behind a custom command and a couple of tasks. Selectors I changed gradually, asking developers to add data-cy attributes as they touched each screen. Run time came down to around fifteen minutes before we added any parallel machines, and once failures meant real bugs, developers started reading them again, which was the real win."
Starting with a full rewrite without measuring what was actually wrong.
Component test: mounts one component in a real browser using the project's own build setup, with no full app.
vs end-to-end: e2e visits the running app and goes through routing, APIs and real pages.
vs Node unit test: those render into a simulated DOM; component tests get real layout, CSS and events.
Use: many states of one component, quickly; e2e for a few key journeys.
"A component test mounts a single component, say a date picker, in a real browser using the project's own bundler setup, with the props I choose. There's no full app, no routing and usually no backend; I stub whatever it calls. An end-to-end test is the opposite. It visits the running application and goes through real pages and APIs, so it catches integration problems but is slower and harder to set up for edge cases. Compared with a unit test in a Node runner that renders into a simulated DOM, a Cypress component test runs in an actual browser, so layout, CSS, focus and real events behave as they will for users, and I can watch it in the runner. I use component tests for the many states of one piece, like disabled, error or very long text, and keep end-to-end tests for a handful of journeys that matter most."
Saying component tests replace end-to-end tests, or that they run in a fake DOM.
Mount: cy.mount with props, passing a cy.stub() as the callback.
Act and assert: click like a user, check the visible value.
Check the contract: assert the stub was called with the expected argument.
"In a React project, I mount the component with cy.mount and pass its props, including onChange set to a cy.stub with an alias, so I can check how it talks to its parent. Then I test it the way a user would: check the displayed value starts at 1, click plus, and check it shows 2. Last, I check the stub was called with 2. That covers both what the user sees and the component's contract with whoever uses it. I'd add a few more cases in the same file, like the minus button being disabled at 1 and the upper limit, because component tests are cheap enough to cover edge states that would be painful to reach in an end-to-end test. The mount command itself comes from the framework adapter, which the component testing setup registers in the component support file."
// QuantityPicker.cy.jsx, React adapter
import QuantityPicker from './QuantityPicker'
describe('QuantityPicker', () => {
it('increments and reports the new value', () => {
const onChange = cy.stub().as('onChange')
cy.mount(<QuantityPicker initial={1} onChange={onChange} />)
cy.get('[data-cy=qty-value]').should('have.text', '1')
cy.get('[data-cy=qty-plus]').click()
cy.get('[data-cy=qty-value]').should('have.text', '2')
cy.get('@onChange').should('have.been.calledWith', 2)
})
})
Only asserting the stub was called and never checking what the user actually sees.
Install: clean install, caching both node modules and the Cypress binary, which downloads separately.
Start and wait: start the app, wait until it answers, then cypress run, which is headless by default.
Config: base URL and secrets from CI environment variables, not the repo.
Artifacts: upload screenshots, videos if enabled, and a JUnit-style report.
"In CI I use cypress run, not open, so it runs headless and exits with a non-zero code when anything fails, which fails the job. The pipeline does a clean install, and I cache both the node modules and the Cypress binary cache, because the binary is a separate download and fetching it every time is slow. The app has to be up before tests start, so I either point at a deployed environment or start it in the job and wait until the URL responds, with a helper like start-server-and-test, instead of sleeping. Base URL and credentials come from CI environment variables. On failure, Cypress saves screenshots automatically in run mode, so I upload that folder and any videos as build artifacts, and I add a JUnit reporter so CI shows which tests failed. I also pin the browser with the browser flag so runs stay consistent."
npm ci
npx start-server-and-test start http://localhost:3000 'cypress run --browser chrome'
Using a fixed sleep to wait for the app to start, or trying to run cypress open in the pipeline.
Unit of work: spec files, not single tests; one cypress run works through its specs one after another.
Across machines: several CI machines each take a share of the specs.
Balancing: recording to Cypress Cloud with --parallel hands out specs by past duration; without it, split by timing data yourself.
Design: break up giant specs, keep specs independent, give each machine its own data.
"The first thing to know is that Cypress parallelises by spec file, and a single cypress run goes through its specs one after another. So speed comes from running several CI machines at once, each taking a share. The built-in way is to record to Cypress Cloud and pass the parallel flag with the same build id on every machine; the service hands out specs using their past durations so the machines finish around the same time. Without that, I'd split specs myself with the spec flag, using timing data rather than alphabetical order. For 300 specs, I'd first look at spec durations, because one twenty-minute spec sets the floor however many machines I add, so I'd break the big ones up. Then I'd make sure specs share no data, for example each one creates its own users, and pick the machine count by watching where adding more stops helping."
Believing Cypress runs the tests inside one spec in parallel, or splitting specs without looking at how long each one takes.
Measure: slowest specs, and how much of each test is setup.
Cut waste: programmatic login and seeding, no fixed waits, edge cases moved to component or API tests.
Tier it: a smoke set on every pull request, the full suite on merge or nightly, both in parallel.
Keep trust: quarantine known flaky tests, and track them.
"I'd start with data, not machines. I'd list specs by duration and look at how much of each test is setup. Usually the biggest wins are logging in through the UI, creating data by clicking, and fixed waits, and fixing those alone can cut the time a lot. Next I'd look at what's tested end to end that doesn't need to be. Form validation messages, or every state of a widget, belong in component or API tests that run in seconds. Then I'd split the suite. A smoke set covering the main journeys runs on every pull request across a few parallel machines and should finish in minutes. The full suite runs on merge to main and nightly, also in parallel. Finally, I'd quarantine known flaky tests so they don't block pull requests, but track them, because a fast suite nobody trusts is still useless."
Only asking for more CI machines, or deleting slow tests without checking what coverage goes with them.
Evidence: the failure screenshot, video, error message and history across runs.
Usual causes: racing a request, re-renders or animation, shared or leftover data, fixed waits, order dependence.
Reproduce: repeat the test many times, alone and inside the full spec, with key requests slowed down.
Fix the cause: alias waits, better assertions, unique data; retries only as a safety net.
"First I collect evidence instead of rerunning and hoping. I look at the failure screenshot, the video if there is one, and the error: did it time out finding something, click the wrong thing, or see stale data? Then I look for a pattern across runs, like only on one machine or only after a certain spec. The usual culprits are a test racing a request it never waited for, an element that re-renders or animates while being clicked, data shared with another test or left over from an earlier run, a fixed wait that's sometimes too short, and a test relying on order. To reproduce, I run it many times in a loop, which the bundled lodash times helper makes easy, and I slow the key requests down with a delay on an intercept to widen the race. Once I see the cause, I fix it, usually with an intercept alias, a stronger assertion before acting, or unique data. Retries come afterwards, as a safety net, not the fix."
Cypress._.times(25, (i) => {
it('applies a coupon at checkout, run ' + (i + 1), () => {
// same steps as the flaky test
})
})
Rerunning until green, or adding a longer wait without knowing what it was waiting for.
Situation: the test, how often it failed and where.
Evidence: what the screenshot, video or logs showed.
Cause and fix: the real difference between CI and local, and the change you made.
Proof: repeated runs, and how you stopped the pattern coming back.
"At my last company, a checkout test passed every time for me but failed in CI a few times a week, always at the step that applies a discount code. The failure screenshot showed the old total still on screen. The video made it clear: the CI machine was slower, and the test typed the code and checked the total before the pricing request had come back. On my laptop the request was fast enough that it never showed up. There was also a debounce on the input, so the request only fired after typing stopped. The fix was to intercept the pricing call, wait on its alias, and only then assert the new total. I proved it by running that spec in a loop fifty times on the CI runner, and it passed every time. Then I searched the suite for the same pattern and fixed four more tests."
A story where the fix was a longer wait or turning on retries, with no explanation of the cause.
Look first: is it the test or the app? Read the failures before deciding.
Short term: a retry on that one test, in run mode only, with a ticket and an owner.
Why not global: suite-wide retries hide new flakes and real intermittent bugs.
After release: fix the cause and remove the retry.
"I'd first spend twenty minutes on the failures themselves. If the screenshots show the app doing something wrong, like a wrong total or a double submit, that may be a real intermittent bug, and it matters more than the date, so I'd raise it with the lead tonight. If it's clearly the test, say it races a request, I'm fine with a retry on that one test in run mode, with a ticket and an owner. What I'd push back on is three retries across the whole suite. That quietly hides every new flaky test and every real bug that only shows up sometimes, and nobody notices because the report is green. A small global retry can be fine later, but only if tests that pass on retry are tracked and reviewed. After the release, I'd fix the cause and take the retry off."
// temporary: pricing race, tracked in the flaky-tests ticket
it('applies a discount code', { retries: { runMode: 2, openMode: 0 } }, () => {
// test steps
})
Agreeing to global retries without looking at the failures, or blocking the release over a test nobody has investigated.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.