Back to blog

Migrating from Cypress to Playwright

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 sounds like a small technical detail but it 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 was bigger than i expected. Cypress has this implicit retry/wait mechanism built into its command chain. it's clever but 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

this was the most mechanical part. Cypress uses cy.get() with CSS selectors. Playwright has page.locator() but also has these semantic query methods that are way 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

this is where the real improvement was. Cypress intercepts are fine 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 — you can do whatever you want in there:

// 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 do something complex — like 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

this was the biggest mental shift. Cypress commands are chained and implicitly queued. Playwright is just async/await. sounds simple but it 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

here's the thing that surprised me most. we went from 40% to 90% coverage not because we wrote twice as many tests. we wrote maybe 30% more tests. the coverage jumped because the existing tests actually 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 genuinely 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.