Cypress to Playwright migration, as sixteen real teams actually did it.
We cloned and read the end-to-end directories of 16 public products. Ten had finished a migration to Playwright or were partway through one. The pattern is the same everywhere: one spec at a time, both suites live in CI for months, and a wall that is never selector syntax. This page is the field notes, plus the order of work we would follow.
What we read, and what the numbers are worth
Sixteen public repositories, sparse-cloned to a scratch directory, read on their default branches, counted with grep, then deleted. Migration history came from commit logs and public pull request and issue bodies. The sample is weighted to Cypress, with a smaller cut of TestCafe, WebdriverIO and Puppeteer suites. A companion read covered 15 substantial Playwright suites, roughly 1,642 spec files and about 15,800 test call sites, and the two studies agree wherever they overlap.
Two honest limits. Every count is a call-site count, so parameterized loops distort test totals. And the sample is large, well-staffed open-source products. Small agencies and internal enterprise suites may look different, and we have no direct evidence either way.
Read September 2026. Numbers below are things we counted in these repositories. Nothing is extrapolated past the sample, and no figure here comes from a vendor.
The migration already happened at the top, and it took years
Ten of the 16 suites had completed or were mid-way through a move to Playwright. Five had removed Cypress entirely, three were carrying both, and two had arrived from other frameworks.
| Product | Framework history | State when we read it |
|---|---|---|
| Saleor Dashboard | Cypress → Playwright | Cypress removed July 2024 |
| freeCodeCamp | Cypress → Playwright | Cypress removed June 2024, 85 Playwright specs now |
| PostHog | Cypress → Playwright | Cypress removed August 2025 |
| n8n | Cypress → Playwright | Cypress removed October 2025, 161 files deleted |
| Grafana | Cypress → Playwright | Cypress removed April 2026 |
| Apache Superset | Cypress → Playwright | Active migration commits August 2026, 6 Cypress files left |
| Mattermost | Cypress + Playwright | 658 Cypress specs and 298 Playwright specs side by side |
| Appsmith | Cypress + Playwright | Playwright infrastructure landed May 2026, 6 specs so far |
| WordPress Gutenberg | Puppeteer → Playwright | 266 Playwright specs, one legacy Puppeteer test left |
| Oppia | Protractor → WebdriverIO → Puppeteer → Playwright | WebdriverIO infrastructure fully removed August 2026 |
The mechanics do not vary. One commit per spec, with titles like "migrate sharing spec from Cypress to Playwright"; one team landed at least 11 of those. Then a long dual-carry period where both suites run in CI. Then a final chore pull request that removes the framework. One product's dual carry ran roughly a year. Mattermost has carried both suites for over two years and still does, which is the honest upper bound on how long a large suite takes.
If you are planning this, the useful consequence is that half-migrated is the steady state, not a transitional embarrassment. Whatever tooling you pick has to show a Cypress spec and a Playwright spec in one place, for a long time. A big-bang converter matches nobody's observed behavior.
At scale, nobody writes raw Cypress. Your import surface is your own DSL
The cy.* API is not what your specs call. Your specs call the layer your team built on top of it, and that layer is where the migration cost lives.
What sits between specs and Cypress
- One large suite registers roughly 460 custom commands across 119 support files. Its specs are written against a server-API vocabulary: a single setup command appears 558 times, an API login command 493 times, a create-user command 268 times, a config-update command 245 times.
- Another routes almost everything through one imported helper namespace: a popover helper used 2,641 times, a state-restore helper 1,236 times, a modal helper 877 times.
- A third fronts interaction with a page-object directory under
cypress/support/Pages. - Cypress's own reference application wraps selection in a
getBySelcustom command, used 362 times.
What that means for the port
Most custom commands are thin wrappers over cy.request and cy.get, so a per-command mapping is feasible and worth doing once. The alternative is to keep the DSL and reimplement the runtime underneath it, so specs port with their vocabulary intact.
Either way the first artifact to read is cypress/support/, not cypress/e2e/. Count how many times each custom command is called. That call-frequency list is your migration backlog, ordered.
A rough proxy for the size of the job, runnable in your own repo:
grep -rn "Cypress.Commands.add" cypress/support | wc -l
grep -rohE "cy\.[a-zA-Z]+\(" cypress/e2e | sort | uniq -c | sort -rn | head -30
The second command tells you which verbs your suite actually speaks. In every suite we read, the top of that list was custom, not built in.
Auth and seeding is the wall
Selector syntax is a translation problem. Getting the app into state X cheaply, hundreds of times per run, is an architecture problem, and it is the one that stalls migrations.
cy.session is dead in the wild
We counted cy.session( across seven production Cypress suites. It appeared zero times in all seven. The only use in the whole sample is three calls in Cypress's own reference application.
What teams do instead, in every case: API login built on cy.request (933 cy.request calls in one suite, 158 in another), plus real state setup. One team bakes session and device identifier pairs into a snapshot JSON so tests never hit the login endpoint at all, and resets application state from database snapshots 1,236 times. Another spins a full docker-compose stack, then provisions users, teams and channels per test over the server API.
So the thing you are porting is not a login helper. It is a state machine your team wrote, and it usually talks to a database.
The publicly documented blocker
freeCodeCamp named its single biggest impediment in its own migration tracking issue. Cypress's cy.task() let them run Node code to seed or reset user data mid-test. Their Playwright setup had no equivalent, which forced a choice between canned user snapshots and manual API mocking.
That is the clearest public statement of the wall we found, and it matches what the counts imply everywhere else in the sample.
General observation, not a finding: Playwright test bodies already run in Node, so seeding is usually reachable as a direct call from a fixture rather than a task bridge. The mechanism is available. The work of rebuilding the seeding layer against it is still real work, and it is the part to schedule first.
On the Playwright side of the same study, the pattern the destination teams settled on is consistent. Seven of 15 Playwright suites write storageState once from a setup project, with per-role .auth/<role>.json files; one injects a dependencies: ['authenticate'] entry into every project programmatically. Four others do API login into the page context per test. Exactly one of 15 logs in through the UI. Three separate products independently wrote the same roughly 20-line API login helper against their auth library's credentials endpoint. Whatever you build here, you are not the first.
What translates, what does not
Ordered roughly by how much of your time each row will take.
| Cypress | Playwright | Effort |
|---|---|---|
cy.visit(url) | page.goto(url) | Mechanical |
cy.get(sel).click() | page.getByRole(...), getByTestId(...), .click() | Mechanical, but see the selector note below |
.should('be.visible') and friends | await expect(locator).toBeVisible() | Mechanical. Both retry, so the semantics survive |
cy.intercept() plus cy.wait('@alias') | page.route() plus page.waitForResponse() | Mostly mechanical, one at a time |
cy.request() | The request fixture, or page.request | Mechanical |
cy.wait(1000) | Nothing. Delete it and assert on the state you were waiting for | Cheap per line, and the highest-value edit you will make |
cy.session() | storageState from a setup project | Not applicable in practice: we found zero uses in seven production suites |
| Custom commands and helper namespaces | Fixtures and page objects | The bulk of the work. Map by call frequency |
cy.task() for Node-side seeding | A seeding fixture that calls the same code directly | The wall. Design this before you convert spec one |
| Cypress plugins and config forks | Projects, and the config's own primitives | Varies. One suite we read carries five separate Cypress configs for different product editions |
| Everything outside the test directory | Same, rewritten | Underestimated every time. See the removal note below |
The selector note. No single convention won in the Cypress sample and an importer should preserve what it finds rather than normalize. Two suites are strict data-cy (2,999 and 739 uses). One reaches attributes through Testing Library queries, where text queries at 6,172 uses outnumber test-id queries at 3,398, which means a copy change breaks tests. One large suite is raw CSS ids and classes, over 5,400 combined, against fewer than 100 test-id references. Interestingly, the three test-id-first Playwright suites in the companion study are all suites that either migrated from Cypress or built a selector package. Migration is when teams adopt a selector convention, because it is the moment the cost of not having one becomes a line item.
The removal note. The blockers named in public migration artifacts are not selector syntax. One removal pull request touched CI workflow files, run scripts, tsconfig and workspace entries, dependency-bot config and documentation. And migrations cross repository boundaries: one open-source Cypress removal explicitly followed a matching change in a private sibling repository. If you have private siblings, they move in lockstep.
The order of work we would follow
Derived from what the ten migrating teams actually did, not from a vendor guide.
1 · Read support/, not e2e/
Count custom command registrations and call frequency. That list is your backlog, ordered by how many specs each entry unblocks. The specs are downstream of it.
2 · Rebuild seeding first
Before converting a single spec, get one Playwright fixture that puts the app into a known state and tears it down. This is the step that stalled a public migration. Do it while you still have appetite.
3 · Then auth
Pick one: a setup project that writes per-role storageState, or an API login helper into the page context. Both are proven across the sample. UI login is one team in fifteen.
4 · Convert by value, one spec per commit
Highest-signal spec first, not alphabetical. One commit per spec keeps review honest and makes reverting a bad conversion free.
5 · Run both, on purpose
Dual carry is not failure, it is the observed norm. Budget the CI minutes. Decide up front which suite blocks a merge and which reports.
6 · Audit, do not translate
Two teams described the work as: check the old case is covered, write it if not, delete. One product's post-migration suite is smaller than what it replaced. Expect to shrink.
7 · Delete the sleeps as you go
A converted cy.wait(1000) becomes a waitForTimeout, which is a flaky test with a new accent. Replace it with the assertion you were really waiting for.
8 · Plan the flake tooling now
Every migrated team rebuilt it immediately on the far side. Switching frameworks restarted the flake war rather than ending it.
One thing we could not determine: wall-clock run times and CI spend, before or after. Workflow files show shard counts, not durations, and we did not query CI run APIs. If someone tells you migration made their suite N percent faster, ask where the number came from.
Record what will not translate, instead of guessing at it
We did this research because our import path has to survive it. The design commitment is simple: a spec we cannot faithfully carry across is reported as unconvertible with the reason attached, not silently rewritten into something that passes for the wrong reason. That is the same rule as our run verdicts, where a step that could not be measured returns UNMEASURED rather than a pass.
Live now
- Record or describe a flow in a hosted browser, get a deterministic Playwright spec. The code is written by code; a model only proposes the title and assertions, each stamped
decided_bywith the model id and its cost. - Ordinary
.spec.jsfiles in a repo you own, editable in the built-in IDE or your own editor. Nothing locked behind the UI. - Runs with screenshot, trace and network capture, PASS / FAIL / UNMEASURED, and a bug filed from the evidence.
- Self-healing that refuses: model-proposed heals are quarantined for a human rather than auto-promoted.
What ships today
qareef import <path>converts existing Playwright and Cypress specs into QA Reef flows, so a half-migrated suite lands in one place. Run it with--dry-runfirst to see what it would do.- Anything it cannot translate faithfully is marked needs-review and is never claimed runnable. It reports the gap rather than guessing a passing test.
The open-source core is free to self-host. Hosted plans are pre-launch.
What we are building for migrations
- A support-directory read that reports custom command call frequency, so the backlog orders itself.
- An explicit unconvertible list, with the reason per spec: seeding via a task bridge, a plugin with no equivalent, a custom command we will not guess at.
- Suite health on import: fixed sleeps and their sizes, skip and fixme counts, retry configuration, aliased-wait coverage.
Pre-launch, and stated as roadmap rather than shipped. Every page on this site marks the line.
If you are mid-migration and want a second pair of eyes on the seeding layer, that is a conversation we are glad to have whether or not you ever buy anything.
Questions about migrating off Cypress
How long does a Cypress to Playwright migration take?
Longer than a sprint. In the 16 public suites we read, migration ran spec by spec with both suites live in CI. One product's dual-carry period ran roughly a year before Cypress was removed; another has carried 658 Cypress specs and 298 Playwright specs side by side for over two years and still does. Plan for a dual-carry period measured in months, and budget CI time for running both.
Can I automatically convert Cypress tests to Playwright?
Only the easy fraction. At scale nobody writes raw Cypress: one suite we read registered about 460 custom commands across 119 support files, and another routes specs through a single imported helper namespace used thousands of times. A converter that handles cy.get().click() does not touch that layer, and that layer is most of the suite. Start by reading the support directory, not the specs.
What is the hardest part of migrating off Cypress?
Getting the application into a known state. cy.session appeared zero times across the seven production Cypress suites we counted; every team hand-rolls API login on cy.request plus database seeding or snapshot restore. freeCodeCamp named the same thing in its public migration tracking issue: cy.task let them run Node code to seed and reset user data mid-test, and their Playwright setup had no equivalent. Login is trivial. Cheap, repeatable state is the product.
Should I translate every Cypress test to Playwright?
The teams we read did not. Two of them described the work as checking whether the Cypress case is covered by a new test, writing it if not, and deleting the old one. One product's post-migration Playwright suite is smaller than the Cypress suite it replaced. Treat the migration as a coverage audit rather than a port, and expect the suite to shrink.
Does switching to Playwright fix flaky tests?
No. Sleep density in the suites we counted varies 20 to 30 times between teams on the same framework, so flake tracks team practice rather than framework choice. After migrating, the teams we read immediately rebuilt flake infrastructure on the new framework: one wrote a custom quarantine reporter, another wired a paid flake dashboard and duration-weighted sharding. Switching frameworks restarts the tooling around flake, it does not end it. More detail on the flaky-test page.
What else breaks when you remove Cypress from a repo?
The long tail. One removal pull request we read touched CI workflow files, run scripts, tsconfig and workspace entries, dependency-bot config, and documentation, all referencing Cypress. Migrations can also cross repository boundaries: one open-source Cypress removal explicitly followed a matching change in a private sibling repository. Inventory every reference before you schedule the removal commit.
Is any of this affiliated with the products named?
No. Every product name here belongs to its owner and is used only to identify a public repository we read. Nothing implies a partnership, sponsorship or endorsement in either direction, and nothing here is a criticism of any team's engineering. These are large suites solving hard problems in public, which is the only reason we could count anything at all.
Mid-migration and want the numbers for your own suite?
Send us the shape of your support directory and we will run the same counts we ran on these sixteen. No charge, no obligation, and you keep the output.
Talk to the team