Migrating from Cypress to Playwright
Back to blog

Migrating from Cypress to Playwright

·Dan Castrillo

the test suite nobody trusted

so we had this Cypress test suite at work. about 200 tests covering maybe 40% of the critical user flows. sounds okay on paper but in practice nobody trusted it. tests would pass locally and fail in CI. they'd fail on Tuesday and pass on Wednesday with zero code changes. the team started ignoring test failures which is basically the same as not having tests at all.

the core problem was architectural. Cypress runs inside the browser. same process, same event loop, same origin restrictions. which means every network request, every cross-origin redirect, every multi-tab workflow was either impossible to test or required some brittle workaround that broke every other week.

we'd been talking about Playwright for months. finally pulled the trigger.

why playwright

the big difference is that Playwright runs out-of-process. it controls the browser over CDP (Chrome DevTools Protocol) instead of living inside it. this changes everything:

  • no same-origin limitations: you can test OAuth flows, cross-domain redirects, multi-tab scenarios
  • real network interception: intercept at the protocol level, not by monkey-patching XMLHttpRequest
  • multiple browsers: Chromium, Firefox, WebKit from the same test code
  • proper async: no magic auto-waiting chains, just regular async/await

that last one mattered most. Cypress has this implicit retry/wait mechanism built into its command chain. it makes debugging a nightmare because you can't tell where the wait is happening or why it timed out.

the migration patterns

we didn't rewrite everything at once. we migrated page by page, keeping both suites running in CI until the Playwright version covered everything the Cypress version did.

selectors

Cypress uses cy.get() with CSS selectors. Playwright has page.locator() but also has semantic query methods that work better:

// Cypress
cy.get('[data-testid="job-title"]').should("contain", "Senior Engineer")
cy.get(".proposal-list > li").should("have.length", 5)
 
// Playwright
await expect(page.getByTestId("job-title")).toContainText("Senior Engineer")
await expect(page.locator(".proposal-list > li")).toHaveCount(5)

getByTestId, getByRole, getByText: these read so much better than raw CSS selectors. and they encourage accessible markup which is a nice side effect.

network interception

Cypress intercepts work for simple cases but they get weird with multiple intercepts on the same route, or when you need to modify the response conditionally. Playwright's page.route() is just a function:

// Cypress
cy.intercept("GET", "/api/jobs/*", { fixture: "job.json" })
cy.get('[data-testid="job-title"]').should("contain", "Senior Engineer")
 
// Playwright
await page.route("**/api/jobs/*", (route) =>
  route.fulfill({ path: "./fixtures/job.json" })
)
await expect(page.getByTestId("job-title")).toContainText("Senior Engineer")

similar line count but the Playwright version is more explicit about what's happening. and when you need to delay a response to test loading states, or return different data on the second call, it's just JavaScript:

let callCount = 0
await page.route("**/api/proposals", (route) => {
  callCount++
  if (callCount === 1) {
    return route.fulfill({ json: { proposals: [] } })
  }
  return route.fulfill({ path: "./fixtures/proposals.json" })
})

try doing that cleanly in Cypress. i dare you.

async model

Cypress commands are chained and implicitly queued. Playwright is just async/await. this changes how you structure tests:

// Cypress — implicit chaining
cy.visit("/jobs/123")
cy.get('[data-testid="apply-btn"]').click()
cy.get('[data-testid="modal"]').should("be.visible")
cy.get('[data-testid="cover-letter"]').type("hire me please")
cy.get('[data-testid="submit"]').click()
cy.url().should("include", "/applications")
 
// Playwright — explicit async
await page.goto("/jobs/123")
await page.getByTestId("apply-btn").click()
await expect(page.getByTestId("modal")).toBeVisible()
await page.getByTestId("cover-letter").fill("hire me please")
await page.getByTestId("submit").click()
await expect(page).toHaveURL(/\/applications/)

every line says exactly what it's doing and when it's waiting. no hidden retry logic. when a test fails the error points to the exact await that timed out. debugging went from "stare at the Cypress runner for 20 minutes" to "read the stack trace."

the coverage jump

we went from 40% to 90% not because we wrote twice as many tests. we wrote maybe 30% more tests. the coverage jumped because the existing tests passed consistently now.

with Cypress we had about 60 tests that were permanently skipped because they were "flaky." with Playwright we rewrote those same scenarios and they just worked. turns out they weren't flaky. Cypress's in-browser execution model just couldn't handle them reliably.

the other factor was speed. Playwright tests run faster so the feedback loop is tighter. people actually run tests before pushing now. wild concept.

what i'd do differently

i'd migrate the network interception layer first. that's where most of the flakiness lived. if i'd done that before touching selectors or assertions we would've seen the stability improvement sooner and gotten more buy-in from the team earlier.

also: Playwright's codegen tool (npx playwright codegen) is useful for bootstrapping tests. i didn't discover it until we were halfway through the migration. would've saved a lot of typing.

still have a few Cypress tests hanging around for some legacy pages. they'll get migrated eventually. but the suite is green now and people trust it. that's the whole point.

Related Posts