I Wrote 800 Cypress Tests. Then Playwright Came Out. Here's the Honest Story.
The first Cypress test I ever wrote was a login flow for a SaaS dashboard sometime in late 2019. I had spent the previous week wrestling with Selenium and a flaky Chromedriver, and a colleague kept telling me to "just try Cypress, it's different." I installed it, ran npx cypress open, and a window popped up showing my actual application running next to a list of commands, and I could click any command in the sidebar and watch the DOM rewind to that exact moment.
I think I said "oh" out loud. The kind of "oh" that means a tool just made a problem you'd been brute-forcing for years go away.
For the next four years, Cypress was the tool. Not "a tool we considered" the tool. We had eight hundred tests. We had a Cypress Cloud subscription. We had custom commands for everything. We had a wiki page called "How to debug a flaky Cypress test" that was longer than most onboarding documents. I gave talks about Cypress at meetups. I argued for Cypress in code review. I was a Cypress person.
And then, one Tuesday in mid-2024, I sat down to write a test for a feature that opened a popup window, and I remembered for what felt like the hundredth time that Cypress can't open a second tab. And I looked at the Playwright docs that I had been deliberately avoiding for two years, because deep down I knew what I would find. And I found it.
This article is the long version of what I found. It's also a love letter, because Cypress is genuinely a brilliant piece of software and the philosophy behind it changed how I think about testing. But it's an honest love letter, which means it has to acknowledge that the world has moved. As of April 2026, Playwright has roughly seven times Cypress's weekly npm downloads. The crossover happened in June 2024. There are real reasons that happened, and there are also real reasons Cypress is still installed on millions of machines and shipping a new release every two weeks.
If you're picking a testing tool today, you need both halves of the story. Get coffee. We're going to walk through what Cypress actually is, what makes it special, what it can't do, and where it sits in 2026.
What Cypress Actually Is (Architecturally, Which Is The Whole Story)
Most articles about Cypress start with "it's an end-to-end testing framework" and move on. That sentence is correct and useless. The thing you have to understand about Cypress the thing that explains every strength and every weakness, every pleasant surprise and every infuriating limitation is where the test code runs.
In Selenium, in Playwright, in basically every other browser-based testing tool, your test code runs in a Node.js process, outside the browser. The test process talks to the browser through a protocol WebDriver in Selenium's case, Chrome DevTools Protocol in Playwright's. When you write await page.click('button'), that's a Node-side instruction that travels over a wire, gets executed inside the browser, and reports back.
Cypress doesn't do this. Cypress runs your test code inside the browser, in the same JavaScript event loop as the application under test. There's a Node.js process that handles file watching, network proxying, and CI orchestration. But the actual cy.get('.button').click() you wrote runs in an iframe that's a sibling of your app, in the same tab, with full direct access to the DOM, the window object, and every JavaScript runtime detail.
This decision is the single most important thing about Cypress. It's why Cypress feels magical when it works and frustrating when it doesn't.
Because the test code lives in the browser, Cypress can do things other tools can't easily do. It can synchronously inspect any DOM node. It can stub window.fetch at the source. It can take a complete snapshot of the DOM after every command and let you scrub through them like a video. It can show you the application running while the tests run, with a sidebar of commands you can click to see exactly what the page looked like at that moment. The "time-travel debugger" everyone raves about isn't a feature so much as a free side effect of where the code lives.
But the same architectural choice forecloses certain things permanently. Browsers are designed so that code in one tab cannot control another tab. So Cypress, by definition, cannot test multi-tab flows there's no second tab for it to reach into. The same-origin policy, which is fundamental to web security, means Cypress can't navigate to a different origin and keep running, because that would be a sandbox violation. (They've worked around this with cy.origin(), but the workaround has its own limits.) Real Safari support is hard, because Safari's WebKit doesn't expose the same hooks Chromium does for in-browser test injection. Parallelism is hard, because each browser instance needs its own coordinator and the natural parallelism of a Node process spawning workers doesn't apply.
If you keep this architectural difference in mind, every Cypress strength and every Cypress limitation makes immediate sense. It's not arbitrary product design. It's the consequence of a single decision made in 2014 and lived with ever since.
How Cypress Got Big
Brian Mann started Cypress around 2014 because he was tired of writing brittle Selenium tests for his own team. The first commit landed on June 5, 2014. Public beta was October 2017. Commercial launch came in October 2018. By 2019, every JavaScript developer I knew had at least heard of it, and most had tried it.
The reason it caught on was not architectural cleverness most users didn't and don't care how it works inside. The reason was that writing the first test was joyful. You ran cypress open and the runner showed you your app and the test side by side. You could see the test run. You could click any command in the log to time-travel. When something failed, the runner showed you exactly which element wasn't found, with a screenshot of the page at the moment of failure. There were no Selenium grids to set up, no Chromedriver versions to manage, no flakiness from "stale element reference" errors. Tests just worked, mostly, and when they didn't, you could see why.
Compared to Selenium which had been the only serious option for ten years this was a revelation. Compared to the other test frameworks that existed at the time (Nightwatch, WebdriverIO, TestCafe), Cypress just felt nicer. The docs were good. The error messages were good. The DX was, frankly, an order of magnitude better than what came before.
By 2021, Cypress had been adopted by Slack, Disney, the NBA, Netflix, Shopify, LEGO, NASA, GitHub, Vercel, and Netlify (their own marketing materials, which is to say: don't quote this as a current state of affairs, since several of those teams have publicly or quietly migrated since). The company had raised $54M across a 2019 Series A and a 2020 Series B. They launched Cypress Dashboard (later renamed Cypress Cloud) as the paid product. Things were, by every metric, going great.
Then Microsoft launched Playwright in 2020.
The Anatomy of a Cypress Test
Before we get to the comparison, let's talk about what writing Cypress tests actually feels like, because this is the part that people either fall in love with or get frustrated by within twenty minutes.
A typical Cypress test looks like this:
describe('login flow', () => {
beforeEach(() => {
cy.visit('/login')
})
it('logs in with valid credentials', () => {
cy.get('[data-cy=email]').type('jane@example.com')
cy.get('[data-cy=password]').type('correct-password')
cy.get('[data-cy=submit]').click()
cy.url().should('include', '/dashboard')
cy.contains('Welcome back, Jane').should('be.visible')
})
})
There are several things going on here that look like JavaScript but aren't.
cy.get is not a function call that returns an element. It's a command that gets queued. When you write cy.get('[data-cy=email]').type('jane@example.com'), you are not actually getting the email field and then typing into it. You are appending two commands to a queue. After your it block returns, Cypress walks the queue and executes the commands one at a time, with each command's result feeding into the next.
This is the part that breaks people's brains, so let me say it again with feeling. Cypress commands are not promises. Cypress commands cannot be awaited. The official docs say so explicitly: "While the API may look similar to Promises, with its then() syntax, Cypress commands and queries are not promises they are serial commands passed into a central queue, to be executed asynchronously at a later date."
This means the following code, which you will absolutely write at some point, does not work:
// DOES NOT WORK
const text = await cy.get('.title')
console.log(text) // undefined
The await succeeds sort of but text is not the element. It's whatever Cypress decides to return, which is not what you want. The correct version is:
cy.get('.title').then($el => {
const text = $el.text()
// do something with text inside this callback
})
You have to do everything inside the chain. You can't extract a value, do some plain JavaScript with it, and then resume Cypress commands as if they were normal async/await code. The chain is the whole world.
The first time this hits you, it feels like a bug. The second time, it feels like an inconvenience. The hundredth time, after you've worked around it with custom commands and aliases and cy.then callbacks for two years, it feels like the cost of admission. Some developers love it, because the queue-based model is what enables the magic of automatic waiting and time-travel debugging. Some developers hate it, because it forecloses entire categories of normal JavaScript patterns.
GitHub issue #1417, "Await-ing Cypress Chains," is one of the most-engaged issues in the project's history. The answer, which has been the answer since 2017, is still no. Cypress's whole architecture would have to change.
The Best Thing Cypress Got Right: Implicit Assertions and Auto-Waiting
Here's the thing Cypress nailed and nobody else had really cracked at the time. Look at this:
cy.get('.notification').should('contain', 'Saved')
This command does something subtle. It tries to find .notification. If it doesn't exist yet, it retries. If it exists but doesn't contain "Saved" yet, it retries. If both conditions become true within the timeout (default four seconds), the command passes. If neither is true after four seconds, it fails.
You don't write await page.waitForSelector followed by a separate assertion. The assertion is the wait. The wait is the assertion. They're the same thing.
This was the answer to the most common form of test flakiness. In Selenium tests, you'd write something like:
await driver.wait(until.elementLocated(By.css('.notification')), 5000)
const text = await driver.findElement(By.css('.notification')).getText()
expect(text).to.contain('Saved')
Three operations, three opportunities for a race condition. You wait for the element to be located, then you find it (it might have re-rendered between those steps), then you get its text (it might have changed). Cypress collapses all of this into one retry-able assertion. The element either exists and contains the text within four seconds, or it doesn't. If your test is going to be flaky here, it'll be flaky on this one line, not on three different lines for three different reasons.
This pattern alone is responsible for a lot of Cypress's reputation for "non-flaky" tests. It's not that the tool magically eliminated race conditions. It's that the API made the right thing easy to write.
Playwright eventually adopted similar patterns expect(locator).toContainText(...) retries the way Cypress's should does but Cypress had it first, and for a few years that mattered.
cy.intercept: Network Stubbing That Doesn't Suck
Cypress 6.0, released in December 2020, introduced cy.intercept. Before it, network stubbing was done with cy.route and cy.server, which only worked for XHR meaning if your app used fetch (which by 2020 most apps did), you were out of luck without a workaround.
cy.intercept works at the proxy layer. It catches every network request the page makes, regardless of whether it's XMLHttpRequest, fetch, an iframe load, a WebSocket handshake, or a static asset. You can spy on requests, stub them with static responses, or attach handlers that modify requests and responses on the fly:
// Spy mode wait for the request, don't change it
cy.intercept('GET', '/api/users').as('getUsers')
cy.visit('/users')
cy.wait('@getUsers')
// Static stub
cy.intercept('GET', '/api/users', { fixture: 'users.json' })
// Dynamic handler
cy.intercept('POST', '/api/login', (req) => {
if (req.body.password === 'correct') {
req.reply({ statusCode: 200, body: { token: 'abc123' } })
} else {
req.reply({ statusCode: 401, body: { error: 'Invalid credentials' } })
}
})
The cy.wait('@alias') pattern is the real magic. Instead of cy.wait(2000), you wait until a specific network request happens. Tests get faster (you only wait as long as you need to) and less flaky (you're not guessing how long the network will take). The Cypress best practices page is dogmatic about this, and it's right. Every time I see cy.wait(5000) in a codebase, I know I'm about to find a flaky test.
cy.intercept is, hands down, my favorite Cypress feature. It's the part of the API that has aged the best, and it's still genuinely competitive with Playwright's page.route (which works similarly but with slightly different ergonomics).
The Famous Errors
You can't write about Cypress without writing about its errors, because every Cypress developer alive has seen these specific messages and felt the specific kind of dread they produce.
"This element is detached from the DOM." Here's the actual text:
CypressError: Timed out retrying after 4050ms: cy.click() failed because this element is detached from the DOM. Cypress requires elements be attached in the DOM to interact with them. The previous command that ran was: > cy.should() This DOM element likely became detached somewhere between the previous and current command. Common situations why this happens: - Your JS framework re-rendered asynchronously - Your app code reacted to an event firing and removed the element
What this means: Cypress found the element, was about to click it, and in the millisecond between finding and clicking, your React component re-rendered and replaced the DOM node with a new one. The reference Cypress has is now pointing to a node that no longer exists in the document.
The fix is usually to re-query break the chain so Cypress refetches the element right before the click. Cypress 12 in December 2022 made this much better by rewriting the query commands to retry-and-re-query automatically in many cases. It still happens, especially with libraries like react-select or Material UI's autocompletes that do aggressive async re-rendering. GitHub issues #5743, #6215, #7306, #17043, #25863 are all this same problem with collectively thousands of upvotes.
"Cypress detected a cross origin error." Exact text:
CypressError: Cypress detected a cross origin error happened on page load: Blocked a frame with origin "http://example.com" from accessing a cross-origin frame.
Before Cypress 9.6 in April 2022, this was a near-fatal limitation. If your test needed to navigate to a different origin say, an OAuth provider you basically couldn't. The workaround was chromeWebSecurity: false, which disabled most of the browser's security model for the test run, and even that didn't always work.
cy.origin('https://other.example.com', () => { ... }) is the modern fix. It runs the callback in the context of the other origin. It works, but with caveats. You can't nest origins. The callback runs in a fresh JavaScript context, so closures over outer-scope variables don't work you have to explicitly pass them in via the args option. And the syntax is, frankly, awkward enough that I've seen teams just write OAuth flows as API requests in cy.request() instead, bypassing the UI entirely.
"Cannot click on multiple elements." Cypress, by design, refuses to operate on multiple matching elements unless you explicitly ask for them. This is good philosophy your test should know exactly which element it's targeting but it produces this error any time your selector matches more than one thing, and figuring out which extra element matched can take longer than you'd think.
The chain is broken errors when you accidentally try to use a Cypress command outside the chain context, or you await something you shouldn't have, the error messages are detailed but not always actionable. New users hit these constantly.
cy.session: The Quiet Revolution
If you've been writing Cypress tests for years and still log in via the UI in beforeEach, please stop. cy.session() exists. It's been GA since Cypress 12 in December 2022. With cacheAcrossSpecs: true, you log in once per test run instead of once per test:
const login = (email) => {
cy.session(
email,
() => {
cy.visit('/login')
cy.get('[data-cy=email]').type(email)
cy.get('[data-cy=password]').type(Cypress.env('PASSWORD'))
cy.get('[data-cy=submit]').click()
cy.url().should('include', '/dashboard')
},
{ cacheAcrossSpecs: true }
)
}
beforeEach(() => {
login('jane@example.com')
cy.visit('/some-page')
})
The first time login('jane@example.com') runs, it executes the callback. Cypress captures all the cookies, localStorage, and sessionStorage at the end. Every subsequent call with the same key restores those cookies and storage instead of running the callback again. On a 100-test suite where logging in takes two seconds, this saves more than three minutes per run.
Filip Hric's blog post "Use cy.session instead of login page object" is the canonical write-up if you want to dig deeper. The pattern took a while to spread because it was experimental for so long, but in 2026 there's no excuse for not using it.
Component Testing in Cypress
Cypress 10, released June 1, 2022, made Component Testing a first-class mode. You can mount React, Vue, Angular, or Svelte components directly in the runner and test them in isolation:
import Stepper from './Stepper'
describe('<Stepper />', () => {
it('increments and decrements', () => {
cy.mount(<Stepper initialValue={0} />)
cy.get('[data-cy=increment]').click()
cy.contains('1').should('be.visible')
cy.get('[data-cy=decrement]').click()
cy.contains('0').should('be.visible')
})
})
The pitch is real: you're testing in a real browser, with real CSS, real layout, real DOM. JSDOM (which React Testing Library uses by default) lies about a lot of layout-related things. Visibility checks are unreliable. Hover states don't work. CSS that depends on the viewport doesn't render correctly. Cypress component tests don't have those problems.
So why hasn't it taken over? Two reasons.
First, RTL plus Vitest is faster. A lot faster. Cypress component tests need to boot a browser, load a dev server, and render the component, all per spec file. Vitest tests run in milliseconds and parallelize across CPU cores trivially. For most components buttons, forms, data displays JSDOM is fine, and the speed difference matters more than the fidelity difference.
Second, the React testing ecosystem standardized on RTL in 2020, and that gravity is hard to escape. Most teams have RTL tests already. Most tutorials use RTL. The "Testing Library" mental model (test what the user sees, not implementation details) is what every senior React developer absorbed.
Cypress component testing is genuinely useful for visual-heavy components, complex hover/focus interactions, drag-and-drop, anything where the real DOM matters. For most teams it ends up being a complement to RTL, not a replacement.
Cypress Cloud and the Cost Question
The free Cypress runner is MIT-licensed and complete. You can run thousands of tests on your laptop or in CI without paying anyone. Where Cypress.io makes its money is Cypress Cloud, which records test runs to a dashboard, provides screenshots and videos, and crucially handles parallelization.
Here's the part that drives migrations. Parallelization is paywalled. If you want to split your test suite across multiple CI machines to make it run faster, you need a Cloud subscription. The free tier gives you 500 test results per month, which a real CI pipeline can chew through in a day or two.
Pricing as of early 2026 looks roughly like:
- Free / Starter: 500 results/month, 3 users, 30-day retention
- Team: around $67/month
- Business: around $267/month
- Enterprise: custom, typically $15K to $40K+ annually for mid-market
For a one-developer side project, free is fine. For a five-person team, Team works. For a real product with a proper CI pipeline, you're either paying low four figures a year for Business or you're rolling your own parallelization with cypress-split or Sorry Cypress or Currents.dev.
This pricing model free OSS tool, paid coordination layer is the same model GitLab and HashiCorp use, and it's defensible. But for testing tools, where Playwright offers parallelization for free, it stings. The BigBinary blog post "Why we switched from Cypress to Playwright" cites paywalled parallelization as one of their two main migration triggers, alongside the inability to bypass third-party bot detection in real OAuth flows.
Cypress Cloud has shipped genuinely impressive new features in the last two years Test Replay (think Playwright's Trace Viewer but for CI runs), UI Coverage (visualizes what percent of your UI is exercised by tests), Cypress Accessibility (axe-core scans on every recorded run), and recently Cloud MCP for AI assistants. These are real and they solve real problems. But they're also enterprise features at enterprise prices, and the message they send is clear: Cypress.io's commercial focus has moved upmarket.
The Playwright Comparison We've Been Avoiding
Let's just do this.
In June 2024, Playwright passed Cypress in weekly npm downloads for the first time. Since then the gap has only widened. As of late April 2026, Playwright sits around 48 million weekly downloads to Cypress's 7 million. State of JS 2024 showed Playwright leading on retention (the percentage of users who say they'd use it again) at around 94%, with Cypress around 74%. Playwright's GitHub stars are roughly 86,000 to Cypress's 49,600.
These numbers are not close.
The reasons, in approximate order of how often I hear them cited by teams that migrated:
Free parallelization. Playwright parallelizes across CPU cores by default. No paid cloud needed. For teams that were paying mid-four-figures a year for Cypress Cloud just to get tests running fast in CI, this alone justifies the migration.
Multi-browser, including real WebKit. Playwright runs against Chromium, Firefox, and WebKit (which is Safari's engine). Cypress can run against Chrome, Edge, Firefox, and Electron, but not WebKit. If your users use Safari and on iOS, your users always use Safari Playwright actually tests their browser. Cypress doesn't.
Multi-tab, multi-origin, multi-context. Cypress can't open a second tab. Playwright can, trivially. Cypress's cy.origin works but has limitations. Playwright treats different origins as a non-event.
Real async/await. Playwright tests are normal async JavaScript. You can await anything. You can mix Playwright calls with regular function calls and Promises without thinking about it. The mental model is just JavaScript. Coming from Cypress, this feels like a weight lifting.
Speed. The BigBinary writeup cites their auth flow going from two minutes in Cypress to under twenty seconds in Playwright. One of their isolated tests went from 16 seconds to 1.8 an 88% speedup. Playwright's out-of-process design makes parallelism easy, and it's just generally faster at routine operations.
Trace Viewer. Playwright's equivalent of Cypress's time-travel debugger is Trace Viewer, and it's good. You get DOM snapshots, network logs, console logs, action timelines, all for every test run, all without paying anyone.
These are real advantages and they're not subjective. If you're greenfielding a project in 2026 and you don't have a strong reason to pick Cypress, you should probably pick Playwright. I say this as someone with eight hundred Cypress tests in production and tattoos that say "data-cy" on them figuratively.
So why am I writing this article instead of an article called "Don't Use Cypress"? Because Cypress still has things going for it.
The runner experience the live, interactive GUI where you watch your tests run alongside your application is still better than Playwright's UI mode in some ways, especially for debugging and authoring new tests. The time-travel debugger is still uniquely good. The community knowledge base the Stack Overflow answers, the recipe blogs, the years of accumulated patterns is enormous, even compared to Playwright. Component testing in a real browser is a genuine niche that Playwright doesn't really compete in. And for teams that already have a thousand Cypress tests in production, the migration cost is non-trivial. We did the math at one point: at our pace, migrating our suite would have taken about four engineering months. That's a lot of months.
If you're starting fresh: Playwright. If you're considering migrating: do the math. If you're staying on Cypress: that's a perfectly defensible choice in 2026, you just need to know what you're trading off.
Best Practices That Actually Matter
If you're going to write Cypress tests, the official best practices page is genuinely worth reading top to bottom. The greatest hits, with my commentary:
Use data-cy (or data-test) attributes for selectors. Not classes. Not IDs. Classes change for styling reasons. IDs sometimes change for accessibility reasons. data-cy exists for one reason to be a stable selector for tests and that's its whole job. The Cypress Selector Playground autodetects them.
Don't write conditional tests. If you find yourself writing if (element exists) { do thing } else { do other thing }, your test isn't deterministic. Either the thing is going to happen or it isn't. Pick one and assert on it.
Don't share state between tests. Each test should set up everything it needs. Use cy.session for auth, cy.task to seed your database, cy.intercept to control the network. Don't rely on test A having logged in before test B runs.
Don't use cy.wait(milliseconds). Ever. This is the single biggest cause of slow, flaky Cypress tests in the wild. Wait on aliases (cy.wait('@apiCall')) or wait on assertions (cy.get('.thing').should('be.visible')). Cypress's whole retry-ability machinery is designed to make cy.wait(5000) unnecessary. If you're using it, you're fighting the framework.
Always start a new chain after an action. Don't try to chain queries through a .click(). Click ends a chain. Start fresh:
// Sketchy
cy.get('.btn').click().get('.result')
// Better
cy.get('.btn').click()
cy.get('.result')
Don't test multiple flows in one test. A test should do one thing. If you find yourself writing it('handles signup, login, and password reset'), split it. Cypress is fast enough that the overhead per test is minimal compared to the debugging cost when one big test fails for unclear reasons.
Don't use traditional Page Object Models. This one is contentious. Cypress's official guidance is to skip the POM pattern and use custom commands plus data-cy attributes instead. The community is split there are a thousand Cypress POM tutorials online but I've come around to Cypress's view. POMs add a layer of abstraction that doesn't pay off in a queue-based command model. Custom commands give you the same encapsulation with less ceremony.
Use Cypress.Commands.add for repeated patterns. A cy.login(email) command, once written, makes every subsequent test cleaner. Same for any UI flow that repeats opening a dialog, filling a form, navigating to a tab.
What's Still Hard
Honesty corner. There are things Cypress is just bad at, even in 2026:
iframes. They're better than they used to be, but still awkward. The cypress-iframe plugin helps. Tests that involve embedded payment forms (Stripe Elements, etc.) are usually either painful or written as API tests instead.
File downloads. Cypress has no built-in way to verify a file was downloaded. You either intercept the download URL and check the response, or you use a plugin like cypress-downloadfile, or you skip it.
Mobile emulation. You can set a viewport size, but real mobile emulation touch events, mobile-specific network conditions, device frames is limited. Playwright is significantly better here.
Visual regression testing. Cypress doesn't have built-in visual diffing. You bolt on Percy, Applitools, or Cypress Cloud's own UI Coverage feature. Each costs money. Playwright has built-in screenshot comparison.
WebKit/Safari. Just no. Not happening. The architecture forecloses it.
Performance testing. Cypress is for functional testing. If you want Lighthouse-style metrics or detailed performance traces, you're in Playwright territory.
The async chain still trips up new developers. I've onboarded probably a dozen engineers onto Cypress over the years, and every single one hit the "why doesn't await cy.get() work" wall in their first week. It's a well-trodden path with good explanations available, but it's still a path.
When to Pick Cypress in 2026
After all that, here's my honest recommendation framework.
Pick Cypress if: you primarily test in Chromium, you value a live interactive runner above all else, your team is small enough that the free tier or Team tier is enough, you don't need multi-tab or multi-origin in serious ways, your existing team already knows Cypress, you have an existing Cypress test suite that's working, or you genuinely prefer the queue/chain mental model after trying both.
Pick Playwright if: you need cross-browser including WebKit, you have a complex CI parallelization need, you need multi-tab or multi-context flows, you're starting fresh in 2026 with no existing tests and no team preference, you want async/await to just work, or your application includes serious mobile flows.
Pick neither if: you're testing a static site or a simple form, in which case Cypress and Playwright are both overkill and you should just write a few integration tests with Vitest plus jsdom and call it a day.
For component testing specifically, default to React Testing Library plus Vitest, and reach for Cypress component testing only when you have specific real-browser needs.
A Realistic Cypress Test File in 2026
Here's what I think a healthy Cypress E2E test file looks like today:
describe('Checkout Flow', () => {
beforeEach(() => {
cy.session('checkout-user', () => {
cy.request('POST', '/api/test/login', {
email: 'test@example.com',
password: Cypress.env('TEST_PASSWORD'),
}).then(response => {
window.localStorage.setItem('token', response.body.token)
})
}, { cacheAcrossSpecs: true })
cy.intercept('GET', '/api/products*').as('getProducts')
cy.intercept('POST', '/api/orders').as('createOrder')
cy.visit('/shop')
cy.wait('@getProducts')
})
it('completes checkout with valid card', () => {
cy.get('[data-cy=product-card]').first().within(() => {
cy.get('[data-cy=add-to-cart]').click()
})
cy.get('[data-cy=cart-button]').click()
cy.get('[data-cy=checkout-button]').click()
cy.get('[data-cy=card-number]').type('4242424242424242')
cy.get('[data-cy=card-expiry]').type('1234')
cy.get('[data-cy=card-cvc]').type('123')
cy.get('[data-cy=pay-button]').click()
cy.wait('@createOrder').its('response.statusCode').should('eq', 201)
cy.url().should('include', '/order-confirmation')
cy.contains(/thank you/i).should('be.visible')
})
it('shows error on declined card', () => {
cy.intercept('POST', '/api/orders', {
statusCode: 402,
body: { error: 'Card declined' }
}).as('declinedOrder')
cy.get('[data-cy=product-card]').first().within(() => {
cy.get('[data-cy=add-to-cart]').click()
})
cy.get('[data-cy=cart-button]').click()
cy.get('[data-cy=checkout-button]').click()
cy.get('[data-cy=card-number]').type('4000000000000002')
cy.get('[data-cy=card-expiry]').type('1234')
cy.get('[data-cy=card-cvc]').type('123')
cy.get('[data-cy=pay-button]').click()
cy.wait('@declinedOrder')
cy.contains(/card declined/i).should('be.visible')
cy.url().should('not.include', '/order-confirmation')
})
})
Notice the patterns. Authentication via cy.session with cross-spec caching. Network calls aliased and waited on by alias, never by milliseconds. Selectors all use data-cy. Each test does one thing. Setup happens via the API where possible (the login bypasses the UI entirely with cy.request), reserving the UI exercise for the actual feature being tested. Stubbed responses for failure scenarios using cy.intercept's static stub mode. Assertions are should-based, leveraging implicit retry-ability.
If your Cypress tests look something like this, you're in good shape. If they look like a hundred lines of cy.wait(2000) and UI-based logins in beforeEach, you have an afternoon's worth of cleanup that will make your suite dramatically faster and less flaky.
The Closing Argument
Cypress was the first testing tool that made me enjoy writing browser tests. That's not a small thing. For ten years before Cypress, browser testing was a chore that everyone did badly because the tools were bad. Cypress changed the baseline expectation for what these tools should feel like. Every modern alternative, including Playwright, has been pulled forward by Cypress's example.
That doesn't mean it's the right tool for new projects today. The architectural choices that made Cypress feel magical in 2017 running in the browser, queue-based commands, time-travel by default are also the choices that have foreclosed multi-browser and multi-tab and easy parallelism, and the world has moved toward those things mattering more.
If I'm starting a new project tomorrow, I'm starting with Playwright. If I'm advising a team with an existing Cypress suite that's working, I'm telling them to keep it and use the time saved to ship features. If I'm hiring, I'm looking for people who understand testing principles, not specific tools, because both will be relevant for years to come.
The good news is that the principles are the same regardless. Test what the user does, not what the code does internally. Wait on conditions, not on time. Set up state programmatically, exercise the UI for the thing under test, assert on what's visible. Don't share state. Don't get clever. Whether you write that with cy.get or page.locator, the test that comes out the other side will be the same kind of test. The tool is a syntax. The thinking is the work.
Now if you'll excuse me, I have eight hundred tests to keep alive.
Top comments (0)