# How I Migrated 90 Cypress Tests to Playwright With Claude Code in 4 Days

> Source: <https://dev.to/yureki_lab/how-i-migrated-90-cypress-tests-to-playwright-with-claude-code-in-4-days-1im6>
> Published: 2026-09-19 14:32:12+00:00

I moved a 90-spec Cypress suite to Playwright in 4 working days using Claude Code. The trick wasn't "ask the AI to convert everything." It was: hand-translate **one** spec, extract the pattern into a written rulebook, then let the agent fan out across the other 89 while a screenshot-diff gate caught the silent behaviour drift. Here's the workflow, the two traps that cost me a day, and what I'd do differently.

Our end-to-end suite was 90 Cypress specs, written over three years by six different people. It ran in about 38 minutes on CI, flaked roughly once every four runs, and had grown a 600-line `commands.js` full of custom helpers that nobody fully understood anymore.

We wanted Playwright for the usual reasons: real multi-tab support, first-class parallelism, and one runner for the three browsers we actually ship to. The problem was the migration itself. Every estimate I got from the team landed at "two sprints, maybe three." Nobody wanted to own it. It was pure toil with zero product upside until the very last spec crossed over.

That's exactly the kind of work I've been throwing at Claude Code (v2.1.x at the time, Node.js 22, Playwright 1.54). Mechanical, high-volume, pattern-heavy, and easy to verify. So I gave myself one week and a rule: **no spec gets deleted from Cypress until its Playwright twin passes 10 times in a row.**

I picked a medium-complexity spec (the checkout flow: login, add to cart, apply coupon, pay with a test card) and translated it myself. No AI. About 90 minutes.

It was ugly, but that was the point. Doing it by hand surfaced every decision that a converter would otherwise make silently:

`cy.get('[data-cy=x]')` becomes `page.getByTestId('x')`, which meant changing the test-id attribute in `playwright.config.ts`.` await expect(locator).toHaveText()`.` cy.login()` custom command hits an API and sets a cookie. In Playwright that became a `storageState` fixture created once per worker, not per test.`cy.intercept()` stubs became `page.route()`, but the matcher semantics differ (glob vs. minimatch), and I got two wrong on the first try.
Each of those became a line in a file I called `MIGRATION_RULES.md`. By the end of spec one, it had 14 rules.

I put the rulebook into the project spec file that Claude Code reads on startup, along with the before/after of my hand-translated spec as a worked example. The key section looked like this:

``` php
## Cypress -> Playwright migration rules

1. Never translate a custom command inline. Look it up in
   cypress/support/commands.js, then map it to the fixture
   in tests/fixtures/*.ts. If no fixture exists, STOP and
   report which command is missing.
2. Every cy.get(...).should(...) chain becomes a single
   `await expect(locator).toX()`. Do not add manual waits.
3. cy.intercept(method, urlGlob) -> page.route(urlGlob).
   Convert Cypress glob to Playwright glob; verify with the
   table in MIGRATION_RULES.md section 3.
4. Keep the original spec's describe/it names verbatim so
   the test report diff is readable.
5. After writing a spec, run it 3x with `--repeat-each=3`.
   Only report success if all 3 pass.
```

Rule 1 turned out to be the most important line I wrote all week. More on that below.

I didn't run one giant "migrate all 89" session. I ran batches of five specs, each in a fresh session, with a prompt like:

```
Migrate these 5 Cypress specs to Playwright following the rules
in CLAUDE.md. Work on them one at a time. For each: write the
spec, run it 3x, then show me the pass/fail summary before
moving to the next. Do not touch any file outside tests/e2e/.
```

Why five? Three reasons:

Here's the flow end to end:

``` php
flowchart LR
    A[Pick 5 Cypress specs] --> B[Agent translates spec N]
    B --> C{3x pass?}
    C -- no --> D[Agent reports failure + reason]
    D --> E[I fix rule or fixture]
    E --> B
    C -- yes --> F[Screenshot diff vs Cypress run]
    F -- drift --> E
    F -- clean --> G[Mark spec migrated]
    G --> B
```

Batches 1 through 4 went through in about a day and a half. Twenty specs, no drama. Then I hit the first trap.

Batch 5 included a spec that called `cy.selectPlan('pro')`. The agent, following rule 1, looked it up. The command did three things: clicked a plan card, waited for a modal, and **silently dismissed a "confirm downgrade" dialog if it appeared.**

That third behaviour was never documented. It existed because two years ago someone had a flaky test and patched the helper instead of the app. The Playwright fixture the agent wrote did the first two things, and the test passed. It passed because the test data never triggered the downgrade dialog.

I only caught it because rule 1 also said "report which command is missing," and the agent's report included a one-liner: *"Note: the Cypress version also handles a confirm dialog conditionally; I did not port this since no test exercises it."*

That single sentence saved a production bug. It turned out our real app **did** show that dialog for one plan transition, and the old Cypress helper had been hiding a broken flow for two years.

Lesson: when the agent says "I skipped this, here's why," read it. Every time.

Around batch 9, the pass rate got suspiciously good. Every spec passed 3 of 3 on the first attempt. I got nervous and pulled up a diff.

The agent had discovered that Playwright's auto-waiting `expect()` is *very* forgiving, and had translated a Cypress assertion like this:

```
cy.get('[data-cy=total]').should('contain', '$49.00');
```

into this:

```
await expect(page.getByTestId('total')).toBeVisible();
```

Visible. Not "contains $49.00." The element was always visible. The test could never fail.

This wasn't the agent being lazy. It was me being vague. My rule 2 said "becomes a single expect" but never said "preserve the assertion's semantics exactly." Fixing that took one line in the rulebook and a re-run of 11 specs.

This is the part I'd keep even if I never migrate another test suite.

Before touching anything, I ran the full Cypress suite once with screenshots on at every `it()` boundary, and saved them to `baseline/`. For each migrated Playwright spec, I captured a screenshot at the same boundaries and diffed with `pixelmatch`:

``` js
import { test, expect } from '@playwright/test';
import { compareToBaseline } from '../fixtures/visual';

test('checkout: coupon applied', async ({ page }) => {
  // ... steps ...
  await compareToBaseline(page, 'checkout-coupon-applied', {
    threshold: 0.02,
  });
});
```

The diff caught four cases where the Playwright test passed, the assertions passed, but the screen looked different. Three were timing (Playwright was faster and captured mid-animation). One was real: a locator matched a different button with the same label, so the test was exercising the wrong path and getting lucky.

**Do the first one by hand, always.** The 90 minutes I spent translating spec one produced the 14 rules that made the other 89 possible. If you skip this, the agent makes every one of those decisions for you, silently and inconsistently.

**"Stop and report" beats "figure it out."** The single highest-value instruction was telling the agent to halt on unknown helpers instead of guessing. Every real bug I caught came through one of those reports.

**Batch small, session fresh.** Five specs per session was the sweet spot. Bigger batches got hallucinated helper names. Smaller ones wasted my review time on context-switching.

**Passing tests are not evidence. Failing-when-they-should tests are.** After trap 2, I added a step: for every migrated spec, deliberately break the app once and confirm the test goes red. The agent can do this too, and it takes 30 seconds per spec.

**Visual diffs are the cheapest oracle you have.** Assertions encode what someone remembered to check. Screenshots encode everything. For a migration, "does it look the same" catches a class of drift that no assertion will.

The suite now runs in 11 minutes across 4 workers, down from 38, and I haven't seen a flake in three weeks. The `commands.js` file is gone. The 600 lines became about 180 lines of typed fixtures that a new hire can actually read.

Next up, I'm pointing the same "hand-translate one, extract rules, fan out" workflow at our 40-odd Jest snapshot tests, which have the same "green but meaningless" problem in a different costume. I'm also experimenting with having the agent write the *deliberate break* step itself, so the "does this test actually fail" check becomes part of the migration loop instead of something I remember to do.

If there's interest, I'll write up the fixture design in a follow-up. The `storageState`-per-worker pattern alone cut our login overhead by 80%.

If you're staring at a test-suite migration that nobody wants to own, this is the workflow I'd hand you:

Got a migration war story of your own, or a rule that saved you a bad afternoon? Drop it in the comments. And if you want the follow-up on fixture design, hit **follow** so it lands in your feed. 🚀
