Skip to content

Blog / Cypress

Cypress

Migrate from Cypress to Playwright: Command Map, Plan and Pitfalls

A command-by-command map from Cypress to Playwright, plus a migration plan that keeps CI green while you switch.

October 23, 2026 · Blendra Team · 17 min read

To migrate Cypress to Playwright well, you rewrite the test layer, not just the syntax. Commands become awaited calls, custom commands become fixtures, cy.intercept becomes page.route, and cy.session becomes a saved storageState file. A few defaults also flip without warning, and those break ports quietly. This guide gives you a command map tested in both tools, the same suite written twice, the real errors you'll hit, and a plan that keeps CI green the whole way.

Everything here was run against a small demo task app, with no third-party sites, using Cypress 16.2.0 (Chrome 156) and Playwright 1.61 (Chromium) on Linux, in October 2026. Both suites pass: Cypress 9 of 9, Playwright 10 of 10. With --repeat-each=10, every Playwright test also passed 10 times in one parallel run.

Key takeaways

  • Port behaviour, not lines. A .should() chain becomes one web-first expect. A .then() callback becomes a plain await.
  • Several defaults change silently. For example, Playwright dismisses confirm() dialogs, ignores uncaught page errors, runs files in parallel, and matches role names by substring.
  • Turn on @typescript-eslint/no-floating-promises before you port the first spec. A missing await is a bug Cypress never let you write.
  • Run both suites in CI. Delete each Cypress spec in the same pull request that adds its Playwright replacement.

Should you migrate at all?

A migration takes real time and ships no product feature. Do it for a reason you can name.

Good reasons, each one a limit described in Cypress's own docs (see Cypress vs Playwright for the sourced comparison):

  • Your hard flows open a second tab, or put form fields inside an iframe.
  • You need WebKit as a supported browser. In Cypress it's experimental.
  • Your QA team wants Python, Java or .NET.
  • You want to run tests in parallel across CI machines without a paid service.

Weak reasons: "Playwright is newer" or "everyone is switching". If your Cypress suite is stable and your app lives on one origin in one tab, a migration buys you little. Component testing is another reason to wait. Cypress's support there is mature, while Playwright has just replaced its experimental component-testing packages with a new story-gallery approach.

Before you decide, port your two hardest specs. If they port cleanly in a day, the rest will follow. If they fight you, you've learned that cheaply.

The command map

Each row below runs in the demo repo, in cypress/e2e/command-map.cy.ts and tests/command-map.spec.ts. The Playwright column assumes you await each action and each expect. Two Cypress commands here are new: cy.getByRole arrived in 16.2.0 and cy.env() in 15.10.0. Older suites use Testing Library's cy.findByRole and Cypress.env('X'), which get rows too, though the demo doesn't run them.

Cypress Playwright Watch out for
cy.visit('/tasks') await page.goto('/tasks') Both resolve against the base URL: baseUrl in Cypress, baseURL in Playwright.
cy.get('#new-task') page.locator('#new-task') A locator finds nothing until you act or assert on it.
cy.get('[data-testid="open-count"]') page.getByTestId('open-count') Playwright reads data-testid by default.
cy.contains('Review the login PR') page.getByText('Review the login PR') cy.contains is case-sensitive by default. getByText isn't.
cy.contains('li', 'Review the login PR') page.getByRole('listitem').filter({ hasText: 'Review the login PR' })
cy.getByRole('button', { name: 'Add task' }) page.getByRole('button', { name: 'Add task' }) Name matching differs. See below.
cy.findByRole('button', { name: 'Add task' }) (Testing Library) page.getByRole('button', { name: 'Add task' }) Here too, a string name must match the whole accessible name. Playwright's matches a substring.
.within(() => cy.getByRole('checkbox')) row.getByRole('checkbox') Chain locators instead of changing scope.
.first() / .eq(1) / .last() .first() / .nth(1) / .last()
.find('input') .locator('input')
.type('draft') .fill('draft') fill sets the value in one step. Use pressSequentially when the app listens to each key.
.type('Buy milk{enter}') .fill('Buy milk'), then .press('Enter')
.clear() .clear()
.check() / .select('Done') .check() / .selectOption('Done')
.should('be.visible') expect(locator).toBeVisible()
.should('not.be.visible') expect(locator).toBeHidden()
.should('not.exist') expect(locator).toHaveCount(0)
.should('have.text', '2 open') expect(locator).toHaveText('2 open')
.should('contain', 'Write') expect(locator).toContainText('Write')
.should('have.length', 3) expect(locator).toHaveCount(3)
.should('have.class', 'done') expect(locator).toContainClass('done') toHaveClass('done') must match the whole class attribute.
.should('be.checked') / .should('be.disabled') toBeChecked() / toBeDisabled()
.should('have.value', '') toHaveValue('')
.should('have.attr', 'autocomplete', 'off') toHaveAttribute('autocomplete', 'off')
cy.url().should('include', '/tasks') expect(page).toHaveURL(/\/tasks/)
cy.location('pathname').should('eq', '/login') expect(page).toHaveURL('/login') A path is resolved against baseURL.
.invoke('text').then((text) => …) const text = await locator.textContent() A one-time read that never retries.
cy.intercept(…).as('tasks') + cy.wait('@tasks') page.waitForResponse(url), started before the action
cy.intercept(url, { fixture: 'tasks.json' }) page.route(url, (r) => r.fulfill({ path })) Playwright globs must match the whole URL.
cy.request('POST', url, body) page.request.post(url, { data: body })
cy.clock() / cy.tick(3000) page.clock.install() / page.clock.runFor(3000) Install the clock before goto.
cy.on('window:confirm', () => true) page.once('dialog', (d) => d.accept()) Without a handler, Playwright dismisses.
cy.viewport(375, 812) page.setViewportSize({ width: 375, height: 812 })
cy.go('back') / cy.reload() page.goBack() / page.reload()
cy.getCookie('session') (await page.context().cookies()).find(…)
cy.window().its('innerWidth') page.evaluate(() => window.innerWidth)
cy.task('uniqueTitle', 'Buy milk') Call the Node code directly Playwright tests already run in Node.
cy.env(['TASKS_PASSWORD']) or Cypress.env('TASKS_PASSWORD') process.env.TASKS_PASSWORD Cypress.env() was removed in Cypress 16.0.
cy.fixture('tasks.json') import tasks from '../cypress/fixtures/tasks.json' Needs resolveJsonModule in tsconfig.json.
cy.session(…) A setup project plus storageState See below.
Cypress.Commands.add(…) A fixture from test.extend(), or a plain function See below.
cy.origin(url, () => …) Nothing. Just navigate.

Three rows need more than a footnote.

Role names match differently. In Playwright, a string name is a case-insensitive substring, unless you pass exact: true (getByRole docs). So name: 'Delete' matched three buttons in the demo, and the click failed:

Error: locator.click: Error: strict mode violation: getByRole('button', { name: 'Delete' }) resolved to 3 elements:

That strictness is useful, because the error lists all three matches. In Cypress 16.2, cy.getByRole('button', { name: 'Add' }) doesn't match "Add task" at all. A string name has to match the whole accessible name.

cy.getByRole skips role attributes by default. For a role that has a native HTML element, Cypress matches only the native element, unless you pass { native: false } (cy.getByRole). The demo's toast is a <div role="status">, and Cypress's message says so:

AssertionError: Timed out retrying after 4000ms: Expected to find an accessible native element with the role "status", but never did.

Playwright's getByRole('status') finds that div without an option. If your Cypress suite uses cy.getByRole, a port may suddenly match more elements than it used to.

cy.origin has no counterpart. Playwright drives the browser from outside, so a second origin is just another URL. A new tab is just another Page. The Cypress vs Playwright post runs that case in both tools.

The same test in Cypress and Playwright

Here's one spec ported both ways. It adds a task in the UI, then checks a task created through the API.

// cypress/e2e/tasks.cy.ts
describe('tasks', () => {
  beforeEach(() => {
    cy.login();
  });

  it('adds a task and marks it done', () => {
    const title = `Buy milk ${crypto.randomUUID().slice(0, 8)}`;
    cy.intercept('POST', '/api/tasks').as('createTask');
    cy.visit('/tasks');

    cy.getByRole('textbox', { name: 'New task' }).type(title);
    cy.getByRole('button', { name: 'Add task' }).click();
    cy.wait('@createTask').its('response.statusCode').should('eq', 201);

    cy.contains('li', title).within(() => {
      cy.getByRole('checkbox').check();
    });
    cy.contains('li', title).should('have.class', 'done');
  });

  it('shows a task created through the API', () => {
    cy.createTask(`Seeded ${crypto.randomUUID().slice(0, 8)}`).then((task) => {
      cy.visit('/tasks');
      cy.contains('li', task.title).should('be.visible');
      cy.request('DELETE', `/api/tasks/${task.id}`);
    });
  });
});
// tests/tasks.spec.ts
import { test, expect } from './fixtures';

test('adds a task and marks it done', async ({ page }) => {
  const title = `Buy milk ${crypto.randomUUID().slice(0, 8)}`;
  await page.goto('/tasks');

  await page.getByLabel('New task').fill(title);
  const createTask = page.waitForResponse(
    (res) => res.url().endsWith('/api/tasks') && res.request().method() === 'POST',
  );
  await page.getByRole('button', { name: 'Add task' }).click();
  expect((await createTask).status()).toBe(201);

  const row = page.getByRole('listitem').filter({ hasText: title });
  await row.getByRole('checkbox').check();
  await expect(row).toContainClass('done');
});

test('shows a task created through the API', async ({ page, createTask }) => {
  const task = await createTask(`Seeded ${crypto.randomUUID().slice(0, 8)}`);
  await page.goto('/tasks');
  await expect(page.getByRole('listitem').filter({ hasText: task.title })).toBeVisible();
});

The login is gone from the Playwright file because the config handles it (see the cy.session section below). The cleanup is gone too, because the createTask fixture deletes what it made. Both titles get a random suffix. That matters more in Playwright, which may run these tests at the same time as others.

From chains to async and await

Cypress commands don't run when you call them. They go into a queue that Cypress runs later, which is why const el = cy.get('#x') never gives you an element. Playwright is ordinary async code. Each line runs when you await it, and a value is just a value.

That makes some patterns simpler:

  • Aliases and this.* become constants. cy.wrap(42).as('total') followed by this.total is just const total = 42.
  • Nested .then() callbacks flatten. const text = await locator.textContent() reads a value. You can then use it in the next line.
  • Branching is plain if. It's still a smell in an end-to-end test, but you no longer need a workaround to write one.

It also adds two new ways to fail.

A missing await. In this demo, a test without await on fill and expect ended before either finished. Playwright 1.61 reported the dangling assertion:

Error: expect(locator).toBeEnabled() failed

Locator:  getByRole('button', { name: 'Add task' })
Expected: enabled
Received: undefined

A missing await on the last action failed too, with locator.fill: Test ended. In the middle of a test, though, nothing fails straight away. The next line starts before the action has finished, so the result depends on timing. Playwright's best practices recommend the @typescript-eslint/no-floating-promises lint rule. Turn it on before the first port, not after the first mystery.

Reading once instead of asserting. Cypress retries a query together with its .should(). A literal port such as expect(await locator.textContent()).toBe('1 open') reads the text once. The demo API takes 300 ms to save a task, so the read came too early:

Error: expect(received).toBe(expected) // Object.is equality

Expected: "1 open"
Received: "0 open"

await expect(page.getByTestId('open-count')).toHaveText('1 open') passed, because web-first assertions retry for up to 5 seconds by default (timeouts). The rule for ports: if the Cypress line ended in .should(), the Playwright line starts with await expect(locator). Playwright auto-waiting explains what is and isn't retried.

Custom commands become fixtures

A Cypress custom command extends the global cy object, and you declare its type on the Cypress.Chainable interface:

// cypress/support/commands.ts
// Custom commands: the Cypress way to share setup. In Playwright these become fixtures (tests/fixtures.ts).
export type TaskItem = { id: number; title: string; done: boolean };

declare global {
  // eslint-disable-next-line @typescript-eslint/no-namespace
  namespace Cypress {
    interface Chainable {
      login(): Chainable<void>;
      createTask(title: string): Chainable<TaskItem>;
    }
  }
}

Cypress.Commands.add('login', () => {
  cy.session(
    'qa-user',
    () => {
      cy.env(['TASKS_PASSWORD']).then(({ TASKS_PASSWORD }) => {
        cy.request({
          method: 'POST',
          url: '/api/login',
          body: { email: 'qa@example.test', password: TASKS_PASSWORD ?? 'demo-password' },
          log: false,
        });
      });
    },
    {
      validate() {
        cy.request('/api/me').its('status').should('eq', 200);
      },
    },
  );
});

Cypress.Commands.add('createTask', (title: string) => {
  return cy.request<TaskItem>('POST', '/api/tasks', { title }).its('body');
});

In Playwright, shared setup lives in fixtures. A test asks for a fixture by name, and the code after use() runs as teardown, even when the test fails:

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

export type TaskItem = { id: number; title: string; done: boolean };

type Fixtures = {
  /** Replaces cy.createTask(): creates a task through the API and deletes it after the test. */
  createTask: (title: string) => Promise<TaskItem>;
  /** Cypress fails a test on an uncaught page error; Playwright doesn't, so this auto fixture does. */
  failOnPageError: void;
};

export const test = base.extend<Fixtures>({
  createTask: async ({ page }, use) => {
    const created: TaskItem[] = [];
    await use(async (title) => {
      // page.request shares the signed-in cookies of this test's browser context.
      const response = await page.request.post('/api/tasks', { data: { title } });
      expect(response.status()).toBe(201);
      const task = (await response.json()) as TaskItem;
      created.push(task);
      return task;
    });
    for (const task of created) await page.request.delete(`/api/tasks/${task.id}`);
  },

  failOnPageError: [
    async ({ page }, use) => {
      const errors: Error[] = [];
      page.on('pageerror', (error) => errors.push(error));
      await use();
      expect(errors.map((e) => e.message), 'uncaught errors in the page').toEqual([]);
    },
    { auto: true },
  ],
});

export { expect };

Port each command to the smallest thing that does the job:

  • A command that sets up state (createTask, seedUser) becomes a fixture. You get cleanup in one place, and tests that don't ask for it don't pay for it.
  • A command that wraps a few UI steps (cy.fillAddress()) becomes a plain function or a page-object method. Playwright page object model shows how to hand page objects out as fixtures.
  • cy.login() becomes a setup project. That's the next section but one.
  • Overwritten commands (Cypress.Commands.overwrite) map best to an overridden fixture. test.extend() can override a built-in fixture such as page, for example to set something up before every test hands the page over (overriding fixtures). For a single command, a helper or a config default is often enough.

The second fixture, failOnPageError, exists because of a default that flips. Cypress fails a test when your app throws an uncaught error. In our run, it printed:

TypeError: The following error originated from your application code, not from Cypress.

  > Cannot read properties of undefined (reading 'split')

Playwright doesn't do that. The same visit to the broken page passed. With the auto fixture, it failed with "uncaught errors in the page" and the same message. If your Cypress suite never set uncaught:exception to return false, you relied on that check, so keep it.

cy.intercept to page.route

Both tools stub, spy on and change network traffic. The shapes differ.

Job Cypress Playwright
Stub with a file cy.intercept('GET', '/api/tasks*', { fixture: 'tasks.json' }) route.fulfill({ path: 'cypress/fixtures/tasks.json' })
Fake an error { statusCode: 500, body: {…} } route.fulfill({ status: 500, json: {…} })
Wait for a call .as('createTask') + cy.wait('@createTask') page.waitForRequest(…) or page.waitForResponse(…), started before the click
Read the request body interception.request.body request.postDataJSON()
Change a real response req.continue((res) => { res.body = … }) route.fetch(), then route.fulfill({ response, json })

route.fulfill({ path }) serves a file and infers the content type from its extension. So during a migration, Playwright can reuse your existing cypress/fixtures/*.json files.

// cypress/e2e/network.cy.ts
describe('network', () => {
  beforeEach(() => {
    cy.login();
  });

  it('stubs the task list', () => {
    cy.intercept('GET', '/api/tasks*', { fixture: 'tasks.json' });
    cy.visit('/tasks');
    cy.get('#task-list li').should('have.length', 3);
  });

  it('waits for the create request and checks its body', () => {
    cy.intercept('POST', '/api/tasks').as('createTask');
    cy.visit('/tasks');
    cy.getByRole('textbox', { name: 'New task' }).type('Water the plants');
    cy.getByRole('button', { name: 'Add task' }).click();
    cy.wait('@createTask').then(({ request, response }) => {
      expect(request.body).to.deep.equal({ title: 'Water the plants' });
      expect(response?.statusCode).to.eq(201);
      cy.request('DELETE', `/api/tasks/${response?.body.id}`);
    });
  });

  it('shows an error when the list fails to load', () => {
    cy.intercept('GET', '/api/tasks*', { statusCode: 500, body: { error: 'boom' } });
    cy.visit('/tasks');
    cy.getByRole('alert').should('have.text', 'Could not load tasks');
  });

  it('changes a real response', () => {
    cy.intercept('GET', '/api/tasks*', (req) => {
      req.continue((res) => {
        res.body = [...res.body, { id: 999, title: 'Injected by the test', done: false }];
      });
    });
    cy.visit('/tasks');
    cy.contains('li', 'Injected by the test').should('be.visible');
  });
});
// tests/network.spec.ts
import { test, expect } from './fixtures';

test('stubs the task list', async ({ page }) => {
  // The glob must match the whole URL, query string included.
  await page.route('**/api/tasks?*', (route) => route.fulfill({ path: 'cypress/fixtures/tasks.json' }));
  await page.goto('/tasks');
  await expect(page.locator('#task-list li')).toHaveCount(3);
});

test('waits for the create request and checks its body', async ({ page }) => {
  await page.goto('/tasks');
  await page.getByLabel('New task').fill('Water the plants');
  const createTask = page.waitForRequest(
    (req) => req.url().endsWith('/api/tasks') && req.method() === 'POST',
  );
  await page.getByRole('button', { name: 'Add task' }).click();

  const request = await createTask;
  expect(request.postDataJSON()).toEqual({ title: 'Water the plants' });
  const response = await request.response();
  expect(response?.status()).toBe(201);
  await page.request.delete(`/api/tasks/${(await response?.json()).id}`);
});

test('shows an error when the list fails to load', async ({ page }) => {
  await page.route('**/api/tasks?*', (route) => route.fulfill({ status: 500, json: { error: 'boom' } }));
  await page.goto('/tasks');
  await expect(page.getByRole('alert')).toHaveText('Could not load tasks');
});

test('changes a real response', async ({ page }) => {
  await page.route('**/api/tasks?*', async (route) => {
    const response = await route.fetch();
    const tasks = await response.json();
    await route.fulfill({
      response,
      json: [...tasks, { id: 999, title: 'Injected by the test', done: false }],
    });
  });
  await page.goto('/tasks');
  await expect(page.getByRole('listitem').filter({ hasText: 'Injected by the test' })).toBeVisible();
});

The query-string trap catches both tools. The app calls /api/tasks?status=all. Cypress's docs say a path such as cy.intercept('/api/users') never matches /api/users?page=1 (cy.intercept). In our run, cy.intercept('GET', '/api/tasks', …) let the real request through, and the test timed out with "Expected to find content: 'Write the release notes' but never did." Playwright's globs must match the entire URL (network docs), so '**/api/tasks' missed in the same way.

When an intercept "does nothing" after a port, check the pattern against the full URL with its query string. '**/api/tasks?*' works. In Playwright's globs, ? matches only a literal question mark. A path such as '/api/tasks?status=all' also worked, because a path is resolved against baseURL. A regular expression is the safe choice for anything complex. Playwright network mocking covers HAR replay and when not to mock.

cy.session to storageState

cy.session() caches a login in memory and restores it before each test. Playwright saves the signed-in state to a file once, and every test starts from that file (authentication docs):

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

// Replaces cy.login() + cy.session(): sign in once, save cookies and localStorage, reuse them in every test.
setup('sign in', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('qa@example.test');
  await page.getByLabel('Password').fill(process.env.TASKS_PASSWORD ?? 'demo-password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL('/tasks');
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: 'html', // writes playwright-report/; CI uploads it
  use: {
    baseURL: 'http://localhost:4300', // Cypress: baseUrl
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
  // Cypress expects the app to be running already; Playwright can start it.
  webServer: {
    command: 'node app/server.mjs',
    url: 'http://localhost:4300/login',
    reuseExistingServer: !process.env.CI,
  },
});

Add playwright/.auth to .gitignore. The Playwright docs warn that the file can hold cookies that let someone impersonate your test user.

Four differences to plan for:

cy.session storageState
What it saves Cookies, localStorage and sessionStorage, for all domains (docs) Cookies and localStorage, plus IndexedDB if you pass indexedDB: true (API). Not sessionStorage.
How long it lives One spec file, or the whole run with cacheAcrossSpecs: true, in memory A file, written once per run by the setup project
Checking it still works validate() re-runs setup when it fails No built-in check. The setup runs before every run.
Several users One id per user One setup and one file per role

If your app keeps its token in sessionStorage, a port that only swaps in storageState logs you out. The auth docs show a workaround: save sessionStorage yourself and restore it with addInitScript. Roles, API logins and per-worker accounts are in Playwright authentication.

Both page.request and the built-in request fixture used the project's saved state in our run. Each returned 200 from /api/me. That makes them the natural replacement for cy.request calls that relied on the browser's cookies.

Defaults that flip silently

These ports compile, look right in review, and then fail, or worse, pass.

  • Dialogs. Cypress auto-accepts confirm(). Playwright dismisses dialogs unless you handle them (dialogs). The demo's "Clear done" button did nothing in the Playwright port. The list still had 1 item when 0 were expected. Register page.once('dialog', (d) => d.accept()) before the click.
  • Uncaught page errors. Cypress fails the test. Playwright ignores the error. Use the auto fixture above.
  • Parallel files. One cypress run works through spec files one after another. Playwright runs files in parallel by default (parallelism), and fullyParallel: true also runs tests in one file side by side. Tests that share a user and expect an exact count of rows will collide. Give each test its own data, as the random titles and the createTask fixture do. Playwright test data goes deeper.
  • Timeouts. Cypress queries wait up to 4 seconds (defaultCommandTimeout). Playwright assertions wait 5 seconds, and actions have no timeout of their own, so a click on a missing button waits until the 30-second test timeout. Set actionTimeout in the config if you want those failures sooner.
  • Typing. .type() fires key events for each character. fill() sets the value and fires input. A search box that reacts to keydown needs pressSequentially().
  • Retries. Cypress takes one number or can set them per mode (runMode, openMode). Playwright uses one retries number, and it reports a test that passed on retry as flaky. Read Cypress flaky tests before you carry retry settings across.

A plan to migrate Cypress to Playwright and keep CI green

Four migration phases, inventory, foundations, port by feature and remove Cypress, with the share of tests in CI moving from Cypress to Playwright

1. Take an inventory. Count what you'll port, and find the hard parts before you estimate:

# Which Cypress commands the suite uses, most used first
grep -rhoE "cy\.[a-zA-Z]+" cypress/e2e cypress/support | sort | uniq -c | sort -rn
# Custom commands you'll turn into fixtures or functions
grep -rn "Cypress.Commands.add" cypress/support
# Specs that touch the network, sessions, Node tasks or a second origin
grep -rlE "cy\.(origin|intercept|session|task)" cypress/e2e | sort

Also list your plugins from package.json. Each one needs a Playwright counterpart, or a reason to drop it.

2. Lay the foundations in one pull request. Install Playwright next to Cypress:

npm install --save-dev @playwright/test
npx playwright install --with-deps chromium

Then add playwright.config.ts, the setup project, a tests/fixtures.ts with your ported custom commands, and the lint rule. Keep the two tools apart. Put specs in tests/ and cypress/e2e/, and give Cypress its own cypress/tsconfig.json with "types": ["cypress", "node"]. Then remove cypress from the root tsconfig.json's types and add "exclude": ["cypress"] there. Cypress's global Chai expect then never meets Playwright's expect. Add a second CI job:

name: e2e
on:
  push:
    branches: [main]
  pull_request:

jobs:
  # Delete this job in the pull request that empties cypress/e2e.
  cypress:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - name: Run Cypress against the app
        run: |
          node app/server.mjs &
          until curl -sf http://localhost:4300/login > /dev/null; do sleep 0.5; done
          npx cypress run --browser chrome

  playwright:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test # webServer in playwright.config.ts starts the app
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: |
            playwright-report/
            test-results/
          retention-days: 14

The upload keeps the HTML report, plus retry traces from test-results/. It needs reporter: 'html' in the config. Without it, CI uses the dot reporter and writes no report. npm ci needs a committed package-lock.json. Make both jobs required checks. On GitLab, the same setup is two jobs in one stage. See Playwright in GitLab CI.

3. Port by feature, one pull request each. For each feature folder:

  1. Port its specs, using the command map.
  2. Prove the port isn't flaky: npx playwright test tests/tasks.spec.ts --repeat-each=10.
  3. Delete the Cypress specs in the same pull request.

That last step is the one that keeps the plan honest. A test lives in exactly one framework at any time, so nothing runs twice and nothing gets lost. Start with the specs that use your custom commands most, because they exercise your fixtures early.

4. Remove Cypress. When cypress/e2e is empty, delete the Cypress job, the cypress package, the folder and its tsconfig, and any CYPRESS_* secrets in CI. Then turn on sharding if the suite is big enough to need it.

AI assistants and codemods can draft ports quickly. Treat their output like a junior's first pull request. Check every await, every one-time read that should be an expect, every dialog, and every URL pattern. Those are the places where a mechanical translation goes wrong.

Switching frameworks in Blendra

If the Cypress tests you're moving were recorded in Blendra, you don't port them by hand. The test framework is a per-project setting. Every plan exports Playwright TypeScript. On Pro+ and Team, a project can export Cypress (Beta) or Playwright Python (Beta) instead, from the same recordings and checks. Switching back to Playwright TypeScript is always allowed.

A switch starts with Preview switch in the project's Export tab. The preview writes nothing. It lists the files that are rewritten, added or removed, and how the suite will run. Someone with owner access confirms the switch, and you download the new suite. The switch notice gives you the git rm commands that remove the old framework's files. Undo stays available for 10 minutes, or until someone downloads, exports or pulls the suite. Blendra's own cloud runs use Playwright TypeScript whatever you export. The Playwright export docs describe the generated repo.

Blendra doesn't convert hand-written Cypress specs. For those, follow the plan above.

FAQ

Is there a tool that converts Cypress to Playwright automatically?

Codemods and AI assistants can translate the syntax, and they save typing. They can't know that your app relies on an auto-accepted confirm(), an uncaught-error check, or tests running one at a time. Review each port against the "defaults that flip" list, and prove it with --repeat-each before you delete the Cypress spec.

Can Cypress and Playwright run in the same repo?

Yes. The demo repo for this post runs both. Keep separate test folders, a separate cypress/tsconfig.json, and separate CI jobs. Playwright's default test match (*.spec.ts, *.test.ts) doesn't pick up *.cy.ts files, and Cypress only runs its specPattern.

Can I reuse my cy.intercept fixture files?

Yes. route.fulfill({ path: 'cypress/fixtures/tasks.json' }) serves the file as it is, with the content type taken from the extension. Move the files to a neutral folder once Cypress is gone.

How long does a migration take?

It depends on how many custom commands, plugins and intercepts you have, more than on the number of specs. Port your two hardest specs first and time them. That number is a better estimate than any rule of thumb.

Should I migrate Cypress component tests too?

Not as part of this project. Cypress's component testing is mature, and Playwright's new component-testing model is young. Migrate the end-to-end suite first. Then decide about components on their own merits.

CypressPlaywrightMigration

Start with the flow you test by hand every release.

Start free →