cd /news/ai-agents/how-i-migrated-90-cypress-tests-to-p… · home topics ai-agents article
[ARTICLE · art-134615] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

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

A developer migrated a 90-spec Cypress end-to-end suite to Playwright in four working days using Claude Code, hand-translating one checkout spec first and distilling the patterns into a 14-rule MIGRATION_RULES.md file that the agent followed for the remaining 89 specs. The workflow ran in batches of five specs with a three-times repeat-run gate and a screenshot-diff check to catch silent behaviour drift, with no spec deleted from Cypress until its Playwright twin passed ten consecutive runs.

by read7 min views4 publishedSep 19, 2026

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:

## 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:

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:

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. 🚀

── more in #ai-agents 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-i-migrated-90-cy…] indexed:0 read:7min 2026-09-19 ·