Migration Guide

Migrating from Cypress to Playwright.

A working engineer's technical manual — patterns, tradeoffs, and the command reference you'll actually use.

Verified May 2026 · Playwright 1.60.0 · Cypress 15.15.0

Still deciding whether to migrate? See the full tool comparison →

// Introduction

This is the technical manual for engineers who have already decided — or are seriously leaning toward — migrating from Cypress to Playwright. It covers project setup, test translation patterns, CI integration, authentication, and an honest account of what gets harder. If you're still weighing the decision, read the e2e tools comparison page first, then come back.

Scope

This guide assumes you already know Cypress. It skips Playwright fundamentals — if you've never seen `await page.locator()` before, read the Playwright docs first, then come back. It also skips the decision of whether to migrate; for that, see the e2e tools comparison page.

// Should you migrate?

Migration has real costs. A 500-test Cypress suite typically takes 2–4 engineer-weeks to migrate properly — more if your team is new to async/await patterns or if you have heavy Cucumber/BDD investment. Staying on Cypress is a legitimate outcome. Only migrate when the specific pain points below are actively blocking your team.

When migration makes sense

Migrate when Cypress's constraints are actively blocking your team, not when migration sounds interesting. The constraints that cross the line:

  • Language diversity: your team writes backend tests in Python, Java, or C#, and maintaining a separate JS-only Cypress toolchain creates friction. Playwright supports JS, TS, Python, Java, and C#.
  • Parallel execution cost: you're paying for Cypress Cloud to get parallelism. Playwright's built-in sharding (--shard=1/4) is free and doesn't require a cloud account.
  • Cross-origin and multi-tab: you're testing OAuth flows or multi-tab behaviour. Cypress requires cy.origin() (added in 9.6, still awkward) and doesn't support multiple browser contexts natively. Playwright handles this without configuration.
  • Modern browser APIs: your app uses service workers, WebSockets, CDP, or browser permissions. Playwright exposes these natively; Cypress routes everything through a proxied JS context.
  • Mobile web emulation: you need realistic mobile device emulation including touch events. Playwright's device emulation is based on real Chromium device profiles. Cypress's viewport resizing doesn't emulate touch.

When you shouldn't migrate

Staying on Cypress is a legitimate outcome. Migrate only if the pain you're solving outweighs the migration cost.

  • Your suite works. If your Cypress tests are fast, reliable, and maintained, the migration is pure overhead. A working test suite is an asset — don't discard it without a clear reason.
  • Your team's primary signal is interactive debugging. Cypress's time-travel debugger — step through every command with full DOM snapshots — has no equivalent in Playwright. If your team debugs tests live in the browser daily, you will miss this.
  • Small test estate. For fewer than 50 tests, migration ROI is low. You'd spend more time migrating than you'd recover in any Playwright-specific efficiency gains.
  • Migration would block feature work. A 500-test suite, on a typical team, takes 2–4 engineer-weeks to migrate properly. Add 50% for unknowns — flaky tests that only surface under Playwright, CI config rework, team upskilling. If your team can't afford that window, wait.
  • Heavy Cucumber/BDD investment. cypress-cucumber-preprocessor is production-stable. Playwright has no first-party BDD support; community packages (playwright-bdd, etc.) exist but are less mature. If BDD is a hard requirement, you're trading a solved problem for an unsolved one.

// Migration strategy

Before writing a single line of Playwright code, decide your approach. The three questions that matter: big bang or incremental, how long to run both suites in CI, and what to migrate first.

Big bang vs incremental

Big bang: delete all Cypress tests, write all Playwright equivalents in one sprint. Incremental: migrate test-by-test or file-by-file while both suites run in CI. For suites under 50 tests, big bang is defensible. For anything larger, incremental is safer — you catch Playwright-specific failures on individual test groups rather than on the whole suite at once.

Running both side-by-side

During migration, run Cypress and Playwright in separate CI jobs. Both must pass. This means regressions in either direction surface immediately — a Playwright test that can't reproduce Cypress behavior is a signal to investigate, not a reason to skip. Retire the Cypress job when your team is confident the Playwright suite is equivalent: at least one full sprint with zero Cypress-only failures.

Cypress

{
  "scripts": {
    "test:cy": "cypress run",
    "test:cy:open": "cypress open",
    "test:pw": "playwright test",
    "test:pw:ui": "playwright test --ui",
    "test": "npm run test:cy && npm run test:pw"
  }
}

Playwright

# .github/workflows/test.yml (hybrid CI during migration)
jobs:
  cypress:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm run test:cy
  playwright:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run test:pw
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Note: Run both jobs in parallel. Fail the build if either fails. Remove the Cypress job only after a full sprint with zero Cypress-only failures.

Picking what to migrate first

Three defensible approaches: (1) Migrate critical-path tests first — your login, checkout, or core user flows. These have the most value and the most scrutiny. (2) Migrate the easiest tests first — build team confidence and discover Playwright configuration issues with low-stakes tests before tackling complex ones. (3) Delete low-value tests rather than migrate them — if a test has failed intermittently for months without anyone investigating, migration is not the time to fix it. Delete it and write a new Playwright test when the behaviour matters.

// Project setup

Getting Playwright installed alongside an existing Cypress project without breaking CI. The goal: both frameworks coexist in package.json, both run in CI, and neither interferes with the other.

Installing Playwright alongside Cypress

Run `npm init playwright@latest` in your existing project. It generates playwright.config.ts, a sample test, and a GitHub Actions workflow. Accept the defaults then edit the config. Install browser binaries separately: `npx playwright install --with-deps`.

Cypress

{
  "scripts": {
    "cypress:open": "cypress open",
    "cypress:run": "cypress run",
    "cypress:run:headless": "cypress run --headless --browser chrome"
  },
  "devDependencies": {
    "cypress": "^15.15.0"
  }
}

Playwright

{
  "scripts": {
    "playwright:test": "playwright test",
    "playwright:ui": "playwright test --ui",
    "playwright:report": "playwright show-report",
    "playwright:install": "playwright install --with-deps"
  },
  "devDependencies": {
    "cypress": "^15.15.0",
    "@playwright/test": "^1.60.0"
  }
}

Note: Keep both devDependencies during migration. Remove cypress once the Playwright suite is fully green and the team is confident.

Config file translation

Most cypress.config.ts fields have a direct playwright.config.ts counterpart. The key translation is defaultCommandTimeout → timeout (Playwright's actionTimeout and expect timeout are separate: use actionTimeout for action waits, and expect({ timeout }) for assertion waits).

Cypress

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

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    defaultCommandTimeout: 8000,
    viewportWidth: 1280,
    viewportHeight: 720,
    retries: { runMode: 2, openMode: 0 },
    env: {
      API_URL: 'http://localhost:3001',
    },
    specPattern: 'cypress/e2e/**/*.cy.{js,ts}',
  },
})

Playwright

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

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  expect: { timeout: 5_000 },
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://localhost:3000',
    actionTimeout: 8_000,
    viewport: { width: 1280, height: 720 },
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
})

Note: Playwright separates test timeout (how long a full test runs), action timeout (how long a locator waits to be actionable), and expect timeout (how long an assertion polls). Cypress uses defaultCommandTimeout for everything.

Folder structure conventions

Cypress defaults to cypress/e2e/ for tests, cypress/fixtures/ for data, and cypress/support/ for commands. Playwright defaults to tests/ with no forced subdirectory structure. Both are configurable. Prefer keeping tests/ for Playwright tests during migration to avoid confusion with the still-active cypress/e2e/ folder.

TypeScript setup

Playwright defaults to TypeScript. If your Cypress suite is plain JS, you'll need a tsconfig.json. `npm init playwright@latest` generates one, but check that it doesn't conflict with your project's existing tsconfig. At minimum, you need `"moduleResolution": "node"` and `"target": "ES2020"` or later. Playwright's types are bundled in @playwright/test — no separate @types package required.

// Running tests — CLI and CI

Day-to-day commands and CI integration patterns. Playwright's CLI is different enough from Cypress's that the first week will feel unfamiliar — here's the mapping.

Local dev workflow

The closest equivalent to `npx cypress open` is `npx playwright test --ui`. Playwright's UI mode (introduced in 1.32) shows a live test list, a browser panel, and a DOM inspector. It's different from Cypress's interactive mode — Playwright UI mode does not show a command log with DOM snapshots at each step. For step-by-step debugging, use `npx playwright test --debug` to pause in a browser with Playwright Inspector, or open a trace file with `npx playwright show-trace trace.zip`.

CI execution — headless, parallel, sharding

Playwright runs headless by default in CI (Chromium, Firefox, WebKit). Use `--workers` to control parallelism within a machine. Use `--shard=N/M` to split the test suite across multiple CI nodes — Playwright assigns tests to shards based on file, not test count, so balance your test files roughly by duration. Cypress's equivalent requires Cypress Cloud. Playwright sharding is built in and free.

Cypress

# Cypress — requires Cypress Cloud for parallelism
- name: Run Cypress tests
  uses: cypress-io/github-action@v6
  with:
    start: npm start
    wait-on: 'http://localhost:3000'
    record: true        # requires CYPRESS_RECORD_KEY secret
    parallel: true      # requires Cypress Cloud paid plan
  env:
    CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

Playwright

# Playwright — sharding is built in, no account required
- name: Install Playwright browsers
  run: npx playwright install --with-deps

- name: Run Playwright tests (shard ${{ matrix.shard }})
  run: npx playwright test --shard=${{ matrix.shard }}/4

- name: Upload Playwright report
  uses: actions/upload-artifact@v4
  if: failure()
  with:
    name: playwright-report-${{ matrix.shard }}
    path: playwright-report/

strategy:
  matrix:
    shard: [1, 2, 3, 4]

Note: For Playwright sharding to work across machines, merge reports with `npx playwright merge-reports` after all shards complete.

Reporters

Playwright ships with html (default), list, dot, line, json, and junit reporters. Configure multiple reporters in playwright.config.ts under the `reporter` key. The html reporter generates a self-contained report in playwright-report/. In CI, the most useful combination is `['list', ['html', { open: 'never' }]]` — list prints results to stdout, html generates an artifact you can download. The junit reporter integrates with GitHub Actions test reporting and most CI dashboards.

// Writing tests — core patterns

Side-by-side Cypress to Playwright translations for the patterns you use every day. This is the largest section — work through it in order if you're new to Playwright, or jump to the specific pattern you're translating.

Test structure

Test structure is nearly identical. `describe` becomes `test.describe`, `it` becomes `test`, and `beforeEach`/`afterEach` map 1:1. The critical difference: you import `test` and `expect` from `@playwright/test` — there is no global injection. Every test file starts with this import.

Cypress

// cypress/e2e/login.cy.ts
describe('Login flow', () => {
  beforeEach(() => {
    cy.visit('/login')
  })

  it('logs in with valid credentials', () => {
    cy.get('[data-testid="email"]').type('user@example.com')
    cy.get('[data-testid="password"]').type('password123')
    cy.get('[data-testid="submit"]').click()
    cy.url().should('include', '/dashboard')
  })

  it('shows error for invalid credentials', () => {
    cy.get('[data-testid="email"]').type('wrong@example.com')
    cy.get('[data-testid="password"]').type('wrongpassword')
    cy.get('[data-testid="submit"]').click()
    cy.get('[data-testid="error-message"]').should('be.visible')
  })
})

Playwright

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

test.describe('Login flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login')
  })

  test('logs in with valid credentials', async ({ page }) => {
    await page.getByTestId('email').fill('user@example.com')
    await page.getByTestId('password').fill('password123')
    await page.getByTestId('submit').click()
    await expect(page).toHaveURL(/\/dashboard/)
  })

  test('shows error for invalid credentials', async ({ page }) => {
    await page.getByTestId('email').fill('wrong@example.com')
    await page.getByTestId('password').fill('wrongpassword')
    await page.getByTestId('submit').click()
    await expect(page.getByTestId('error-message')).toBeVisible()
  })
})

Note: Every Playwright test function receives a `{ page }` fixture argument — no globals. The `async/await` keywords are not optional: they are syntactically required for every Playwright API call that returns a Promise.

Element selection

Cypress's `cy.get()` immediately starts retrying against the current DOM. Playwright's `page.locator()` is lazy — it doesn't touch the DOM until you call an action or assertion on it. This means a Playwright locator returned from a function is safe to pass around and reuse; a Cypress cy.get() chain cannot be stored and reused. Playwright also provides semantic locators: `getByRole()`, `getByText()`, `getByTestId()`, `getByLabel()`. Prefer these over CSS selectors — they're more resilient to markup changes and more accessible.

Cypress

// CSS selector
cy.get('[data-testid="submit-btn"]').should('be.visible')

// Text content
cy.contains('Submit').click()

// Chained selection
cy.get('.form').find('[data-testid="email"]').type('user@example.com')

// nth element
cy.get('.item').eq(2).click()

Playwright

// Semantic locator (preferred)
await expect(page.getByTestId('submit-btn')).toBeVisible()

// Role-based (most resilient)
await page.getByRole('button', { name: 'Submit' }).click()

// Text content
await page.getByText('Submit').click()

// Chained selection
await page.locator('.form').getByTestId('email').fill('user@example.com')

// nth element
await page.locator('.item').nth(2).click()

Note: Playwright's getByRole() uses ARIA roles — it matches what assistive technologies see, not just what the DOM says. It's the preferred selector for buttons, links, inputs, and headings.

User actions

Most user actions translate directly: `cy.click()` → `locator.click()`, `cy.type()` → `locator.fill()`, `cy.select()` → `locator.selectOption()`. The main behavioural difference: `cy.type()` appends to existing content unless you call `cy.clear()` first. `locator.fill()` always replaces the field's current value. If you need to simulate key-by-key typing (for autocomplete, rich text, or input masks), use `locator.pressSequentially()` instead of `locator.fill()`.

Cypress

// Text input — cy.type() appends; cy.clear() first if needed
cy.get('[data-testid="search"]').clear().type('test query')

// Form submission
cy.get('[data-testid="email"]').type('user@example.com')
cy.get('[data-testid="password"]').type('password{enter}')

// Dropdown
cy.get('select[name="country"]').select('Canada')

// Checkbox
cy.get('[data-testid="agree"]').check()

// File upload
cy.get('input[type="file"]').attachFile('document.pdf')

Playwright

// Text input — fill() always replaces
await page.getByTestId('search').fill('test query')

// Form submission
await page.getByTestId('email').fill('user@example.com')
await page.getByTestId('password').fill('password')
await page.getByTestId('password').press('Enter')

// Dropdown
await page.locator('select[name="country"]').selectOption('Canada')

// Checkbox
await page.getByTestId('agree').check()

// File upload
await page.locator('input[type="file"]').setInputFiles('document.pdf')

Note: Playwright has no attachFile equivalent to the cypress-file-upload plugin — setInputFiles() is built into Playwright core. For drag-and-drop file upload (not via input), use page.dispatchEvent() with a DataTransfer object.

Assertions philosophy

This is the most common source of bugs when migrating from Cypress. In Cypress, assertions are chained and automatically retried: `.should('be.visible')` retries the entire preceding chain until the assertion passes or the timeout expires. In Playwright, assertions use `expect()` — but `expect()` must be awaited. A bare `expect(locator).toBeVisible()` without `await` returns a Promise that is silently discarded. The test will pass even if the assertion would have failed. This is the #1 silent bug pattern in Playwright migrations.

Cypress

// Cypress: chained assertions, implicit retry
cy.get('[data-testid="status"]').should('be.visible')
cy.get('[data-testid="count"]').should('have.text', '42')
cy.get('[data-testid="submit"]').should('be.disabled')

// Multiple assertions on the same element
cy.get('[data-testid="user-name"]')
  .should('be.visible')
  .and('contain.text', 'Alice')
  .and('not.have.class', 'loading')

Playwright

// Playwright: explicit await on every expect() call
await expect(page.getByTestId('status')).toBeVisible()
await expect(page.getByTestId('count')).toHaveText('42')
await expect(page.getByTestId('submit')).toBeDisabled()

// Multiple assertions on the same locator
const userName = page.getByTestId('user-name')
await expect(userName).toBeVisible()
await expect(userName).toContainText('Alice')
await expect(userName).not.toHaveClass('loading')

Note: Install eslint-plugin-playwright and enable the 'playwright/no-floating-promises' rule to catch missing awaits at lint time. This is not optional for production test suites.

Async/await — the conceptual shift

Cypress uses a command queue — when you call `cy.get()`, you're not getting a DOM element, you're enqueuing a command. The queue executes asynchronously after your test body runs synchronously. This is why you can't store `cy.get()` results in variables or use `if/else` on them directly. Playwright uses real Promises with async/await. `await page.locator()` doesn't return a locator — `page.locator()` returns a Locator synchronously (it's lazy). `await locator.click()` is what triggers the async operation. You can store locators in variables, pass them to functions, and use standard JS control flow.

Cypress

// Cypress: can't use return values directly; use .then() for sequential logic
cy.get('[data-testid="counter"]').invoke('text').then((text) => {
  const count = parseInt(text, 10)
  if (count > 5) {
    cy.get('[data-testid="reset"]').click()
  }
})

// Can't do this — cy.get() returns a Chainable, not a DOM element
// const el = cy.get('.foo')  // WRONG — el is a Chainable, not an element

Playwright

// Playwright: plain async/await, real values
const countText = await page.getByTestId('counter').innerText()
const count = parseInt(countText, 10)
if (count > 5) {
  await page.getByTestId('reset').click()
}

// Locators are lazy — safe to store and reuse
const counter = page.getByTestId('counter')  // no await, no DOM touch
await expect(counter).toBeVisible()           // now it touches the DOM
await counter.click()

Note: Storing locators in variables is safe and encouraged — it reduces repetition. Storing awaited results (like innerText()) is safe too. What you can't do is store a Locator and expect it to be 'live' after the DOM changes — locators always re-query on use.

Auto-waiting

Cypress retries the entire command chain for up to `defaultCommandTimeout` milliseconds (4000ms default). Every `.should()` in the chain triggers a full re-evaluation from the original `cy.get()`. Playwright auto-waits on locators before each action: before `.click()`, it checks that the element is visible, stable, enabled, and not obscured (actionability checks). Before each `expect()` assertion, it polls until the assertion passes or the timeout expires. The mechanisms differ, but both frameworks avoid explicit `cy.wait(ms)` / `page.waitForTimeout(ms)` calls in well-written tests.

Cypress

// Cypress auto-retries the full chain until .should() passes
// defaultCommandTimeout: 4000ms
cy.get('[data-testid="spinner"]').should('not.exist')
cy.get('[data-testid="result"]').should('be.visible').and('contain.text', 'Done')

// Explicit wait — anti-pattern, avoid
cy.wait(2000)
cy.get('[data-testid="result"]').should('be.visible')

Playwright

// Playwright auto-waits on each assertion independently
// expect timeout: 5000ms default (configurable in playwright.config.ts)
await expect(page.getByTestId('spinner')).not.toBeVisible()
await expect(page.getByTestId('result')).toBeVisible()
await expect(page.getByTestId('result')).toContainText('Done')

// Explicit wait — anti-pattern, avoid
await page.waitForTimeout(2000)
await expect(page.getByTestId('result')).toBeVisible()

// Preferred alternative: wait for a condition
await page.waitForSelector('[data-testid="result"]', { state: 'visible' })

Note: Playwright's default expect timeout is 5 seconds (configurable). Cypress's defaultCommandTimeout is 4 seconds. Playwright's action timeout defaults to 30 seconds — much longer. This longer action timeout can mask flakiness; consider setting actionTimeout to 8000ms in your config.

Custom commands → fixtures and test.extend

Cypress's `Cypress.Commands.add()` attaches methods to the global `cy` object. Playwright's `test.extend()` creates a new `test` function with additional fixtures. The mental model shift: in Cypress, custom commands are globally available in every test file. In Playwright, fixtures are explicitly imported — you use the extended `test` object instead of the base one. This is more explicit but also more type-safe: TypeScript knows exactly what fixtures your test expects.

Cypress

// 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('[data-testid="submit"]').click()
  cy.url().should('include', '/dashboard')
})

// cypress/support/e2e.ts
import './commands'  // auto-imported in every test

// Usage in any test file:
cy.login('user@example.com', 'password123')

Playwright

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

type Fixtures = { loggedIn: void }

export const test = base.extend<Fixtures>({
  loggedIn: async ({ page }, use) => {
    await page.goto('/login')
    await page.getByTestId('email').fill('user@example.com')
    await page.getByTestId('password').fill('password123')
    await page.getByTestId('submit').click()
    await expect(page).toHaveURL(/\/dashboard/)
    await use()  // test runs here
  },
})

export { expect }

// Usage — import from fixtures.ts, not @playwright/test:
import { test, expect } from './fixtures'

test('dashboard loads', async ({ page, loggedIn }) => {
  await expect(page.getByTestId('welcome')).toBeVisible()
})

Note: For auth-heavy test suites, prefer storageState over login fixtures — storageState reuses a saved browser session and is significantly faster than re-running the login flow each test.

Page objects

Page object model works in both frameworks with minimal changes. The key difference: Playwright page objects receive the `page` fixture via constructor injection. There's no global `cy` object to call — everything goes through `this.page`. Locators defined as class properties are evaluated lazily, so defining them in the constructor is efficient.

Cypress

// cypress/support/pages/LoginPage.ts
export class LoginPage {
  visit() {
    cy.visit('/login')
  }

  fillEmail(email: string) {
    cy.get('[data-testid="email"]').type(email)
  }

  fillPassword(password: string) {
    cy.get('[data-testid="password"]').type(password)
  }

  submit() {
    cy.get('[data-testid="submit"]').click()
  }
}

// Usage:
const loginPage = new LoginPage()
loginPage.visit()
loginPage.fillEmail('user@example.com')
loginPage.fillPassword('pass')
loginPage.submit()

Playwright

// tests/pages/LoginPage.ts
import { type Page, expect } from '@playwright/test'

export class LoginPage {
  readonly page: Page
  readonly emailInput = this.page.getByTestId('email')
  readonly passwordInput = this.page.getByTestId('password')
  readonly submitButton = this.page.getByTestId('submit')

  constructor(page: Page) {
    this.page = page
  }

  async goto() {
    await this.page.goto('/login')
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email)
    await this.passwordInput.fill(password)
    await this.submitButton.click()
  }
}

// Usage:
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'pass')

Note: Playwright locators defined as class properties (like `this.page.getByTestId('email')`) are lazy — they don't touch the DOM until called. They can be defined outside the constructor for conciseness. TypeScript readonly is a good habit here.

beforeEach / afterEach / hooks

Hook names map directly: `beforeEach` → `test.beforeEach`, `afterEach` → `test.afterEach`, `before` → `test.beforeAll`, `after` → `test.afterAll`. The scoping difference matters: in Playwright, hooks are scoped to the `test.describe` block they appear in. Global setup belongs in `playwright.config.ts` via the `globalSetup` option (a file path to a module that runs once before all tests). Cypress's `cypress/support/e2e.ts` file has no direct Playwright equivalent — use fixtures instead.

Cypress

// Global hooks via support/e2e.ts (runs before every test file)
beforeEach(() => {
  cy.clearCookies()
  cy.clearLocalStorage()
})

// Local hooks in a test file
describe('Dashboard', () => {
  before(() => {
    // runs once before this describe block
    cy.task('db:seed')
  })

  beforeEach(() => {
    cy.login('admin@example.com', 'admin')
  })
})

Playwright

// playwright.config.ts — global setup/teardown
export default defineConfig({
  globalSetup: './tests/global-setup.ts',
  globalTeardown: './tests/global-teardown.ts',
})

// tests/global-setup.ts — runs once before all tests
export default async function globalSetup() {
  // seed the database, start external services, etc.
}

// Local hooks in a test file
import { test, expect } from '@playwright/test'

test.describe('Dashboard', () => {
  test.beforeAll(async ({ browser }) => {
    // runs once before tests in this describe block
  })

  test.beforeEach(async ({ page }) => {
    // runs before each test — receives fixtures as parameters
    await page.goto('/login')
  })
})

Note: test.beforeAll and test.afterAll in Playwright receive worker-scoped fixtures, not test-scoped ones. Use them for setup that's expensive per worker (like database seeding), not for per-test state reset.

// Network & data

Intercepting, mocking, and reading network calls; API testing; fixture loading. Playwright's network API is more composable than Cypress's but requires a slightly different mental model around async handlers.

Intercepting network calls

cy.intercept() and page.route() both let you mock or modify network requests, but the API shape differs. In Cypress, you set up intercepts before visiting the page and optionally alias them for later waiting. In Playwright, page.route() handlers are async functions — you await the routing setup, and the handler itself can be async.

Cypress

// Mock a GET response
cy.intercept('GET', '/api/users', {
  statusCode: 200,
  body: [{ id: 1, name: 'Alice' }],
}).as('getUsers')

cy.visit('/users')
cy.wait('@getUsers')
cy.get('[data-testid="user-name"]').should('contain', 'Alice')

// Modify a real response
cy.intercept('GET', '/api/config', (req) => {
  req.reply((res) => {
    res.body.featureFlag = true
  })
})

Playwright

// Mock a GET response
await page.route('/api/users', async (route) => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([{ id: 1, name: 'Alice' }]),
  })
})

await page.goto('/users')
await expect(page.getByTestId('user-name')).toContainText('Alice')

// Wait for a real network request
const responsePromise = page.waitForResponse('/api/users')
await page.goto('/users')
const response = await responsePromise
expect(response.status()).toBe(200)

// Modify a real response
await page.route('/api/config', async (route) => {
  const response = await route.fetch()
  const json = await response.json()
  json.featureFlag = true
  await route.fulfill({ json })
})

Note: page.route() has no alias system. To wait for a response, use page.waitForResponse() before triggering the navigation or action. Unhandled routes pass through to the network by default.

API testing

Playwright's APIRequestContext replaces cy.request(). The main difference: Playwright's API context is separate from the browser's cookies and storage unless you pass `storageState` when creating it. For API calls that need authentication, either use the browser's request context (`page.request`) which shares the browser's session, or create a standalone context with the appropriate auth headers.

Cypress

// cy.request shares the browser's cookies automatically
cy.request({
  method: 'POST',
  url: '/api/items',
  body: { name: 'New Item', quantity: 5 },
  headers: { 'Content-Type': 'application/json' },
}).then((response) => {
  expect(response.status).to.eq(201)
  expect(response.body.name).to.eq('New Item')
})

// Commonly used to log in via API (bypassing UI)
cy.request('POST', '/api/auth/login', { email, password })
  .then(({ body }) => {
    window.localStorage.setItem('token', body.token)
  })

Playwright

// page.request shares the browser's cookies
const response = await page.request.post('/api/items', {
  data: { name: 'New Item', quantity: 5 },
})
expect(response.status()).toBe(201)
const body = await response.json()
expect(body.name).toBe('New Item')

// Standalone API context (separate from browser session)
import { request } from '@playwright/test'

const apiContext = await request.newContext({
  baseURL: 'http://localhost:3001',
  extraHTTPHeaders: {
    Authorization: `Bearer ${process.env.API_TOKEN}`,
  },
})
const response = await apiContext.get('/api/items')
await apiContext.dispose()

Note: page.request is the direct cy.request() equivalent — it shares the browser session. Use the standalone request fixture for API-only tests that don't need browser cookies.

Fixtures — loading test data

Cypress provides a first-class cy.fixture() command that reads from cypress/fixtures/ and handles JSON parsing automatically. Playwright has no equivalent command — it doesn't need one. Import JSON files directly (TypeScript's JSON imports work out of the box with `resolveJsonModule: true`), or read files with Node's `fs.readFile`. For fixtures shared across many tests, exporting them from a TypeScript module is cleaner and gives you type safety.

Cypress

// cypress/fixtures/users.json must exist in fixtures/ dir
cy.fixture('users.json').then((users) => {
  cy.intercept('GET', '/api/users', users).as('getUsers')
  cy.visit('/users')
  cy.wait('@getUsers')
})

// Can also use the fixture shorthand
cy.intercept('GET', '/api/users', { fixture: 'users.json' })

Playwright

// Direct import — no special command needed
import usersFixture from './fixtures/users.json'

test('users list', async ({ page }) => {
  await page.route('/api/users', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(usersFixture),
    })
  })
  await page.goto('/users')
  await expect(page.getByTestId('user-row')).toHaveCount(usersFixture.length)
})

// Or read dynamically with fs
import { readFileSync } from 'fs'
const users = JSON.parse(readFileSync('./fixtures/users.json', 'utf-8'))

Note: JSON imports require `"resolveJsonModule": true` in tsconfig.json. This is already enabled if you used `npm init playwright@latest`.

Database / state setup

Playwright doesn't provide a cy.task() equivalent for running Node code in tests. Use Playwright's `globalSetup` for one-time database seeding, or call your seeding functions directly in `test.beforeAll` hooks using Node APIs. For test isolation, prefer resetting state via API calls (faster than reseeding) or using test-scoped database transactions if your backend supports it.

// Authentication & sessions

Playwright's storageState is one of the biggest ergonomic wins for teams doing auth-heavy testing — log in once per suite run, not once per test or once per test file. Here's how to translate your Cypress session patterns.

cy.session → storageState

cy.session() caches cookies and localStorage in Cypress between tests — if the setup function already ran with the same session ID, Cypress restores the cached state instead of re-running the function. Playwright's equivalent is storageState: you run a login script once, save the browser state (cookies + localStorage) to a JSON file, and load it into tests via playwright.config.ts or per-test. The key difference: cy.session() is test-scoped (each test file can have its own session). storageState is typically set up globally and shared across all tests in a project.

Cypress

// cypress/support/commands.ts
Cypress.Commands.add('loginViaSession', (email: string, password: string) => {
  cy.session([email, password], () => {
    cy.visit('/login')
    cy.get('[data-testid="email"]').type(email)
    cy.get('[data-testid="password"]').type(password)
    cy.get('[data-testid="submit"]').click()
    cy.url().should('include', '/dashboard')
  })
})

// Usage — session is restored if already created
cy.loginViaSession('admin@example.com', 'admin')
cy.visit('/dashboard')

Playwright

// tests/auth.setup.ts — run once before tests, saves auth state
import { test as setup, expect } from '@playwright/test'
import path from 'path'

const authFile = path.join(__dirname, '.auth/user.json')

setup('authenticate', async ({ page }) => {
  await page.goto('/login')
  await page.getByTestId('email').fill('admin@example.com')
  await page.getByTestId('password').fill('admin')
  await page.getByTestId('submit').click()
  await expect(page).toHaveURL(/\/dashboard/)
  await page.context().storageState({ path: authFile })
})

// playwright.config.ts — reference the saved auth file
projects: [
  {
    name: 'setup',
    testMatch: /auth\.setup\.ts/,
  },
  {
    name: 'authenticated tests',
    dependencies: ['setup'],
    use: { storageState: '.auth/user.json' },
  },
]

Note: Add `.auth/` to .gitignore — the file contains session tokens. The setup project runs once per test run (not per test), making this much faster than cy.session() for large suites.

OAuth flows

This is the most common migration trigger for teams with OAuth-protected apps. Cypress added cy.origin() in 9.6 to handle third-party identity providers (Google, Okta, Auth0), but it's still fragile: it requires you to know exactly which domains will be visited, and some IdPs block automation user agents. Playwright has no same-origin restriction — it can navigate to any domain, fill forms, and handle redirects natively. The test code is simpler because there's no workaround required.

Cypress

// Cypress — requires cy.origin() for cross-origin navigation
cy.visit('/login')
cy.get('[data-testid="login-with-google"]').click()

// Must explicitly declare the cross-origin domain
cy.origin('https://accounts.google.com', () => {
  cy.get('input[type="email"]').type(Cypress.env('GOOGLE_EMAIL'))
  cy.get('#identifierNext').click()
  cy.get('input[type="password"]').type(Cypress.env('GOOGLE_PASSWORD'))
  cy.get('#passwordNext').click()
})

cy.url().should('include', '/dashboard')

Playwright

// Playwright — no special handling needed for cross-origin
test('OAuth login via Google', async ({ page }) => {
  await page.goto('/login')
  await page.getByTestId('login-with-google').click()

  // Playwright follows the redirect to accounts.google.com naturally
  await page.getByLabel('Email or phone').fill(process.env.GOOGLE_EMAIL!)
  await page.getByRole('button', { name: 'Next' }).click()
  await page.getByLabel('Enter your password').fill(process.env.GOOGLE_PASSWORD!)
  await page.getByRole('button', { name: 'Next' }).click()

  await expect(page).toHaveURL(/\/dashboard/)
})

Note: For CI, avoid testing against real OAuth providers — their UI changes frequently and bot detection can block automation. Use storageState to save a real login session locally, then load it in CI tests instead.

// Things that get harder

An honest look at what Playwright does worse than Cypress. Migration guides tend to undersell this. Read this section before committing to the migration.

Time-travel debugging

Cypress's interactive mode shows a DOM snapshot at every command step — you can click backward and forward through your test's execution, inspect the DOM as it was at each point, and see the full Cypress command log. This is genuinely unmatched. Playwright's trace viewer (opened with `npx playwright show-trace trace.zip`) shows screenshots, network logs, and source code at each action — it's excellent for diagnosing CI failures, but it's file-based and post-mortem, not live. When debugging locally, `--debug` mode pauses at each step with Playwright Inspector, but it's closer to a step-through debugger than Cypress's visual replay. If your team's primary workflow is opening the test runner and clicking through failures interactively, expect a real productivity hit in the first month after migration.

cucumber / BDD plugins

cypress-cucumber-preprocessor is a mature, widely-adopted package that integrates Gherkin/BDD syntax directly into Cypress. It's actively maintained and supports TypeScript, tagged hooks, and step parameter types. Playwright has no first-party BDD support. The community package playwright-bdd exists and is functional, but it's a one-person project with no corporate backing, and its step definition model differs from the Cypress preprocessor in ways that require rewriting steps, not just translating them. If BDD is a team or stakeholder requirement, not just an engineering choice, budget significant extra time for this migration path — or consider keeping Cypress.

Component testing

Cypress component testing (introduced in 2022, stable since 2023) supports React, Vue, Angular, and Svelte. It mounts components in a real browser, supports cy.intercept() for mocking, and integrates with the time-travel debugger. Playwright's component testing is a separate package (@playwright/experimental-ct-react, etc.) that is still labelled experimental in 1.60. It works in practice for React and Vue, but 'experimental' means the API can change between minor versions without a deprecation cycle. If component testing is a core part of your strategy — not just a few isolated tests — weigh this carefully. You might migrate your E2E suite to Playwright while keeping Cypress for component tests.

Plugin ecosystem

Cypress has roughly 800 npm packages tagged with 'cypress-plugin', covering accessibility testing (cypress-axe), visual regression (cypress-image-snapshot, Percy), custom reporters, and dozens of domain-specific helpers. Playwright's package ecosystem is younger and skews toward Microsoft-curated integrations. For accessibility testing, @axe-core/playwright is maintained by Deque and is a solid equivalent to cypress-axe. For visual regression, you'll find options (playwright-visual-regression, Argos, Percy's Playwright SDK) but they vary in maturity. For custom reporters and CI integrations, Playwright's built-in reporter API is well-documented and covers most cases. The gap is real, but it's narrowing.

// Common migration pitfalls

The bugs and surprises that appear most often in the first weeks of a Playwright migration. Most of them are silent — your tests pass, but they're not testing what you think they are.

  • Missing await on expect(). `expect(locator).toBeVisible()` without await creates a floating Promise — the assertion is silently skipped and the test passes even if the assertion would fail. Add eslint-plugin-playwright with 'no-floating-promises' enabled to catch this at lint time.
  • Confusing Locator and ElementHandle. `await page.locator('.foo')` is wrong — locator() is synchronous and returns a Locator object. `await page.locator('.foo').elementHandle()` gives you an ElementHandle, which is eager and can go stale. Prefer Locators over ElementHandles for almost everything.
  • Assuming test isolation means state isolation. Playwright creates a fresh browser context per test by default — cookies, localStorage, and sessionStorage are cleared. But your backend database is not reset between tests. If test A creates data that test B depends on (or conflicts with), you'll see order-dependent failures that are hard to diagnose.
  • Hover-dependent flows. Playwright's `locator.hover()` moves the mouse to the element's center point. Some CSS hover effects or custom tooltips require the element to be stable for a moment before the hover state activates. If your Cypress tests used cy.trigger('mouseover'), the Playwright equivalent is page.dispatchEvent(), not locator.hover().
  • 30-second default action timeout masking flakiness. Playwright waits up to 30 seconds for actions by default (vs Cypress's 4-second defaultCommandTimeout). A test that passes consistently in Playwright might be waiting 15 seconds for something that should happen in 1 second. Set actionTimeout to 8000ms in your config as a starting point, then adjust per test only when justified.
  • cy.wait('@alias') patterns without Playwright equivalents. cy.wait('@interceptAlias') waits for a specific intercepted request by alias. In Playwright, there's no alias system — use `page.waitForResponse(urlOrPredicate)` before the action that triggers the request, then await the result after. The order matters: set up waitForResponse before triggering navigation.
  • iframe handling. cy.get('iframe').its('0.contentDocument').then(cy.wrap) is the Cypress iframe pattern. Playwright uses `page.frameLocator('iframe')` which returns a FrameLocator — a first-class API. Most teams find Playwright's iframe handling easier, but the migration requires rewriting any Cypress iframe code.
  • Global before/after hooks from cypress/support/e2e.ts. In Cypress, setup in support/e2e.ts runs before every test in every file automatically. Playwright has no equivalent file. Move global setup to playwright.config.ts globalSetup, per-project setup files, or a shared fixture that all tests import.

// Post-migration checklist

Use this list to verify the migration is complete before removing the Cypress dependency. Don't rush the last two items — they're the ones teams most often skip and then regret.

  • CI runs both the Cypress suite and the Playwright suite for at least one full sprint, both consistently green. Don't remove Cypress until you have sustained confidence.
  • Every Cypress test has either a Playwright equivalent or an explicit decision to drop it (documented in a comment, ticket, or test plan).
  • Playwright sharding is configured and verified — CI total time is within your team's acceptable window.
  • Trace files are uploaded as CI artifacts on test failure and accessible to all team members, not just CI admins.
  • Every engineer on the team has opened at least one Playwright trace file and knows how to navigate from a failed assertion to the screenshot and network log at that point.
  • Cypress can be uninstalled: grep the entire repo for `cy.` and `Cypress.` — zero matches outside cypress/ directory.
  • All Cypress custom commands have been ported as Playwright fixtures or page object methods — no cy.* calls remain in active test code.

// What's next

You've migrated. Here are the most useful next steps depending on where your team is heading.

// Appendix — sources & references

Primary sources used to verify the API examples and claims in this guide. All Playwright links point to the stable docs for 1.60.

Playwright documentation

  • Playwright Locators: https://playwright.dev/docs/locators — the primary API for selecting elements
  • Playwright Assertions (expect): https://playwright.dev/docs/test-assertions — complete list of expect() matchers with auto-waiting semantics
  • Playwright Authentication: https://playwright.dev/docs/auth — storageState setup, multi-user testing, OAuth patterns
  • Playwright API Testing: https://playwright.dev/docs/api-testing — APIRequestContext, page.request, standalone request fixture
  • Playwright Test Sharding: https://playwright.dev/docs/test-sharding — --shard flag, merging reports across shards
  • Playwright Trace Viewer: https://playwright.dev/docs/trace-viewer — how to record, open, and read traces
  • Playwright Test Fixtures: https://playwright.dev/docs/test-fixtures — test.extend(), fixture scopes, worker fixtures
  • Playwright Network Mocking (page.route): https://playwright.dev/docs/network — route interception, response mocking, request modification
  • Playwright Component Testing: https://playwright.dev/docs/test-components — experimental status, supported frameworks
  • Playwright Configuration Reference: https://playwright.dev/docs/test-configuration — all playwright.config.ts options

Cypress documentation

  • Cypress cy.intercept(): https://docs.cypress.io/api/commands/intercept — network interception and stubbing
  • Cypress cy.session(): https://docs.cypress.io/api/commands/session — session caching between tests
  • Cypress cy.origin(): https://docs.cypress.io/api/commands/origin — cross-origin navigation and interaction
  • Cypress Component Testing: https://docs.cypress.io/guides/component-testing/overview — mounting components, supported frameworks, maturity

// Command reference

CategoryCypressPlaywrightNotes
Navigationcy.visit('/path')await page.goto('/path')Relative paths work if baseURL is set in playwright.config.ts. Absolute URLs always work.
cy.reload()await page.reload()Direct equivalent.
cy.go('back')await page.goBack()Direct equivalent. Returns the Response or null if navigation didn't happen.
cy.go('forward')await page.goForward()Direct equivalent.
cy.url()page.url()page.url() is synchronous and returns the current URL string. Use await expect(page).toHaveURL(/pattern/) for assertions.
cy.title()await page.title()await page.title() returns the document title string. Use await expect(page).toHaveTitle('...') for assertions.
Selectioncy.get('.selector')page.locator('.selector')Locator is lazy — no DOM access until an action or assertion is called. Supports CSS, XPath, and text selectors.
cy.get('[data-testid=x]')page.getByTestId('x')getByTestId() is the idiomatic Playwright equivalent. Requires testIdAttribute config or uses data-testid by default.
cy.contains('text')page.getByText('text') or page.locator('text=text')getByText() does a substring match by default. Pass { exact: true } for an exact match.
cy.find('.child')locator.locator('.child')Chain .locator() on an existing locator to scope the search. Direct equivalent.
cy.parent()locator.locator('..')XPath parent selector — no dedicated .parent() method in Playwright. Or use locator.locator('xpath=..').
cy.children()locator.locator(':scope > *')No direct .children() method. Use :scope > * to get immediate children.
cy.first() / cy.last()locator.first() / locator.last()Direct equivalents. Note: Playwright's .first() does NOT retry on subsequent elements if the first is removed — use more specific locators.
cy.eq(n)locator.nth(n)Direct equivalent. Zero-indexed in both. Playwright also has .first() and .last() for the ends.
Actionscy.click()await locator.click()Direct equivalent. Playwright auto-waits for actionability (visible, stable, enabled, not obscured) before clicking.
cy.type('text')await locator.fill('text')fill() replaces existing content. For key-by-key input (autocomplete, input masks), use locator.pressSequentially('text') instead.
cy.clear()await locator.clear()Direct equivalent. Playwright also accepts locator.fill('') for clearing.
cy.select('option')await locator.selectOption('value')selectOption() accepts value, label, or index: selectOption({ label: 'Canada' }). Supports multi-select via array.
cy.check()await locator.check()Direct equivalent. Throws if the element is not a checkbox or radio.
cy.uncheck()await locator.uncheck()Direct equivalent.
cy.trigger('event')await locator.dispatchEvent('event')dispatchEvent() fires a synthetic DOM event. For mouse events, provide eventInit: await locator.dispatchEvent('mouseover', { bubbles: true }).
cy.hover()await locator.hover()Direct equivalent for CSS hover effects. For custom tooltip triggers that need dispatchEvent, see dispatchEvent('mouseover').
cy.focus() / cy.blur()await locator.focus() / await locator.blur()Direct equivalents.
cy.dblclick()await locator.dblclick()Direct equivalent.
cy.rightclick()await locator.click({ button: 'right' })No .rightclick() shorthand — pass button option to click().
Assertions.should('be.visible')await expect(locator).toBeVisible()Auto-waits up to expect timeout (5s default). Remember: await is required — missing it silently skips the assertion.
.should('not.exist')await expect(locator).not.toBeAttached()toBeAttached() checks DOM presence; toBeVisible() checks visibility. Use not.toBeAttached() for 'not in DOM'.
.should('have.text', '...')await expect(locator).toHaveText('...')toHaveText() does an exact match on normalized whitespace. Use toContainText() for substring match.
.should('have.value', '...')await expect(locator).toHaveValue('...')Direct equivalent for input, textarea, and select elements.
.should('have.class', '...')await expect(locator).toHaveClass(/class-name/)Accepts string or regex. String must be the full class attribute; regex or toHaveClass('partial') checks for one class.
.should('have.attr', 'href', '...')await expect(locator).toHaveAttribute('href', '...')Direct equivalent. Second argument can be a string or regex.
.should('be.disabled')await expect(locator).toBeDisabled()Direct equivalent.
.should('be.checked')await expect(locator).toBeChecked()Direct equivalent. Also works for radio inputs.
Networkcy.intercept('GET', '/api/*', { ... })await page.route('/api/**', route => route.fulfill({ ... }))page.route() uses glob patterns (** not *). The handler is async — await route.fulfill() inside it.
cy.wait('@alias')const resp = await page.waitForResponse('/api/pattern')No alias system — set up waitForResponse before the action that triggers the request, then await after. Receives a Response object.
cy.request({ method, url, body })await page.request.post(url, { data: body })page.request shares the browser session. Use request fixture for standalone API calls.
cy.intercept().as('alias')const routePromise = page.waitForResponse(matcher)Playwright has no alias concept. Use waitForResponse() or waitForRequest() with a URL matcher instead.
cy.intercept({ times: 1 })await page.route('/path', handler, { times: 1 })Pass { times: N } as the third argument to page.route() to limit how many requests the handler applies to.
cy.intercept() with req.reply()route.fulfill() or route.continue({ ... })route.fulfill() returns a mock response. route.continue() forwards to the real server with optional modifications.
Storage & Cookiescy.clearCookies()await page.context().clearCookies()Clears all cookies for the browser context. Add { name, domain, path } filter to clear specific cookies.
cy.getCookies()await page.context().cookies()Returns array of Cookie objects. Filter by domain: await page.context().cookies('http://localhost:3000').
cy.setCookie('name', 'value')await page.context().addCookies([{ name, value, url }])addCookies() requires a url or domain+path. Takes an array — wrap single cookie in [].
cy.clearLocalStorage()await page.evaluate(() => localStorage.clear())No dedicated API — evaluate() runs JS in the browser context. For clearing all storage: page.context().clearPermissions() clears permissions, not storage.
cy.window().then(w => w.localStorage.getItem('key'))await page.evaluate(() => localStorage.getItem('key'))Direct equivalent via evaluate(). Returns the value or null.
Test lifecyclebefore(() => {})test.beforeAll(async ({ browser }) => {})test.beforeAll receives worker-scoped fixtures. Use for expensive once-per-worker setup. Does NOT receive page — create a page with await browser.newPage().
beforeEach(() => {})test.beforeEach(async ({ page }) => {})Direct equivalent. Receives test-scoped fixtures including page, context, browser.
afterEach(() => {})test.afterEach(async ({ page }) => {})Direct equivalent. Cleanup here is guaranteed to run even if the test fails.
after(() => {})test.afterAll(async ({ browser }) => {})Same scoping note as beforeAll — receives worker fixtures, not test fixtures.
support/index.ts global hooksplaywright.config.ts globalSetup + shared fixturesNo equivalent file. Use globalSetup for one-time DB/service setup, and a shared fixture module for per-test setup that every test needs.
Fixtures & datacy.fixture('file.json')import data from './fixtures/file.json'TypeScript JSON import is cleaner and typed. Requires resolveJsonModule: true in tsconfig. Or use fs.readFileSync for dynamic paths.
cy.wrap(value)No direct equivalentcy.wrap() puts a value into the Cypress command queue. In Playwright, values are already plain JS — no wrapping needed. Just use the value directly with async/await.
cy.task('taskName')No direct equivalentcy.task() runs Node code in the Cypress server process. In Playwright, test code already runs in Node — call your utility functions directly. For worker-level setup, use globalSetup.
cy.readFile('path')fs.readFileSync('path', 'utf-8') or await fs.promises.readFile('path', 'utf-8')Playwright tests run in Node — use the Node fs module directly. No browser-side equivalent needed.
cy.writeFile('path', content)await fs.promises.writeFile('path', content)Same as readFile — use the Node fs module directly.
ConfigurationCypress.env('KEY')process.env.KEY or test.use({ ... })Playwright uses standard Node environment variables. Access via process.env.KEY. For test-specific config, use test.use() or pass via playwright.config.ts env.
Cypress.config('baseUrl')use.baseURL in playwright.config.tsSet baseURL in playwright.config.ts under the use key. Access programmatically with page.context().browser()?.contexts() — but you rarely need to read it in tests.
baseUrl in cypress.config.jsbaseURL in playwright.config.ts use blockSpelling change: baseUrl → baseURL (capital URL). Both support http://localhost:3000 style values.
retries: { runMode: 2 }retries: 2 (or retries: { runMode: 2, openMode: 0 })Playwright accepts a number or an object with runMode/openMode. Identical semantics.
defaultCommandTimeout: 10000actionTimeout: 10_000 in use blockactionTimeout controls how long Playwright waits for actions on locators. expect timeout is separate: set via expect: { timeout: 5000 } at the top level of defineConfig.
viewportWidth / viewportHeightviewport: { width, height } in use blockPlaywright combines both into a single viewport object. Set per project or globally.
Custom commands & utilitiesCypress.Commands.add('name', fn)test.extend({ fixtureName: async ({ page }, use) => { ... } })test.extend() creates a new test function with the custom fixture. Import and use this extended test in your test files instead of the base @playwright/test one.
Cypress.Commands.overwrite('visit', fn)No direct equivalentThere's no command overwrite in Playwright. Use a custom fixture that wraps page.goto() with your additional logic, or a page object method.
cy.log('message')test.info().annotations or console.logconsole.log() works and shows in --reporter=list output. For structured annotations on the test report, use test.info().annotations.push({ type: 'log', description: msg }).
cy.screenshot()await page.screenshot({ path: 'screenshot.png' })page.screenshot() is async and returns a Buffer. Playwright also auto-captures screenshots on failure if set in playwright.config.ts: screenshot: 'only-on-failure'.
cy.pause()await page.pause()page.pause() pauses execution and opens Playwright Inspector. Use in --debug mode. Remove before committing.
cy.session('id', fn)storageState in playwright.config.ts projectsNo direct runtime equivalent. Playwright's storageState is set up once before tests run (via a setup project) and loaded via playwright.config.ts. See the Authentication section.