Home / Services / Cypress to Playwright migration

Cypress to Playwright migration, custom commands included

The suite ends up as Playwright specs in your repository, sharded across workers in your pipeline, with nothing left in it that needs cypress/support to run.

Short answer

We move Cypress suites onto Playwright: the specs, the custom commands, the network mocks and the CI job. Custom commands become fixtures, cy.intercept becomes page.route, and the queued command chain becomes ordinary async/await — which is where a find-and-replace conversion goes wrong. Engineers are billed hourly, from $50 an hour, minimum one full-time engineer for one month.

We scope it first: what the suite contains, how much of it lives in custom commands, and whether the conversion is worth paying for.

Who this is for

Teams who chose Cypress deliberately, got years out of it, and have now walked into something the runner will not do for them.

  • A CI job long enough that nobody waits for it any more, where parallelising it means recording the run to Cypress Cloud — Cypress's documentation says running tests in parallel requires the --record flag — or splitting the specs across CI jobs yourselves.
  • A cypress/support/commands.js that has grown past the point where one person can safely edit it, because much of the suite depends on a command whose behaviour nobody can hold in their head.
  • A coverage gap with a workaround, a plugin or a .skip next to it: a second tab, a second origin, a download, or a browser engine you cannot honestly test in.

Your specs are already JavaScript or TypeScript, so the converted suite stays there and nobody on your team learns a language to read it. Playwright's other official bindings, Python, Java and .NET, are not a decision this migration has to make.

If one engineer could convert the suite between two releases, do that. Everything below about fixtures, routing and the command queue works when you apply it yourself. Cypress is one of three sources we see most often and not the limit of what moves: The migration hub carries the method that covers the rest.

What you get

The converted suite

Converted specs arriving in your repository through your own pull requests. Anything carrying a .skip or a quarantine tag gets fixed, or gets listed as a deliberate drop with the reason beside it.

The support directory, rebuilt

The custom commands re-expressed as fixtures and plain functions, and the global hooks in cypress/support/e2e.js as configuration and setup projects. It is the directory that shrinks most.

The CI job that runs it

The pipeline file that runs the suite: sharded across jobs, reporter wired up, traces kept when a test retries. Parallelism becomes a worker count and a --shard flag that live in your repository next to the tests. The playwright.config.ts that replaces your cypress.config.ts comes with it.

How it works

  1. Inventory

    We read the repository and a few weeks of CI history and write down what is in there: how many spec files there are, how many pass today, how much of the code lives in cypress/support, every plugin in package.json, how many pipelines run any of it, and where the test data comes from. You give us read access to the repository and the CI runs, plus an engineer we can ask about the suite's history.

  2. A pilot slice, converted end to end

    One spec that uses a custom command and waits on a cy.intercept alias, converted end to end, green in CI, and put through your own review process. Those two conversions are the ones the estimate turns on, so they get argued over working tests, and the rest of the suite is written in the vocabulary the pilot settles.

  3. The bulk conversion

    Spec by spec, in batches small enough to review. Your side pays for this in review time: each batch arrives as a pull request, and the person who can say why a custom command behaved the way it did has to read it. Specs that were passing because the command queue drained before an assertion ran start failing here, which is the first time anyone finds out they were passing for the wrong reason. Batches merge as they are reviewed, so work that stops halfway leaves the converted specs in your repository and the Cypress job still in the pipeline.

  4. Cutover

    Both suites run on every commit, which is easier here than on most migrations: both runners are npm scripts in one repository, so CI runs them side by side without new infrastructure. The Cypress job stays in the pipeline until the Playwright job has passed on the same commits often enough that your team stops checking both.

There are no weeks in that list, on purpose. The inventory counts what sets the schedule and the pilot slice prices it, which is why those two come before anything is committed to. Anyone quoting a range before they have happened is quoting a sales figure. What gets counted is in the questions at the foot of this page.

What a converted spec looks like

An estimate gets built out of the conversions below. The Cypress in them is Cypress 15.21.1, and the version matters: most of the "Cypress cannot do that" lines still in circulation were true several majors ago and are not true now.

The chain that is not a promise chain

A Cypress spec and its Playwright equivalent rhyme. Here is the Cypress:

// cypress/e2e/customer-search.cy.ts
describe('customer search', () => {
  beforeEach(() => {
    cy.visit('/customers');
  });

  it('opens a customer from the results', () => {
    cy.get('[data-testid=search]').type('Ada Lovelace{enter}');
    cy.get('[data-testid=result]').first().click();
    cy.get('h1').should('contain', 'Ada Lovelace');
    cy.get('[data-testid=plan]').should('be.visible');
  });
});

And the same test in Playwright:

// tests/customer-search.spec.ts
import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  await page.goto('/customers');
});

test('opens a customer from the results', async ({ page }) => {
  await page.getByTestId('search').fill('Ada Lovelace');
  await page.getByTestId('search').press('Enter');
  await page.getByTestId('result').first().click();

  await expect(page.getByRole('heading', { name: 'Ada Lovelace' })).toBeVisible();
  await expect(page.getByTestId('plan')).toBeVisible();
});
The Playwright samples on this page are written against Playwright 1.62.

The two files are about the same length and they do not run the same way. Cypress's own documentation is the clearest statement of the difference: commands "don't do anything at the moment they are invoked, but rather enqueue themselves to be run later", they "do not return their subjects, they yield them", and they "are not Promises and cannot be awaited". Playwright is ordinary async/await, and every action and every assertion above is a promise somebody has to wait on.

That difference produces two failures a mechanical conversion cannot see, and both leave the suite green. A dropped await compiles, runs and passes, because the assertion is evaluated against a page that has not settled. And plain JavaScript written between two commands ran in Cypress before the queue drained, while in Playwright it runs exactly where it sits: a counter, a read of a variable, a conditional.

The assertions change with them. Every .should('be.visible') in the suite becomes await expect(locator).toBeVisible(), and Playwright's assertions documentation says what that buys: "The following assertions will retry until the assertion passes, or the assertion timeout is reached." It is equally plain that non-retrying assertions "can lead to a flaky test". Where a converted assertion has no retrying form, expect.poll and expect.toPass are the escape hatches.

The waits that get deleted

The fixed sleep somebody added in a hurry is a deletion. The alias wait next to it is a translation:

// cypress/e2e/billing.cy.ts
describe('billing', () => {
  it('saves the plan', () => {
    cy.visit('/customers/42');
    cy.get('[data-testid=plan]').clear().type('enterprise');

    cy.intercept('PUT', '/api/customers/42').as('saveCustomer');
    cy.get('button[type="submit"]').click();
    cy.wait('@saveCustomer');
    cy.wait(2000);

    cy.contains('Saved').should('be.visible');
  });
});

In Playwright the sleep goes and the response wait moves in front of the click:

// tests/billing.spec.ts
import { test, expect } from '@playwright/test';

test('saves the plan', async ({ page }) => {
  await page.goto('/customers/42');
  await page.getByTestId('plan').fill('enterprise');

  const responsePromise = page.waitForResponse('**/api/customers/42');
  await page.getByRole('button', { name: 'Save' }).click();
  const response = await responsePromise;

  expect(response.ok()).toBeTruthy();
  await expect(page.getByText('Saved')).toBeVisible();
});

Setting the wait up before the action is the opposite of the reflex, and a converted suite that gets it round the wrong way waits for a response that has already arrived. Often the whole thing comes out: an auto-retrying assertion on the row that appears is a better test than a wait on the request that produced it. Not every alias wait is deletable, though. The ones asserting on the request or the response body become assertions on the response object, as above; deleting them silently removes coverage nobody notices is gone.

cy.intercept becomes page.route

The capability survives intact and the API changes. A stubbed empty state, in Cypress:

// cypress/e2e/customers-empty.cy.ts
describe('customer list', () => {
  it('shows an empty state', () => {
    cy.intercept('GET', '/api/customers', { fixture: 'customers-empty.json' }).as('getCustomers');
    cy.visit('/customers');
    cy.wait('@getCustomers');

    cy.contains('No customers yet').should('be.visible');
  });
});

And in Playwright:

// tests/customers-empty.spec.ts
import { test, expect } from '@playwright/test';

test('shows an empty state', async ({ page }) => {
  await page.route('**/api/customers', route => route.fulfill({ json: { data: [] } }));

  await page.goto('/customers');
  await expect(page.getByText('No customers yet')).toBeVisible();
});

The pass-through cases have counterparts too: route.abort() blocks a request, route.continue({ headers }) lets it through with something changed, and route.fetch() reads the real response so a test can modify it before it reaches the page. A route can also be registered on the browser context instead of on one page, so the intercept your suite repeats in every beforeEach usually collapses into a single fixture. page.routeFromHAR serves matching requests out of a recorded HAR file, which is one answer when your fixtures have drifted away from what the API actually returns.

A custom command becomes a fixture

A realistic command, registered the way custom commands are registered, with Cypress.Commands.add(name, callbackFn):

// cypress/support/commands.ts
Cypress.Commands.add('login', (email: string, password: string) => {
  cy.visit('/login');
  cy.get('[data-testid=email]').type(email);
  cy.get('[data-testid=password]').type(password);
  cy.get('button[type="submit"]').click();
  cy.get('[data-testid=account-menu]').should('be.visible');
});

In Playwright it becomes a fixture, which the specs request by name:

// tests/fixtures.ts
import { test as base, expect, type Page } from '@playwright/test';

type Fixtures = {
  signedIn: Page;
};

export const test = base.extend<Fixtures>({
  signedIn: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByTestId('email').fill('ada@example.com');
    await page.getByTestId('password').fill('hunter2');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page.getByTestId('account-menu')).toBeVisible();

    await use(page);

    await page.getByTestId('account-menu').click();
    await page.getByRole('menuitem', { name: 'Sign out' }).click();
  },
});

export { expect };
// tests/customers.spec.ts
import { test, expect } from './fixtures';

test('lists customers once signed in', async ({ signedIn }) => {
  await signedIn.goto('/customers');

  await expect(signedIn.getByRole('heading', { name: 'Customers' })).toBeVisible();
});

The code after await use(page) is the fixture's teardown half — Playwright's fixture documentation puts setup and teardown either side of that call — and it runs once the test is done with the fixture. A custom command has no such half, which is why cleanup in a Cypress suite tends to live in an afterEach a long way from the thing it cleans up. Scope is now a decision: a fixture runs per test by default, { scope: 'worker' } runs it once per worker process, and { auto: true } runs it whether a test asked for it or not, which is where a login that used to run on every single test stops doing that.

And not every command should become a fixture. Cypress's documentation gets there first — "Don't make everything a custom command", and "Can this be written as a function? The answer is usually yes." A migration that turns three hundred lines of commands into three hundred lines of fixtures has moved the problem into a new file. The ones that were only ever a function with a Cypress wrapper on it become functions.

cypress.config.ts becomes playwright.config.ts

Leaving Cypress means leaving a runner, so the config file is a deliverable in its own right. A Cypress config carries the runner's whole job:

// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'https://staging.example.com',
    specPattern: 'cypress/e2e/**/*.cy.ts',
    supportFile: 'cypress/support/e2e.ts',
    retries: { runMode: 1, openMode: 0 },
    video: false,
  },
});

And the Playwright config that takes it over:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  workers: process.env.CI ? 4 : undefined,
  retries: process.env.CI ? 1 : 0,
  reporter: [['html'], ['list']],
  use: {
    baseURL: 'https://staging.example.com',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
});

The supportFile line has no counterpart: what it loaded becomes fixtures the specs ask for by name and setup the config runs. Each entry under projects is a browser engine your suite now runs against, workers is the parallelism on one machine, and npx playwright test --shard=1/4 in four CI jobs runs a quarter of the suite in each. All of it is configuration in the open-source runner.

Two things called test isolation

A team that has tuned the Cypress option needs to know what it maps onto. Cypress resets the browser context before each test by visiting about:blank and clearing cookies, localStorage and sessionStorage across all domains; with it disabled, its documentation says "the page does not clear between tests and cookies, local storage and session storage will be available across tests in that suite." Playwright gets the same property from browser contexts instead of from an option: its documentation says "Playwright uses browser contexts to achieve Test Isolation", one context per test, and calls contexts "fast and cheap to create and are completely isolated, even when running in a single browser."

A suite that turned isolation off so a login would survive between tests has a dependency between those tests, and it has to be found and replaced, usually with stored authentication state, before any of those specs can be converted honestly. It is one of the few things in a Cypress repository that makes a small suite an expensive one.

What happens to the plugins

Your dependencies are plugins bolted into a runner, and the list in your package.json is the risk register for the whole job. Three buckets:

  • Built into Playwright, so the plugin comes out. Network stubbing: page.route, route.fulfill, route.abort, route.continue and HAR replay, all above. Multiple tabs: Cypress's trade-offs page says you can test them "using our @cypress/puppeteer plugin", and Playwright's is "Each browser context can host multiple pages (tabs)", with context.newPage() and a page.waitForEvent('popup') promise held before the click that opens it.
  • Built in, but the scope is a conversation. Component testing runs on the mount fixture that ships with @playwright/test, and the documentation points teams off the experimental @playwright/experimental-ct-react and @playwright/experimental-ct-vue packages onto it.
  • Read one at a time, in the inventory. Accessibility, visual snapshots, BDD preprocessors, coverage, real events, tag filtering, uploads, reporters. Some are a swap and some are a rebuild, and we name a counterpart for a plugin after we have opened its documentation, not before.

Where this stops

The move takes coverage with it in every direction but these.

  • Safari on a real iOS device. Not something we can give you, and Cypress is the reason this one gets misread. Cypress's experimental WebKit support is literally Playwright's WebKit: the experiments page says that when the flag is set, installs of playwright-webkit are detected and made available in Cypress. So a team arriving from Cypress can reasonably think Playwright's WebKit is where iPhone coverage comes from. It is not. Playwright's WebKit is a patched build of the engine that Playwright ships and runs — its browser documentation says Playwright does not work with the branded version of Safari, because it relies on those patches — and what you get is a second real engine running unflagged in CI on every commit, next to Chromium and Firefox. What you do not get is Safari, on iOS, on hardware, with that version's quirks. If you have that coverage today through a device cloud, keep it: it does not come across, and the webkit project in the config above is not a substitute for it.
  • Native mobile apps. cy.viewport() and Playwright's device descriptors are both browser emulation — a viewport, a user agent, touch input — and neither was ever a device or an app. Playwright drives browsers, so we test your mobile web at a phone-sized viewport and we do not drive an iOS or Android application.
  • Load and performance testing. If a Lighthouse or performance-audit plugin is wired into your Cypress run, it does not have a home in this engagement. Playwright measures one real browser doing one thing well; it is not k6, JMeter or Gatling, and we do not sell load testing in it.
  • Security and penetration testing. Not offered here, in any framework.

Playwright does component testing, built in, which is why it is not on this list. Whether converting your component tests belongs in this engagement or gets scoped on its own is open, and that is a question about us, not about Playwright.

What it costs

This is priced per engineer, because a conversion consumes engineer time. Engineers are billed hourly, from $50 an hour, depending on where the engineer sits. The minimum engagement is one full-time engineer for one month.

What you pay for a Cypress job moves with the number of spec files, how much of the suite lives in cypress/support instead of in the specs, how many plugins are in the repository and how many of those have no counterpart, how many pipelines run any of it, how much test data is set up by clicking through the UI, and whether the suite has state dependencies between tests. A suite of thin specs over a very thick commands.js takes longer to convert than its spec count suggests.

Read first

Questions

How long does it take to migrate a Cypress suite to Playwright?

Nobody can answer that from a web page, and a figure that ignored your repository would be wrong the moment we opened it. It is decided by how many spec files there are, how much of the suite lives in cypress/support, how many plugins are in your package.json and how many of those have no counterpart, how many pipelines run any of it, and whether specs depend on state an earlier spec left behind. The inventory phase measures all of that before anyone commits to a schedule.

What happens to our custom commands?

They get rebuilt, and they land in three places. Commands that set something up and tear it down again become fixtures. Commands that were only ever a function with a Cypress wrapper around them become functions, which is what Cypress's own documentation recommends for them. Commands that exist to hold a wait around a race condition are deleted, because Playwright's actionability checks and auto-retrying assertions already cover what the wait was papering over. Which command goes where is argued command by command during the pilot, on real specs, and your team reviews it.

Do our cy.intercept mocks port to Playwright?

Yes. page.route with route.fulfill covers what cy.intercept was doing, and route.abort and route.continue cover blocking a request and passing one through with modified headers. The habits around them change. The wait is set up before the action: you hold the promise from page.waitForResponse, then click, then await the promise, which is the opposite order to cy.wait on an alias. And a route can be registered on the browser context, so an intercept repeated in every beforeEach usually collapses into one fixture.

We use Cypress component testing - does that move too?

Playwright has component testing and it is built in: the mount fixture ships with @playwright/test, and the documentation points teams off the experimental @playwright/experimental-ct-react and @playwright/experimental-ct-vue packages onto it. So the framework is not the obstacle. Whether converting your component tests sits inside this engagement or gets scoped on its own is a question for the first call, because a component-test setup is usually the part of a Cypress repository that looks least like the end-to-end suite.

Do we need the audit before you start?

No. The audit is there for a team that wants to know what it has before it picks a direction, and a team that has decided to leave Cypress has already picked one. We read the repository, scope the migration and start. It is optional, it is billed by the hour, and how long it takes depends on the project. The minimum of one full-time engineer for one month does not apply to it.

Send us the spec count and your support directory

The number of spec files, what is in cypress/support, and your package.json. With those in front of us we can say which parts of the suite convert on the first pass and which ones need a decision from you before anyone touches them. We do not need an audit to start. Who runs the suite after the migration is a separate engagement.