---
title: "Migrating from Cypress to Playwright: a step-by-step guide"
description: "Migrate from Cypress to Playwright: a command mapping table, the switch from chained commands to await, cy.session to storageState, and a CI setup."
canonical: "https://momentic.ai/blog/migrate-cypress-playwright"
last-updated: "2026-10-31T09:50:51Z"
---

# Migrating from Cypress to Playwright: a step-by-step guide

URL: https://momentic.ai/blog/migrate-cypress-playwright

[blog](/blog) [/ resources](/blog/category/resources)  / migrate-cypress-playwright

Resources

A practical plan for moving a Cypress suite to Playwright: command mapping, the async model change, what breaks, and running both suites during the move.

Wei-Wei Wu

CEO, Momentic

## TL;DR

- Run Cypress and Playwright in separate CI jobs during the migration. Keep Cypress coverage until the converted specs pass reliably.
- Convert specs file by file, starting with tests that cover your most important user flows.
- Expect more work than a command-by-command rewrite. Cypress queues chained commands, while Playwright requires `await`. Review assertions carefully because the frameworks retry them differently.

## Cypress to Playwright command mapping

Use this table to translate common commands, then check the surrounding test logic. Playwright actions return promises, so the converted spec must `await` them.

| Cypress | Playwright | Migration note |
| --- | --- | --- |
| cy.get('[data-testid="save"]') | page.locator('[data-testid="save"]') | Await the action on the locator, such as await locator.click(). |
| cy.intercept('GET', '/api/users', response) | await page.route('**/api/users', handler) | Register the route before the request starts. |
| cy.visit('/dashboard') | await page.goto('/dashboard') | Relative URLs require a configured baseURL. |
| cy.fixture('users.json') | Import JSON or expose data through a Playwright test fixture | A Playwright fixture supplies data to a test rather than queuing a command. |
| Cypress.Commands.add(...) | Playwright test fixtures or page-object helpers | Move shared setup into fixtures and reusable interactions into helpers. |
| cy.session('user', login) | test.use({ storageState: 'playwright/.auth/user.json' }) | Create the authenticated state first. storageState does not preserve sessionStorage. |
| .should('be.visible') | await expect(locator).toBeVisible() | Use a Playwright assertion so the condition retries until its timeout. |

A route handler needs to fulfill or continue the request. For example, this Playwright test registers a stub before opening the page.

`import { test, expect } from '@playwright/test';  test('shows the account name', async ({ page }) => {  await page.route('**/api/account', async (route) => {  await route.fulfill({ json: { name: 'Ada' } });  });   await page.goto('http://localhost:3000/account');  await expect(page.getByText('Ada')).toBeVisible(); });`

The example assumes your app serves `/account` locally and requests `/api/account`. Replace those paths with your app’s routes.

## The async model change: chained commands vs await

Cypress queues commands and retries queries and assertions, so a chain can wait for the page to reach the expected state. Playwright runs asynchronous actions in sequence only when you `await` them. Its actions wait for conditions such as visibility and readiness to receive a click, while `expect` assertions retry until they pass or time out.

With a base URL configured, the same search test looks like this in Cypress.

`it('shows search results', () => {  cy.visit('/search')  cy.get('input[name="query"]').click().type('invoice')  cy.get('button[type="submit"]').click()  cy.get('#status').should('have.text', 'Results for invoice') })`

In Playwright, `fill()` replaces the input value, and each action and assertion needs `await`.

`import { test, expect } from '@playwright/test'  test('shows search results', async ({ page }) => {  await page.goto('/search')  await page.locator('input[name="query"]').click()  await page.locator('input[name="query"]').fill('invoice')  await page.locator('button[type="submit"]').click()  await expect(page.locator('#status')).toHaveText('Results for invoice') })`

A mechanical replacement can leave promises unhandled. If you omit `await` before `click()`, the test may start typing before the click finishes, causing a race condition. Replacing `should('have.text', ...)` with a one-time `textContent()` check creates a different problem. The check can read the old DOM before the result appears, while `await expect(locator).toHaveText(...)` keeps checking within its timeout.

## Step-by-step migration

Migrating file by file lets you compare the new Playwright suite with the Cypress suite before removing either one. Start with a spec that covers an important user path, then carry its behavior, authentication, and CI checks across.

### 1. Install Playwright alongside Cypress

Keep Cypress installed while you port specs. Add Playwright and its browser binaries without changing the existing Cypress scripts.

`npm install --save-dev @playwright/test npx playwright install`

Put new specs in `tests/playwright` so Playwright does not discover Cypress files. Commit the Playwright dependency and configuration before converting tests.

### 2. Port the base URL and environment values

Create `playwright.config.ts`. Playwright reads environment variables through `process.env`, so replace `Cypress.env('NAME')` references with the corresponding `process.env.NAME` references as you convert each spec.

`import { defineConfig } from '@playwright/test';  export default defineConfig({  testDir: './tests/playwright',  use: {  baseURL: process.env.BASE_URL ?? 'http://localhost:3000',  trace: 'retain-on-failure',  }, });`

The example assumes your app runs on port 3000. Start it before running either suite, or set `BASE_URL` to the address your CI job uses. Keep secrets in CI environment variables rather than committing them to the config.

### 3. Convert one spec end to end

Port the interaction and its assertion together. A Cypress chain queues commands, while a Playwright test must `await` browser actions and assertions. Leaving out an `await` can let the test finish before the browser completes the action.

`// Cypress describe('product search', () => {  it('shows matching results', () => {  cy.visit('/products');  cy.get('[data-testid="search"]').type('keyboard');  cy.get('[data-testid="results"]')  .should('contain.text', 'keyboard');  }); });`

`// tests/playwright/products.spec.ts import { test, expect } from '@playwright/test';  test('product search shows matching results', async ({ page }) => {  await page.goto('/products');  await page.getByTestId('search').fill('keyboard');  await expect(page.getByTestId('results')).toContainText('keyboard'); });`

Run the new spec against the same app as its Cypress counterpart.

`npx playwright test tests/playwright/products.spec.ts`

Replace selectors when the existing ones depend on fragile DOM structure. Playwright's [locator guide](/blog/playwright-locators-guide) covers the available locator strategies. In this example, `toContainText` retries the assertion while results load. A one-time read of `textContent()` would not provide that retry behavior.

### 4. Convert custom commands into explicit helpers or fixtures

Move a custom command's implementation when you move the specs that call it. A small command can become a helper that accepts `page`, making its browser dependency visible at the call site.

`// tests/playwright/helpers/catalog.ts import type { Page } from '@playwright/test';  export async function openCatalog(page: Page) {  await page.goto('/products'); }`

`import { openCatalog } from './helpers/catalog';  await openCatalog(page);`

Use a Playwright fixture when the replacement must prepare state for many tests, and use a page object when several tests share interactions with the same page. Do not translate a custom command mechanically if it also hides network stubs or test data setup. Port those effects explicitly and check the resulting behavior.

### 5. Replace `cy.session()` with saved authentication state

Playwright can save cookies and local storage after login, then load that state in authenticated specs. Create a setup test using your application's actual login fields and success condition.

`// tests/playwright/auth.setup.ts import { test, expect } from '@playwright/test';  test('save authenticated state', async ({ page }) => {  await page.goto('/login');  await page.getByLabel('Email').fill(process.env.E2E_EMAIL!);  await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);  await page.getByRole('button', { name: 'Sign in' }).click();  await expect(page).toHaveURL(/\/dashboard/);  await page.context().storageState({  path: 'playwright/.auth/user.json',  }); });`

Create the directory, run setup, and load the file only in specs that need authentication.

`mkdir -p playwright/.auth npx playwright test tests/playwright/auth.setup.ts`

`// At the top of an authenticated spec import { test, expect } from '@playwright/test';  test.use({ storageState: 'playwright/.auth/user.json' });`

Add `/playwright/.auth/` to `.gitignore`. Run the setup test before authenticated specs in CI, and regenerate the file when the session expires. Saved state does not reproduce every `cy.session()` callback, so retain any test-specific setup separately.

### 6. Run both suites in CI until coverage matches

Give Cypress and Playwright separate CI jobs against the same app build. The Playwright job needs browser installation and, if it runs authenticated specs, the authentication setup before the suite.

`# Cypress job npx cypress run`

`# Playwright job, after starting the app npx playwright install mkdir -p playwright/.auth npx playwright test tests/playwright/auth.setup.ts npx playwright test`

Exclude `auth.setup.ts` from the main Playwright run or configure it as a setup project, so it does not run again as an ordinary spec. Keep both jobs required while you compare the user paths and assertions they cover. Retire a Cypress spec after its Playwright replacement checks the same behavior, rather than after a fixed migration date. The [Playwright CI/CD guide](/blog/playwright-ci-cd-guide) covers pipeline configuration in more detail.

## What breaks when you migrate

Cypress assertions can pass while a direct Playwright translation flakes because the two frameworks retry different operations. Cypress retries a query and its chained assertion until the condition passes or times out. Playwright retries web-first assertions such as `toHaveText`, but `expect(await locator.textContent()).toBe('Ready')` compares one value captured at one moment. Use `await expect(locator).toHaveText('Ready')` for DOM state, or `expect.poll` when the value comes from a function rather than a locator. Playwright’s action auto-waiting does not make a later one-shot assertion retry.

Iframe selectors need to start inside the frame. A Cypress test that reads an iframe’s document cannot replace `cy.get` with `page.locator`, because the page locator searches the parent document. Use `frameLocator` to reach the element, then apply a web-first assertion.

`const checkout = page.frameLocator('iframe[name="checkout"]') await expect(checkout.getByRole('button', { name: 'Pay' })).toBeVisible()`

Component tests need a separate migration decision. Playwright’s component testing is experimental, and Cypress `mount` helpers and their custom commands do not translate directly into Playwright component tests. If component tests make up a substantial part of your suite, keep them in Cypress while you migrate end-to-end specs. Evaluate Playwright’s component testing against your components and test helpers before retiring that suite.

## Running both suites during the migration

Run Cypress and Playwright in separate CI jobs while you port specs. A hard cutover removes regression checks for behavior the new suite has not covered yet. During the overlap, keep the Cypress job required and add the Playwright job so you can investigate failures without losing the existing check.

Both jobs should test the same commit against a comparable application environment. Give each job its own server process and port, or use separate test environments if they run concurrently. Keep framework-specific config, environment variables, and test artifacts separate so one job cannot overwrite the other’s results.

Port specs in small batches and compare the behavior each suite verifies, not just the number of passing tests. Retire a Cypress spec when its Playwright replacement covers the same assertions and relevant browser configuration, and the new job runs reliably in CI. Remove the Cypress job only after the remaining Cypress coverage has a replacement or an explicit reason to drop it.

## CI example: running Playwright in the pipeline

Run Playwright in its own CI job while Cypress remains active. The job below installs the project’s locked dependencies and Playwright browsers, then uploads the HTML report and retained traces if a test fails.

`name: Playwright  on:  push:  pull_request:  jobs:  playwright:  runs-on: ubuntu-latest  steps:  - uses: actions/checkout@v4  - uses: actions/setup-node@v4  with:  node-version: 20  cache: npm  - run: npm ci  - run: npx playwright install --with-deps  - run: npx playwright test --trace retain-on-failure --reporter=html  - uses: actions/upload-artifact@v4  if: ${{ failure() }}  with:  name: playwright-failures  path: |  playwright-report/  test-results/  if-no-files-found: ignore`

The test command assumes your Playwright config starts the application through `webServer` or points tests at an accessible environment. Keep the Cypress job separate so a Playwright failure cannot hide a Cypress failure during the migration. For deployment environments and other pipeline patterns, see the [Playwright CI/CD guide](/blog/playwright-ci-cd-guide).

## How long migrating 500 tests actually takes

A 500-test count cannot produce a credible migration time estimate on its own. A suite with shared custom commands and stable selectors takes different work than one with duplicated setup and flaky tests. Use a representative pilot to measure each path before committing to a deadline.

| Path | Planning estimate for 500 tests | What to measure in the pilot |
| --- | --- | --- |
| Manual engineering | Pilot hours per converted test × 500, plus shared setup and CI work | Time spent replacing custom commands, debugging flakes, and reviewing coverage |
| Coding agent | Pilot review and repair hours per converted test × 500, plus shared setup and CI work | How often generated tests need corrections, especially around custom commands and existing flakes |
| Momentic | Depends on how many user journeys the suite covers; there is no syntax conversion step | Time to express the existing suite’s intent, have agents author and run tests, review repairs, and verify coverage |

The manual and coding-agent estimates depend heavily on custom command density and existing flake, not test count alone. For either path, sample simple and complex specs so one easy file does not set the schedule for the whole suite.

Momentic’s path is a separate evaluation, not a faster syntax conversion. Its agents can author, run, and repair tests based on the behavior you want to verify, but you still need to check that the new tests cover the old suite’s important assertions.

## Where Momentic fits after the migration

Playwright changes how you write and run browser tests, but you still own selector upkeep, flaky-test triage, and decisions about which behavior needs coverage. Keep those responsibilities visible after the Cypress suite retires.

If you add Momentic as a verification layer beside Playwright, define which user behaviors each suite checks and review failures for useful signal. Keep existing Playwright tests until any replacement checks have demonstrated equivalent coverage. Engineers still weighing the framework choice can read [Cypress alternatives](/blog/cypress-alternatives) and [Playwright vs. Cypress](/blog/playwright-vs-cypress-pros-and-cons).

To evaluate Momentic against your migrated suite, [sign up](/).

## Frequently asked questions

Can Cypress and Playwright coexist in one repo long-term?    Yes. Keep their configs and test directories separate, and run each suite in its own CI job. You will maintain two sets of dependencies and test infrastructure for as long as both remain.    What happens to Cypress plugins without a Playwright equivalent?    Identify what each plugin does before replacing it. A plugin that creates test data may become a Node helper, while a plugin that changes browser behavior may require a different approach.    Does the TypeScript config need to change?    Not necessarily. If Cypress and Playwright types conflict, give each suite its own tsconfig.json with separate test-file includes.    How should I replace arbitrary `cy.wait()` timeouts?    Wait for the condition the test needs, such as a visible element with expect(locator).toBeVisible() or a specific response with page.waitForResponse(). A fixed delay can expire before the condition is ready or waste time after it is ready.

Still have additional questions?

## Keep reading.

[Resources   Testing iOS and Android apps with Claude Code     Four ways to test iOS and Android apps with Claude Code, from simctl and adb to XCUITest, Espresso, mobile MCP servers and Momentic mobile tests.     Wei-Wei Wu     12 min read](/blog/claude-code-mobile-app-testing)[Resources   UI and end-to-end testing with OpenAI Codex     How far Codex gets on UI and end-to-end tests, and how AGENTS.md instructions plus a test runner over MCP turn its output into checks you can trust.     Wei-Wei Wu     8 min read](/blog/codex-ui-testing-guide)[Resources   Migrating from Appium to Maestro     How to move an Appium suite to Maestro flows, with a worked login example, a concept map and the cases where Appium is still the better fit.     Wei-Wei Wu     6 min read](/blog/migrate-appium-to-maestro)

## Close the feedback loop.

Point Momentic at your app. Free to start, no credit card.

[Try for free](https://app.momentic.ai/signup) [Contact sales](/sales)
