{"slug": "how-i-migrated-90-cypress-tests-to-playwright-with-claude-code-in-4-days", "title": "How I Migrated 90 Cypress Tests to Playwright With Claude Code in 4 Days", "summary": "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.", "body_md": "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.\n\nOur 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.\n\nWe 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.\n\nThat'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.**\n\nI 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.\n\nIt was ugly, but that was the point. Doing it by hand surfaced every decision that a converter would otherwise make silently:\n\n`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.\nEach of those became a line in a file I called `MIGRATION_RULES.md`. By the end of spec one, it had 14 rules.\n\nI 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:\n\n``` php\n## Cypress -> Playwright migration rules\n\n1. Never translate a custom command inline. Look it up in\n   cypress/support/commands.js, then map it to the fixture\n   in tests/fixtures/*.ts. If no fixture exists, STOP and\n   report which command is missing.\n2. Every cy.get(...).should(...) chain becomes a single\n   `await expect(locator).toX()`. Do not add manual waits.\n3. cy.intercept(method, urlGlob) -> page.route(urlGlob).\n   Convert Cypress glob to Playwright glob; verify with the\n   table in MIGRATION_RULES.md section 3.\n4. Keep the original spec's describe/it names verbatim so\n   the test report diff is readable.\n5. After writing a spec, run it 3x with `--repeat-each=3`.\n   Only report success if all 3 pass.\n```\n\nRule 1 turned out to be the most important line I wrote all week. More on that below.\n\nI didn't run one giant \"migrate all 89\" session. I ran batches of five specs, each in a fresh session, with a prompt like:\n\n```\nMigrate these 5 Cypress specs to Playwright following the rules\nin CLAUDE.md. Work on them one at a time. For each: write the\nspec, run it 3x, then show me the pass/fail summary before\nmoving to the next. Do not touch any file outside tests/e2e/.\n```\n\nWhy five? Three reasons:\n\nHere's the flow end to end:\n\n``` php\nflowchart LR\n    A[Pick 5 Cypress specs] --> B[Agent translates spec N]\n    B --> C{3x pass?}\n    C -- no --> D[Agent reports failure + reason]\n    D --> E[I fix rule or fixture]\n    E --> B\n    C -- yes --> F[Screenshot diff vs Cypress run]\n    F -- drift --> E\n    F -- clean --> G[Mark spec migrated]\n    G --> B\n```\n\nBatches 1 through 4 went through in about a day and a half. Twenty specs, no drama. Then I hit the first trap.\n\nBatch 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.**\n\nThat 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.\n\nI 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.\"*\n\nThat 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.\n\nLesson: when the agent says \"I skipped this, here's why,\" read it. Every time.\n\nAround 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.\n\nThe agent had discovered that Playwright's auto-waiting `expect()` is *very* forgiving, and had translated a Cypress assertion like this:\n\n```\ncy.get('[data-cy=total]').should('contain', '$49.00');\n```\n\ninto this:\n\n```\nawait expect(page.getByTestId('total')).toBeVisible();\n```\n\nVisible. Not \"contains $49.00.\" The element was always visible. The test could never fail.\n\nThis 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.\n\nThis is the part I'd keep even if I never migrate another test suite.\n\nBefore 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`:\n\n``` js\nimport { test, expect } from '@playwright/test';\nimport { compareToBaseline } from '../fixtures/visual';\n\ntest('checkout: coupon applied', async ({ page }) => {\n  // ... steps ...\n  await compareToBaseline(page, 'checkout-coupon-applied', {\n    threshold: 0.02,\n  });\n});\n```\n\nThe 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.\n\n**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.\n\n**\"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.\n\n**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.\n\n**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.\n\n**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.\n\nThe 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.\n\nNext 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.\n\nIf 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%.\n\nIf you're staring at a test-suite migration that nobody wants to own, this is the workflow I'd hand you:\n\nGot 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. 🚀", "url": "https://wpnews.pro/news/how-i-migrated-90-cypress-tests-to-playwright-with-claude-code-in-4-days", "canonical_source": "https://dev.to/yureki_lab/how-i-migrated-90-cypress-tests-to-playwright-with-claude-code-in-4-days-1im6", "published_at": "2026-09-19 14:32:12+00:00", "updated_at": "2026-09-19 14:53:43.963507+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools", "ai-products"], "entities": ["Claude Code", "Cypress", "Playwright", "Anthropic", "Node.js"], "alternates": {"html": "https://wpnews.pro/news/how-i-migrated-90-cypress-tests-to-playwright-with-claude-code-in-4-days", "markdown": "https://wpnews.pro/news/how-i-migrated-90-cypress-tests-to-playwright-with-claude-code-in-4-days.md", "text": "https://wpnews.pro/news/how-i-migrated-90-cypress-tests-to-playwright-with-claude-code-in-4-days.txt", "jsonld": "https://wpnews.pro/news/how-i-migrated-90-cypress-tests-to-playwright-with-claude-code-in-4-days.jsonld"}}