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.
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
--recordflag — or splitting the specs across CI jobs yourselves. - A
cypress/support/commands.jsthat 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
.skipnext 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
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 inpackage.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.A pilot slice, converted end to end
One spec that uses a custom command and waits on a
cy.interceptalias, 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.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.
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.continueand HAR replay, all above. Multiple tabs: Cypress's trade-offs page says you can test them "using our@cypress/puppeteerplugin", and Playwright's is "Each browser context can host multiple pages (tabs)", withcontext.newPage()and apage.waitForEvent('popup')promise held before the click that opens it. - Built in, but the scope is a conversation. Component testing runs on the
mountfixture that ships with@playwright/test, and the documentation points teams off the experimental@playwright/experimental-ct-reactand@playwright/experimental-ct-vuepackages 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-webkitare 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 thewebkitproject 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
- Playwright vs Cypress. The comparison, for the part of your team still arguing about the decision.
- Mocking APIs in Playwright. The long
version of the
cy.intercepttranslation, including HAR replay. - Playwright fixtures. Scopes, teardown and
auto, which is what your custom commands land in. - Parallel runs and sharding. How a suite gets split across workers and CI jobs.
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.