For most of the time I have been writing end-to-end tests, I would have told you Cypress was the nicer tool. The runner shows you the app as the test drives it. You can hover a command in the log and see the DOM as it was at that moment. When a test failed, you opened it, scrubbed back three steps, and usually knew the answer before you read the stack trace.
That was a real advantage and I do not want to pretend otherwise. It is also an advantage aimed squarely at a human sitting in front of a screen, and over the last year the person debugging our failing tests stopped being a human most of the time.
This post is about moving a working Cypress suite to Playwright, why the deciding argument was not the one I expected, and what changed afterwards. Worth saying up front: this was the second time we had done this. The commit that introduced Cypress in August 2023 also deleted the CodeceptJS suite it replaced. Three years, three homes in the repo, and now a fourth tool. That makes me exactly the person you should be sceptical of, which is part of why I went back and pulled the real numbers out of CI rather than writing from memory.
What was actually wrong
Two things, and neither of them was "Cypress is bad".
Parallelisation. Cypress does not split a suite across CI machines on its own. Parallelisation is a Cypress Cloud feature, and we were running on our own CI. The community answer is cypress-split, which works, and which we used, and which was a bandage. It needed its own configuration to stay in sync with the suite, and the shard indices had to be plumbed through our monorepo task runner to reach it. When the split went wrong the failure did not look like a test failure. Any time your CI has a component whose job is to work around your test runner's licensing model, you are paying rent on a decision somebody made in a pricing meeting.
Helper sprawl. The other problem was ours, and the obvious version of this complaint is wrong. It was not that we had too much shared test code. The Cypress suite carried about 1,450 lines of it. The Playwright suite that replaced it carries about 1,490. That number did not move.
What we had was a specific kind of shared code, the kind you only write to work around your runner. Twenty-one custom commands hanging off cy, declared in a 72-line global type augmentation, so reading a spec meant knowing which of them were ours. And my favourite, a deferedClick command that existed solely to do a double requestAnimationFrame before every click, because Cypress checks whether the chain's final subject is still attached and a React re-render would replace the element underneath it.
Every one of those was a reasonable local fix for a real problem. Together they meant that the thing Cypress sells, the ability to look at a test and immediately see what it does, was gone. The DX advantage I described in the intro was one we had already spent.
Retries are the clearest illustration. By the end we had four layers: retries.runMode in the config, a runSuiteWithRetries helper inside the specs, parallel: 2 in CI, and allow_failure: true on the job so a red suite did not block anything. That last one is the confession. We had a test suite whose failures we had configured CI to ignore.
The numbers. A green run of the merge-request suite took about 25 minutes. That is the number I would have quoted you at the time, and it undersells the problem badly, because the run that actually mattered was the nightly one. The nightly exercised all tenants rather than one, it took between three and a half and nearly five hours, and between 25 June and 20 July 2026 it failed every single night without exception. Fifty-two consecutive red jobs. Nobody was lying to anybody about this. It was simply understood that the nightly was red, in the way a broken clock in a stairwell is understood.
The argument that actually decided it
The performance case for Playwright is well documented and I do not need to re-argue it. Sharding is built in and browser contexts are cheap.
The argument that decided it for us was about who reads the failure.
When a Playwright test fails, it leaves a directory behind. The error with actual and expected values, a screenshot, a video if you asked for one, a trace you can step through, and, since fairly recently, an error-context.md file containing a snapshot of the page at the moment of failure as structured text.
That last one is the whole thing. It is a markdown file describing the accessibility tree of the page the test was looking at when it gave up. A screenshot is useless to an agent that cannot see. A DOM dump is technically readable and practically noise. A markdown page snapshot is exactly the artefact a coding agent needs, in exactly the format it is best at consuming, sitting on disk next to the failure with no tooling required to extract it.
I should be honest that I did not build any of this. There is no custom reporter in our config, no aria-snapshot assertions, nothing switched on. error-context.md is a Playwright default, and it reaches our CI artefacts only because we happened to upload the whole results directory. The thing I am praising as the deciding factor is something I got for free and only understood the value of afterwards.
Cypress's debugging story assumes a person opens the runner. Playwright's assumes something reads files. In 2024 that was a downgrade. Now it means the loop closes without me: the agent runs the test, reads the failure and the page snapshot, works out what state the page was actually in versus the state the test expected, changes the test, and runs it again.
How the migration went
Better than I expected, and I want to explain why rather than just claim it.
A test migration is close to the ideal task to hand an agent, for three reasons:
- There is already a right answer. The old suite is the oracle. It already tells you what the app is supposed to do. You are not deciding anything, you are re-expressing decisions that were made years ago.
- The work is repetitive in shape but varied in detail. This is precisely the profile where a human gets bored on file nine and starts pattern-matching instead of reading. It is also the profile agents are best at.
- Every unit is independently verifiable and cheap to revert. One spec at a time, run it, keep it or throw it away.
Contrast that with a task from the same codebase that had none of those properties: reworking how a permissions check flowed through a set of services. No oracle, nothing to check the answer against, and the cost of being subtly wrong was not a red pipeline but a security bug six months later. That one I did myself. The heuristic is not "agents are good at code", it is "agents are good where a cheap correctness check exists", and a test suite is the purest example of that in a codebase.
Given all that, the bulk of the rewrite went through in essentially one pass. Thirty-one Cypress specs became thirty-one Playwright specs, near enough one-to-one by filename, and it landed as a single commit of about 5,000 lines. What made that possible was giving the agent the conventions up front, one spec at a time, and a command it could run itself to check its own work.
"One pass" is doing some work in that sentence, though, and the honest shape of it is: one pass to write, three weeks to trust. Playwright landed on 30 June. Cypress kept running beside it, still on allow_failure: true, until I deleted it on 21 July. I would not have wanted to skip that overlap, and if you are planning this migration you should budget for it rather than treating the port as the end.
The translations that need a human
The suite going green is not proof the migration worked, and this is the part I would not skip.
Selectors were less of a free ride than I like to tell people. I would have said we used data-testid everywhere, so cy.get('[data-testid="save-toast"]') becomes page.getByTestId('save-toast') and that is the whole job. Then I counted, and it was closer to half. The rest keyed off CSS structure or German UI copy, and those are judgement calls about whether two selectors mean the same thing. That is where the supervision went.
The real trap is retry-ability, because a port that looks right can quietly drop it:
// Cypress: retries until it passes or times out
cy.get('[data-testid="save-toast"]').should('contain', 'Saved')
// Playwright, badly: one-shot read, no retry
expect(await page.getByTestId('save-toast').textContent()).toContain('Saved')
// Playwright, correctly: web-first assertion, retries
await expect(page.getByTestId('save-toast')).toContainText('Saved')The bad version is not obviously bad. It reads fine, the test id is right, and it passes whenever the toast happened to render in time. It is green in CI, green on your machine, and asserting nothing, which is exactly why "the suite passes" does not settle the question. A test that passes for the wrong reason is invisible to the check you are relying on.
I reviewed the diff for this by hand and would have told you not to delegate it. I was wrong: eslint-plugin-playwright has rules for this shape and found several I had missed in files I had already read line by line.
Authentication went the same way. We had been caching sessions with cy.session, the port dropped it, nothing replaced it, and the authenticated specs now log in through the UI on every run. The suite went green anyway. If your suite caches sessions today, check that the port kept it, because a capability you silently lose is not something a passing test tells you about.
Where it landed
The merge-request suite runs in about 11 minutes across three shards, against 25 minutes before. That is the improvement I would put in a slide. The more useful comparison is that nobody expects a red pipeline any more.
The new suite was green from day one. What I would not conclude from it is that the port was therefore correct, which is the whole point of the section above, and it is why Cypress kept running beside it for another three weeks before I trusted the result enough to delete it.
Some honest asterisks on the rest.
The suite still retries, and slightly more eagerly than Cypress did. What I can defend is narrower than "we stopped retrying": of the 326 Playwright CI jobs in the last month, 44 were re-run, and exactly one flipped from red to green. Failures are real now, and that is the property I actually wanted. The four-layer stack is gone, allow_failure: true is gone, and the specs no longer carry their own retry machinery.
And the parallelism is all at the shard level. Every run provisions a throwaway account, with Mailpit catching the verification mail, so sharding gives each lane its own for free, but Playwright's own workers inside a shard would share one, so we run single-worker.
The nightly is also still mid-migration. Turning the all-tenant run back on is the last piece, and it wants those per-worker accounts too so it finishes in a reasonable time. A red suite nobody reads has a coverage value of zero, so eleven trustworthy minutes beats four ignored hours, but that is not the same as done.
The change I did not anticipate is what happened to writing new tests. Adding coverage used to be a task you scheduled. Now it is something an agent does end to end, including running the test, watching it fail for the right reason, and confirming it fails when the feature is broken. The artefacts that made debugging tractable turned out to make authoring tractable too, because forming and checking a theory is the same loop in both cases.
I introduced these tools and shared what worked with the team rather than mandating anything, and the parts people picked up were the parts that removed a chore. Nobody adopted the workflow because it was agentic. They adopted it because the flaky-test rota stopped existing.
Would I recommend it
Yes, and more strongly than I am comfortable with. If you are on Cypress today, I think you should move unless you have a specific reason not to, and I would want to hear the reason.
What I would do differently: measure before you start. Writing this post is the first time I properly pulled the numbers, and every one of them was more interesting than the version I had been repeating. The merge-request run was faster than I thought, and the nightly was far worse than I was telling people.
