Skip to content
Quality Assurance Labs
QA Testing

Cypress to Playwright Migration: A Measured Guide

Kawsher, Founder, QA Labs10 min readPublished Updated

We migrated a ten-test Cypress suite to Playwright and timed both: 14.3 s down to 5.8 s with four workers. The literal migration failed in 8 of 10 parallel runs; here is why.

Illustration of a Cypress test file with an arrow to its Playwright version, four parallel progress bars and a stopwatch
  • Playwright
  • Cypress
  • test-automation
  • migration
  • parallel-testing

Teams usually move from Cypress to Playwright for three reasons: tests that run in parallel on one machine without a paid dashboard, real multi-tab and multi-browser support (Chromium, Firefox and WebKit), and plain async/await code instead of Cypress's command chains. The migration itself is mostly mechanical. The part that breaks is the hidden assumption that tests run one at a time.

To measure what a migration actually changes, we wrote a ten-test Cypress suite for a small demo shop (sign-in, a slow product API, search, cart, checkout and a stubbed declined payment), migrated it to Playwright test by test, and ran both on the same GitHub-hosted runner: Cypress five times, and Playwright five times with one worker and five times with four workers.

The results: the Cypress suite took a median of 14.3 seconds from start to finish. The Playwright version took 10.4 seconds with one worker and 5.8 seconds with four workers, 59% less than Cypress. Every run of both suites passed. But our first, most literal migration, which shared one signed-in session across parallel tests the way cy.session does, failed in 8 of 10 runs. This guide covers the command mapping, the migration steps, and that trap.

The demo and how we measured

The shop is a single Node.js server with no dependencies. It has a sign-in form that takes 400 ms to answer (like password hashing), a product API that takes 300 to 800 ms at random, debounced search, a server-side cart stored per session, and a checkout endpoint. Uneven API timing is deliberate: it punishes fixed waits and rewards tests that wait for the right thing.

  • Cypress suite: 10 tests, a cy.login() custom command built on cy.session, cy.intercept aliases to wait for the product API, a fixture for stubbed data, and a stubbed 402 response for the declined card.
  • Playwright suite: the same 10 tests, migrated one to one, with a fixture replacing cy.login(), page.route replacing cy.intercept, and web-first assertions replacing should().
  • Runner: GitHub Actions ubuntu-latest, 2 vCPU and 7 GB RAM, Node.js 22, Cypress 16.2.0 (default Electron browser), Playwright 1.64.1 (Chromium).
  • Timing: wall-clock time of the whole command, including browser start-up, plus the duration each tool reports for the tests themselves.

We keep the demo code, the workflow and the raw results of every run in our lab repository.

Results: Cypress vs the migrated Playwright suite

RunMedian wall timeRangeTests passed
Cypress, 5 runs14.3 s13.4 to 14.5 s10 of 10, every run
Playwright, 1 worker, 5 runs10.4 s10.3 to 11.2 s10 of 10, every run
Playwright, 4 workers, 5 runs5.8 s5.6 to 6.0 s10 of 10, every run
Playwright, 4 workers, shared sign-in, 10 runs10.6 s5.8 to 10.8 sfailures in 8 of 10 runs

Two things explain the gap. First, start-up: Cypress reported about 9.7 seconds of test time inside a 14.3-second run, so roughly 4.5 seconds went on launching its app and browser. Playwright's overhead was about 0.6 seconds. Second, parallelism: with four workers, Playwright ran the ten tests across four browser processes on the same machine, which almost halved the time again, even on a 2-vCPU runner.

With one worker, the test time itself was almost the same in both tools (about 9.7 seconds in Cypress and 9.6 to 10.6 seconds in Playwright), because the app's slow API dominates. Do not expect a migration to make individual tests faster. It makes the suite faster by cutting overhead and running tests side by side.

Line count barely changed: 79 non-blank lines for the Cypress spec and support file, 94 for the Playwright spec, fixture and setup file. The extra lines are the sign-in fixture, which is also what fixed the parallel failures.

Cypress to Playwright: the command mapping

Most of a migration is a one-to-one translation. This table covers the commands that appear in almost every Cypress suite:

CypressPlaywright
cy.visit("/shop")await page.goto("/shop")
cy.get("[data-testid=product]")page.getByTestId("product")
cy.contains("button", "Sign in")page.getByRole("button", { name: "Sign in" })
cy.get("input[name=email]").type("...")await page.getByLabel("Email").fill("...")
.should("have.length", 8)await expect(locator).toHaveCount(8)
.should("have.text", "2")await expect(locator).toHaveText("2")
.should("be.visible")await expect(locator).toBeVisible()
cy.location("pathname").should("eq", "/shop")await expect(page).toHaveURL(/\/shop$/)
cy.intercept(url).as("x") + cy.wait("@x")const r = page.waitForResponse(url); ...; await r
cy.intercept(url, { statusCode: 402, body })await page.route(url, (route) => route.fulfill({ status: 402, json: body }))
cy.intercept(url, { fixture: "products.json" })await page.route(url, (route) => route.fulfill({ json: products }))
cy.request("DELETE", "/api/cart")await page.request.delete("/api/cart")
cy.session() + custom commandstorageState, set by a fixture or a setup project
beforeEach / describe / ittest.beforeEach / test.describe / test

Step 1: translate one test and compare the results

Start with a test that has an intercept and a few assertions. Here is the declined-payment test in Cypress:

it("shows a declined payment and lets the shopper retry", () => {
  cy.intercept("POST", "/api/checkout", { statusCode: 402, body: { error: "card_declined" } }).as("checkout");
  cy.contains("button", "Add Cap").click();
  cy.get("[data-testid=cart-count]").should("have.text", "1");
  cy.get("#checkout").click();
  cy.wait("@checkout");
  cy.get("[role=status]").should("have.text", "Payment declined: card_declined");
  cy.get("#checkout").should("be.enabled");
});

And the Playwright version:

test("shows a declined payment and lets the shopper retry", async ({ page }) => {
  await page.route("**/api/checkout", (route) => route.fulfill({ status: 402, json: { error: "card_declined" } }));
  await page.getByRole("button", { name: "Add Cap" }).click();
  await expect(page.getByTestId("cart-count")).toHaveText("1");
  await page.getByRole("button", { name: "Checkout" }).click();
  await expect(page.getByRole("status")).toHaveText("Payment declined: card_declined");
  await expect(page.getByRole("button", { name: "Checkout" })).toBeEnabled();
});

Three differences matter. Every action and assertion is awaited, because Playwright returns real promises while Cypress queues commands. The cy.wait("@checkout") line disappears: the web-first assertion on the status message already retries until the text appears or the 5-second timeout runs out. And selectors move from CSS to roles and labels where possible, which tests what a user can actually see and click.

Step 2: replace intercept aliases with waitForResponse

In Cypress you register an intercept, give it an alias and wait for it later. In Playwright you start waiting for the response before the action that triggers it, then await the promise:

// Cypress
cy.intercept("GET", "/api/products*").as("products");
cy.visit("/shop");
cy.wait("@products");

// Playwright: register the wait before the request is sent
const loaded = page.waitForResponse("**/api/products*");
await page.goto("/shop");
await loaded;

If you write await page.waitForResponse(...) after page.goto(), the response may already have arrived and the test hangs until it times out. This is the most common bug in hand-migrated suites. In many tests you do not need the explicit wait at all: an assertion such as toHaveCount(8) waits for the products to render.

Step 3: move sign-in out of the tests

cy.session caches a signed-in cookie and restores it before each test. The direct Playwright equivalent is a setup project that signs in once and saves the cookies to a file, which the other tests load as their storageState:

// auth.setup.js: sign in once and save the cookies
setup("sign in once", async ({ request }) => {
  await request.post("/api/login", { data: { email: "shopper@example.com", password: "correct-horse" } });
  await request.storageState({ path: ".auth/shopper.json" });
});

// playwright.config.js
projects: [
  { name: "setup", testMatch: /auth\.setup\.js/ },
  { name: "shared-login", dependencies: ["setup"], use: { storageState: ".auth/shopper.json" } },
],

This is what we did first, and it is exactly what most migration guides recommend. It passed every test with one worker. With four workers, it failed in 8 of the 10 runs we measured.

The trap: one shared session across parallel workers

Cypress runs the tests in a spec file one at a time, so a shared signed-in session is safe: each test empties the cart, does its work, and the next test starts clean. Playwright with four workers runs four tests at the same moment. With one shared cookie, all four browsers use the same server-side session and therefore the same cart.

The failures looked like ordinary flakiness. The cart badge showed the wrong number: another worker had just added a cap to the same cart, or emptied it in its own beforeEach, a moment before the assertion. Across 10 runs, between 1 and 3 tests failed per run, always among the three cart tests, and 2 runs passed by luck. Nothing in the error message pointed at the shared session.

Shared sign-in, 4 workersResult
Runs with at least one failure8 of 10
Tests failed per run0 to 3
Tests affectedadds items to the cart, places an order, shows a declined payment
Median wall time10.6 s, because failed assertions wait out their 5-second timeout

The fix is to give each worker its own session. A worker-scoped fixture signs in once per worker (four sign-ins instead of one), and every test in that worker reuses the cookie:

// fixtures.js
const base = require("@playwright/test");

exports.test = base.test.extend({
  workerSession: [async ({ playwright }, use) => {
    const api = await playwright.request.newContext({ baseURL: "http://localhost:4300" });
    await api.post("/api/login", { data: { email: "shopper@example.com", password: "correct-horse" } });
    await use(await api.storageState());
    await api.dispose();
  }, { scope: "worker" }],
  storageState: async ({ workerSession }, use) => use(workerSession),
});
exports.expect = base.expect;

With this fixture, the four-worker run passed 10 of 10 tests in all five runs, in a median of 5.8 seconds. In a real app, also give each worker its own test user (for example shopper-1@example.com to shopper-4@example.com, using workerInfo.workerIndex), because some data, such as saved addresses and order history, belongs to the user rather than the session.

The general rule: before turning on parallel workers, list every piece of state your tests share (users, carts, database rows, feature flags, files) and make sure each worker has its own copy. Our guide to parallel execution and sharding covers this in more depth for larger suites.

A migration checklist

  • Inventory the suite: count specs, custom commands, plugins, fixtures and every cy.intercept, so you know the size of the job.
  • Install Playwright next to Cypress and migrate spec by spec. Run both suites in CI until the Playwright one covers everything.
  • Turn custom commands into fixtures or plain helper functions. Sign-in, seeded data and API clients usually become fixtures.
  • Translate selectors to getByRole, getByLabel and getByTestId. If you used data-cy attributes, set testIdAttribute: "data-cy" in the config instead of renaming them.
  • Replace cy.wait("@alias") with web-first assertions, or with waitForResponse registered before the action.
  • Replace cy.wait(milliseconds) with an assertion on what the user should see. Fixed waits are the main source of slow and flaky tests in both tools.
  • Start with one worker and get the suite green. Then raise the worker count and run it 10 times to expose shared-state failures.
  • Give each worker its own session, test user and data. Never share a signed-in cookie across workers if the server keeps state per session.
  • Turn on traces for retries (trace: "on-first-retry") so CI failures can be replayed step by step.
  • Delete the Cypress suite only after the Playwright suite has run green in CI for a few weeks.

Is the migration worth it?

For our ten-test demo, the Playwright suite ran 59% faster on the same machine, and the difference grows with the suite, because parallel workers keep adding throughput while a single Cypress process runs specs one after another; parallel Cypress runs need several machines, coordinated by Cypress Cloud or your own sharding. Playwright also adds Firefox and WebKit coverage and multi-tab and multi-origin tests, which some teams cannot do in Cypress.

Cypress still has strengths: its interactive runner and time-travel debugging are excellent, and a small, stable suite that runs in a few minutes may not justify a rewrite. If your Cypress suite is slow, flaky, or blocking tests you need, such as Safari coverage or multiple tabs, migrate. If it is fast and trusted, keep it. Our Playwright vs Cypress vs Selenium comparison covers the wider trade-offs.

If you want help planning or doing a migration, our QA automation team has moved Cypress and Selenium suites to Playwright, including the CI setup, parallel data isolation and a period of running both suites side by side.

Frequently asked questions

How long does a Cypress to Playwright migration take? — The translation itself is fast: our ten tests took under an hour. In real projects, most of the time goes into custom commands, plugins, test data and making the suite safe to run in parallel. As a rough guide, plan for a few days per hundred tests, plus time to run both suites in CI side by side.

Is there a tool that converts Cypress tests to Playwright automatically? — Several converters and AI assistants can translate the syntax, and they are useful for a first draft. Review every converted test anyway: converters do not fix waits registered after the action, CSS selectors that should become roles, or state shared between parallel workers.

Can Cypress and Playwright run in the same repository? — Yes. They use different config files and folders, so you can migrate spec by spec and run both in CI until the Playwright suite covers everything.

Why did my tests start failing only after turning on parallel workers? — Almost always because tests share state that Cypress's one-at-a-time execution hid: a signed-in session, a user's cart, database rows or files. Give each worker its own session and data, then run the suite several times in a row to confirm the failures are gone.

Key takeaways

  • Our migrated suite ran in 5.8 s with four workers, against 14.3 s for Cypress on the same runner
  • Most of the gain came from lower start-up overhead and parallel workers, not faster individual tests
  • Register waitForResponse before the action, or rely on web-first assertions instead
  • Sharing one signed-in session across parallel workers failed 8 of 10 runs; sign in once per worker
  • Get the suite green on one worker first, then raise workers and run it 10 times

Free QA spot-check

Want a QA engineer to look at your product?

Send us your website or web app address. An engineer will test one key journey, such as sign-up, search or checkout, on desktop and mobile, and email you the most important issues we find. Free, and no call needed.

Related service

QA Testing →

Automated suites, manual deep-dives, and CI guardrails that catch regressions before your users do.

Further reading

About the author

Kawsher, Founder, QA Labs →

Kawsher is the founder of Quality Assurance Labs (QA Labs), the QA-first software testing and development company in Bogura, Bangladesh, founded in 2021. Every article and case study on this site is written or reviewed by Kawsher, drawing on the team's QA testing, AI, web, mobile, video and marketing work for SaaS and AI teams worldwide.

Notes from the lab.

Testing, engineering and growth — delivered to your inbox.

Planning a move from Cypress or Selenium to Playwright? Book a scoping call

Let's talk →