Migrating from Playwright to Cypress
Migrate from Playwright to Cypress with your AI assistant
Copies a ready-made prompt that walks your AI coding assistant through this migration. Works with any AI tool that can read and edit your project.
“Migrate this project's tests from Playwright to Cypress. First read https://docs.cypress.io/llm/markdown/app/guides/migration/playwright-to-cypress.md for the syntax mappings and patterns, then work through these steps: 1. Take inventory. Detect my package manager, find my Playwright config and test files, and summarize the patterns I use: locators, assertions, network mocking, authentication, fixtures/page objects, web server startup, and CI configuration. 2. Agree on a selector strategy. If my tests rely on getByRole/getByLabel/getByText and I have no test attributes, recommend installing @testing-library/cypress for a near 1:1 mapping instead of editing my app to add data-testid attributes. 3. Install Cypress alongside Playwright. Configure them to coexist, and do not modify or delete my Playwright config or specs until the Cypress equivalents pass. 4. Migrate one spec at a time. Translate async/await and the locator API to Cypress command chaining and retry-ability (never assign a cy.* return value to a variable), map expect() assertions to Cypress assertions, convert page.route() mocks to cy.intercept(), and port authentication, fixtures, and page objects. If my Playwright config uses a webServer block, set up start-server-and-test so Cypress has a running app. 5. Flag gaps. Call out anything with no direct Cypress equivalent (visual snapshots, soft assertions, test.step) instead of guessing. 6. Verify. Show me the proposed changes before applying them, then install and run eslint-plugin-cypress and my migrated Cypress tests to confirm they pass. Finish with a summary of what changed and anything you couldn't safely automate.”
This guide helps you translate Playwright tests to Cypress. It covers the core execution model differences, syntax mappings, and patterns for auth, networking, and CI.
Most migrations are incremental. You do not need to migrate all tests at once. Cypress and Playwright can coexist in the same repository during a transition.
Jump to the cheat sheet for a quick reference.
What you'll learn
- How Cypress executes tests: command chaining, retry-ability, and what replaces async/await
- Configuration, CLI flags, browser installation, and starting your app
- Choosing a selector strategy for Playwright's semantic locators
- Locators, interactions, assertions, and network stubbing mapped side by side
- Authentication, cross-origin testing, and multi-tab patterns
- Clock control, dialogs, iframes, and file handling
- Page objects, parameterized tests, and custom commands
- Parallelization, Smart Orchestration, Test Replay, and accessibility testing in Cypress Cloud
Quick conversion example
Here's a side-by-side comparison showing how Playwright tests translate to Cypress:
Before: Playwrightimport { test, expect } from '@playwright/test'
test.describe('Authorization', () => {
test('signs up', async ({ page }) => {
// Navigate
await page.goto('/signup')
// Fill form
await page.getByLabel('Password').fill('testPassword1234')
// Submit
await page.getByRole('button', { name: 'Create new account' }).click()
// Assert navigation
await expect(page).toHaveURL(/\/signup\/success$/)
})
})
describe('Authorization', () => {
it('signs up', () => {
// Navigate
cy.visit('/signup')
// Fill form
cy.get('[data-testid="password"]').type('testPassword1234')
// Submit
cy.contains('button', 'Create new account').click()
// Assert navigation
cy.url().should('include', 'signup/success')
})
})
Installing Cypress
Before migrating your tests, add Cypress to your project as a dev dependency.
- npm
- Yarn
- pnpm
- Bun
npm install cypress --save-dev
yarn add cypress --dev
pnpm add --save-dev cypress
bun add --dev cypress
Unlike Playwright, Cypress does not download its own browser binaries. It uses browsers already installed on your machine. Electron is bundled with Cypress and available by default, so you can run tests immediately after installation without any additional setup. Once installed, open the Cypress app to complete initial configuration:
- npm
- Yarn
- pnpm
- Bun
npx cypress open
yarn cypress open
pnpm cypress open
bunx cypress open
The Cypress App will walk you through choosing a testing type (end-to-end or component) and generating a starter configuration file. After setup, Cypress creates a cypress.config.js (or .ts) file and a cypress/ directory in your project root.
If your Playwright tests are written in TypeScript, add a tsconfig.json inside
the cypress/ folder so Cypress globals (cy, Cypress) type-check:
{
"compilerOptions": {
"types": ["cypress", "node"]
},
"include": ["**/*.ts"]
}
Configuration migration
Both Playwright and Cypress use a defineConfig-based configuration file at the
root of your project. The key structural difference is that Cypress scopes
most E2E-specific options under an e2e object, rather than a flat use
block.
Config file structure
The configuration file location is configurable using the --config-file command line flag or the configFile Module API option.
| Configuration | Playwright | Cypress |
|---|---|---|
| Default config file | playwright.config.ts | cypress.config.ts |
| Test files | testDir: './tests' | e2e.specPattern (glob) |
| E2E-specific options | use: { ... } | e2e: { ... } |
| Node event hooks | globalSetup / globalTeardown | e2e.setupNodeEvents |
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests',
outputDir: './test-results',
retries: process.env.CI ? 2 : 0,
timeout: 30000,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
viewport: { width: 1280, height: 720 },
video: 'off',
screenshot: 'only-on-failure',
},
})
import { defineConfig } from 'cypress'
export default defineConfig({
viewportWidth: 1280,
viewportHeight: 720,
video: false,
videosFolder: './test-results/videos',
reporter: 'html',
screenshotsFolder: './test-results/screenshots',
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'tests/**/*.cy.ts',
retries: {
runMode: 2,
openMode: 0,
},
defaultCommandTimeout: 30000,
screenshotOnRunFailure: true,
},
})
See Cypress Configuration for the full option reference.
Common translations
| Playwright | Cypress | Notes |
|---|---|---|
forbidOnly | use mocha/no-exclusive-tests lint rule | eslint-plugin-mocha |
headless | --headless or --headed CLI flag | Default headless in cypress run |
outputDir | screenshotsFolder / videosFolder | Separate options per asset type |
reporter | reporter | See Reporters. |
retries | retries.runMode / retries.openMode | Cypress separates CI and interactive mode retries |
testDir | specPattern | Cypress uses a glob, not a path. |
testIgnore | excludeSpecPattern | Glob string or array of globs. |
timeout | defaultCommandTimeout | defaultCommandTimeout is per command, not test. |
use.baseURL | e2e.baseUrl | Must be under e2e, not top-level |
use.channel | --browser <name>:<channel> on the CLI | See Launching Browsers. |
use.proxy | OS environment variables | See Proxy Configuration |
use.screenshot | screenshotOnRunFailure | Default is true |
use.video | video | Default is false |
use.viewport | viewportWidth / viewportHeight | Set at top level or scoped under testing type |
For Cypress-specific anti-patterns not covered by configuration options — such as hard-coded
cy.wait() calls, assigning command return values, and unsafe chaining — the official
eslint-plugin-cypress provides lint
rules that catch these issues in-editor. See the
full rules list.
CLI/Command line migration
Playwright and Cypress use different commands and flag structures. This section covers the most common translations.
Base commands
| Task | Playwright | Cypress |
|---|---|---|
| Run all tests (headless) | npx playwright test | cypress run |
| Open interactive mode | npx playwright test --ui | cypress open |
| Run in headed mode | npx playwright test --headed | cypress run --headed |
Common flag translations
| Playwright | Cypress |
|---|---|
--config=playwright.config.ts | --config-file cypress.config.ts |
npx playwright test todo.spec.ts | cypress run --spec "todo.cy.ts" |
--fail-on-flaky-tests | detect-flake-but-always-fail in config with Experimental Test Retries |
--forbid-only | mocha/no-exclusive-tests eslint-plugin-mocha rule |
--grep "pattern" | --expose grep="pattern" from @cypress/grep |
--grep-invert "pattern" | --expose grep="-pattern" from @cypress/grep |
--last-failed | Spec Prioritization |
--max-failures=5 | --auto-cancel-after-failures=5 |
--project=chromium | --project='./front-end' |
--quiet | --quiet |
--retries=2 | --config retries=2 |
--reporter=html | --reporter html |
--shard=1/4 | --record --parallel (requires Cypress Cloud) |
--fully-parallel | --record --parallel (requires Cypress Cloud) |
--workers=4 | --record --parallel (requires Cypress Cloud) |
--trace on | --record automatically records Test Replay |
Passing environment variables
In Cypress, use cy.env() for sensitive values (credentials, tokens, secrets)
or Cypress.expose() for non-sensitive configuration (feature flags, public
URLs, API versions). These are set via the top-level env and expose keys in
your Cypress configuration file. Both APIs require Cypress 15.10.0 or newer.
test('logs in', async ({ page }) => {
await page.getByLabel('User Name').fill(process.env.USER_NAME)
await page.getByLabel('Password').fill(process.env.PASSWORD)
})
export default defineConfig({
expose: {
API_VERSION: 'v2',
},
env: {
USER_NAME: process.env.USER_NAME,
PASSWORD: process.env.PASSWORD,
},
})
it('logs in', () => {
cy.env(['USER_NAME', 'PASSWORD']).then(({ USER_NAME, PASSWORD }) => {
const apiVersion = Cypress.expose('API_VERSION')
cy.visit(`/login/${apiVersion}`)
cy.get('[data-testid="username"]').type(USER_NAME)
cy.get('[data-testid="password"]').type(PASSWORD)
})
})
See cy.env() and Cypress.expose() for the full reference.
Browser installation
Playwright and Cypress take different approaches to browser management. Understanding the difference will help you set up CI correctly.
How each framework handles browsers
Playwright downloads and manages its own isolated browser binaries.
Running npx playwright install fetches Playwright-specific builds of
Chromium, Firefox, and WebKit that are separate from any browsers installed
on your system.
Cypress uses browsers already installed on the machine. There are no browser binaries to download. If Chrome is installed locally, Cypress will find and use it. This means browser setup is a CI concern rather than a per-project dependency.
Before: Playwrightnpx playwright install chromium webkit
To verify which browsers Cypress can find:
cypress info
Browser versions and CI
Because Cypress uses system browsers, you have explicit control over which browser version your tests run against.
Docker images
Cypress publishes official Docker images with specific browser versions pre-installed. Image tags encode the exact versions of Node, Chrome, Firefox, and Edge, so your CI environment is reproducible and version changes are explicit.
Node + browsers, without Cypress
cypress/browsers:node-22.19.0-chrome-139.0.7258.154-1-ff-142.0.1-edge-139.0.3405.125-1
Node + browsers + Cypress pre-installed
cypress/included:cypress-15.1.0-node-22.19.0-chrome-139.0.7258.154-1-ff-142.0.1-edge-139.0.3405.125-1
Use cypress/browsers when you want to control the Cypress version
through your package.json. Use cypress/included when you want a
fully self-contained image. If you need a specific combination of
versions not covered by the published images, use
cypress/factory
to build a custom image.
See cypress-docker-images for all available tags.
CircleCI orb
If you use CircleCI, the
Cypress CircleCI orb
handles installation, caching, and test execution. Pass
install-browsers: true to install Chrome, Firefox, and Edge into the CI
environment.
version: 2.1
orbs:
cypress: cypress-io/cypress@6
workflows:
build:
jobs:
- cypress/run:
install-browsers: true
start-command: 'npm start'
cypress-command: 'npx cypress run --browser chrome'
See CircleCI for full setup documentation.
Browser launch options
Playwright's launchOptions in playwright.config.ts maps to the
before:browser:launch event in Cypress, which you configure inside
setupNodeEvents of your Cypress configuration file. Both let you modify how the browser starts. See the Browser Launch Event for the full reference.
Launch arguments, extensions, and preferences
Cypress exposes the before:browser:launch event.
export default defineConfig({
use: {
launchOptions: {
args: ['--disable-gpu'],
},
},
})
export default defineConfig({
e2e: {
setupNodeEvents(on) {
on('before:browser:launch', (browser, launchOptions) => {
if (browser.family === 'chromium' && browser.name !== 'electron') {
launchOptions.args.push('--disable-gpu')
}
return launchOptions
})
},
},
})
The launchOptions object exposes three properties depending on the browser:
| Capability | Playwright | Cypress |
|---|---|---|
| Browser launch arguments | launchOptions.args | launchOptions.args via before:browser:launch |
| Firefox preferences | launchOptions.firefoxUserPrefs | launchOptions.preferences via before:browser:launch |
| Chrome/Edge extensions | args flags in launchPersistentContext() | launchOptions.extensions via before:browser:launch |
See the Browser Launch Event for the full reference and per-browser examples.
Starting your application under test
Playwright's config can start your dev server automatically through the
webServer option. Cypress does not start your application for you — it assumes
the app is already running at the baseUrl you configured.
The standard solution is
start-server-and-test,
which boots your server, waits for the URL to respond, runs Cypress, then shuts
the server down.
export default defineConfig({
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
{
"scripts": {
"cy:run": "start-server-and-test start http://localhost:3000 'cypress run'"
}
}
Test structure and syntax migration
Playwright imports test from @playwright/test. Cypress uses Mocha's BDD interface, which provides it() and describe() globally. test is not available in Cypress and must be replaced.
| Playwright | Cypress |
|---|---|
test('name', () => {}) | it('name', () => {}) |
test.only('name', () => {}) | it.only('name', () => {}) |
test.skip('name', () => {}) | it.skip('name', () => {}) |
test.describe('group', () => {}) | describe('group', () => {}) |
test.describe.only('group', () => {}) | describe.only('group', () => {}) |
import { test, expect } from '@playwright/test'
test.describe('checkout', () => {
test('completes purchase', async ({ page }) => { ... })
test.only('applies discount', async ({ page }) => { ... })
test.skip('handles expired card', async ({ page }) => { ... })
})
describe('checkout', () => {
it('completes purchase', () => { ... })
it.only('applies discount', () => { ... })
it.skip('handles expired card', () => { ... })
})
Execution model and retry-ability
Playwright code typically awaits each action and may use explicit waits for specific conditions. Cypress commands are enqueued and automatically retry assertions until they pass or timeout.
What this means for your tests:
| Playwright | Cypress |
|---|---|
await page.waitForSelector(...) | cy.get(...) retries until it finds the element |
await expect(locator)... | cy.get(...).should(...) retries automatically |
await page.getByTestId('submit').click()
await page.waitForSelector('[data-testid="toast"]')
await expect(page.getByTestId('toast')).toHaveText('Saved!')
cy.get('[data-testid="submit"]').click()
cy.get('[data-testid="toast"]').should('have.text', 'Saved!')
Learn more about Cypress retry-ability.
Async/await vs command chaining
Playwright uses async/await patterns.
Before: Playwrightconst button = await page.getByRole('button', { name: 'Submit' })
await button.click()
const toast = await page.getByTestId('toast')
await expect(toast).toHaveText('Success')
Cypress uses command chaining without async/await.
After: Cypresscy.get('button').contains('Submit').click()
cy.get('[data-testid="toast"]').should('have.text', 'Success')
See Cypress's command model in the Introduction to Cypress.
Choosing a selector strategy
Playwright suites lean on semantic locators — getByRole(), getByLabel(),
and getByText(). Before translating tests, decide how these map to Cypress,
because the choice affects nearly every test.
Option A — Cypress Testing Library (lowest friction, no app changes).
Install
@testing-library/cypress
for a near 1:1 replacement of Playwright's semantic locators:
| Playwright | Cypress (Testing Library) |
|---|---|
getByRole('button', { name }) | findByRole('button', { name }) |
getByLabel('Email') | findByLabelText('Email') |
getByText('Welcome') | findByText('Welcome') |
getByPlaceholder('Search') | findByPlaceholderText('Search') |
getByTestId('submit') | findByTestId('submit') |
Option B — data-* attributes (Cypress best practice for new code).
Add data-testid (or data-cy) attributes to your application and select with
cy.get('[data-testid="..."]'), as described in
Best Practices. This is
the most resilient long-term strategy, but it requires editing your application
source.
The examples in this guide use data-testid for brevity. If your Playwright
suite relies on semantic locators and you don't already have test attributes,
Option A is the fastest path to parity without touching application code.
Locators and selectors
Prefer stable selectors
Use a stable selector strategy (often data-*) as described in Cypress Best Practices.
await page.getByRole('button', { name: 'Sign in' }).click()
cy.contains('button', 'Sign in').click()
getByRole() and getByLabel()
If you want getByRole()/getByLabel() ergonomics in Cypress, install Cypress Testing Library.
await page.getByRole('button', { name: 'Save' }).click()
await page.getByLabel('First name').fill('Jane')
cy.findByRole('button', { name: 'Save' }).click()
cy.findByLabelText('First name').type('Jane')
Interactions
Click, type, and clear
Before: Playwrightawait page.locator('#username').fill('jane')
await page.locator('#password').fill('secret')
await page.getByRole('button', { name: 'Log in' }).click()
cy.get('#username').clear()
cy.get('#username').type('jane')
cy.get('#password').clear()
cy.get('#password').type('secret')
cy.contains('button', 'Log in').click()
Checkboxes, radios, and selects
Before: Playwrightawait page.getByLabel('Subscribe').check()
await page.getByLabel('Daily').check()
await page.getByLabel('Country').selectOption('US')
cy.get('[data-testid="subscribe"]').check()
cy.get('[data-testid="daily"]').check()
cy.get('[data-testid="country"]').select('US')
Hover
Before: Playwrightawait page.getByTestId('menu').hover()
await page.getByRole('menuitem', { name: 'Settings' }).click()
cy.get('[data-testid="menu"]').trigger('mouseover')
cy.contains('[role="menuitem"]', 'Settings').click()
Mobile viewport testing
Before: Playwrightawait page.setViewportSize({ width: 375, height: 812 })
await page.goto('/')
cy.viewport(375, 812)
cy.visit('/')
See cy.viewport().
Assertions
Visibility and existence
Before: Playwrightawait expect(page.getByText('Welcome')).toBeVisible()
await expect(page.getByTestId('spinner')).toBeHidden()
cy.contains('Welcome').should('be.visible')
cy.get('[data-testid="spinner"]').should('not.be.visible')
Text, attributes, and URL
Before: Playwrightawait expect(page.getByTestId('toast')).toHaveText('Saved!')
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
await expect(page).toHaveURL(/\/dashboard$/)
cy.get('[data-testid="toast"]').should('have.text', 'Saved!')
cy.contains('button', 'Save').should('be.disabled')
cy.url().should('match', /\/dashboard$/)
See Cypress assertions at Assertions.
Waiting, retries, and timeouts
Playwright often uses explicit waits. Cypress will retry most DOM queries and assertions until they pass or time out.
Before: Playwrightawait page.getByRole('button', { name: 'Save' }).click()
await page.waitForResponse('**/api/profile')
await expect(page.getByTestId('toast')).toHaveText('Saved!')
cy.intercept('POST', '/api/profile').as('saveProfile')
cy.contains('button', 'Save').click()
cy.wait('@saveProfile')
cy.get('[data-testid="toast"]').should('have.text', 'Saved!')
Learn more about Cypress retry-ability at Retry-ability.
Network spying and stubbing
Spy
Before: Playwrightconst [response] = await Promise.all([
page.waitForResponse('**/api/users'),
page.getByRole('button', { name: 'Load users' }).click(),
])
expect(response.ok()).toBeTruthy()
cy.intercept('GET', '/api/users').as('users')
cy.contains('button', 'Load users').click()
cy.wait('@users').its('response.statusCode').should('eq', 200)
Stub a response
Before: Playwrightawait page.route('**/api/projects', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: '1' }, { id: '2' }]),
})
})
cy.intercept('GET', '/api/projects', {
statusCode: 200,
body: [{ id: '1' }, { id: '2' }],
}).as('projects')
Modify a response
Before: Playwrightawait page.route('**/api/profile', async (route) => {
const response = await route.fetch()
const json = await response.json()
json.plan = 'enterprise'
await route.fulfill({ response, json })
})
cy.intercept('GET', '/api/profile', (req) => {
req.continue((res) => {
res.body.plan = 'enterprise'
})
}).as('profile')
See Network Requests and cy.intercept().
Authentication patterns
Playwright commonly reuses auth via storageState. In Cypress, use cy.session() to cache cookies and storage between tests.
import { defineConfig } from '@playwright/test'
export default defineConfig({
use: {
storageState: 'storageState.json',
},
})
import { test as setup } from '@playwright/test'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Username').fill('jane')
await page.getByLabel('Password').fill('secret')
await page.getByRole('button', { name: 'Log in' }).click()
await page.context().storageState({ path: 'storageState.json' })
})
const login = () => {
cy.visit('/login')
cy.get('#username').type('jane')
cy.get('#password').type('secret')
cy.contains('button', 'Log in').click()
}
beforeEach(() => {
cy.session('jane', login)
})
Cross-origin
Cypress enforces a same-origin policy by default. Use cy.origin() to
run commands against a different origin within the same test. Any
commands targeting a secondary origin must be scoped inside the
cy.origin() callback.
await page.goto('https://site-a.example')
await page.goto('https://site-b.example')
await expect(page.getByRole('banner')).toBeVisible()
cy.visit('https://site-a.example')
cy.origin('https://site-b.example', () => {
cy.visit('https://site-b.example')
cy.get('[role="banner"]').should('be.visible')
})
See cy.origin() for complete documentation.
Screenshots
Before: Playwrightawait page.screenshot({ path: 'after-signup.png' })
await page.locator('[data-testid="toast"]').screenshot({ path: 'toast.png' })
cy.screenshot('after-signup')
cy.get('[data-testid="toast"]').screenshot('toast')
File uploads and downloads
Upload
Before: Playwrightawait page.setInputFiles('input[type="file"]', 'fixtures/avatar.png')
cy.get('input[type="file"]').selectFile('cypress/fixtures/avatar.png')
See cy.selectFile().
Downloads
In Cypress, a common approach is to trigger the download, then assert the file exists in the configured downloads folder.
Before: Playwrightconst download = await page.waitForEvent('download')
await page.getByRole('button', { name: 'Export CSV' }).click()
const path = await download.path()
cy.contains('button', 'Export CSV').click()
cy.readFile('cypress/downloads/export.csv')
See cy.readFile().
Clock and time control
Both Playwright and Cypress let you replace native browser time functions with a fake clock that you control. This allows tests to set a specific date, skip waiting for timers, and test time-sensitive UI behavior without real delays. The two tools take different approaches to time control.
Before: Playwrightawait page.clock.setFixedTime(new Date('2024-02-02T10:00:00'))
await page.goto('/')
await expect(page.getByTestId('current-time')).toHaveText(
'2/2/2024, 10:00:00 AM'
)
cy.clock(new Date('2024-02-02T10:00:00'))
cy.visit('/')
cy.get('[data-testid="current-time"]').should(
'have.text',
'2/2/2024, 10:00:00 AM'
)
Advancing time
To simulate the passage of time and trigger scheduled timers, Playwright uses fastForward or runFor. Cypress uses cy.tick().
await page.clock.install()
await page.goto('/')
await page.getByRole('button').click()
// Skip forward 5 minutes, firing all timers that fall within the window
await page.clock.fastForward('05:00')
await expect(page.getByText('Session expired')).toBeVisible()
cy.clock()
cy.visit('/')
cy.get('button').click()
// cy.tick() takes milliseconds; 5 minutes = 300,000 ms
cy.tick(5 * 60 * 1000)
cy.contains('Session expired').should('be.visible')
Pausing at a specific time
Playwright supports pauseAt, which lets the clock run naturally from its installed time and then pause automatically when it reaches a target timestamp. For Cypress to reach a specific point in time, calculate the delta between the installed time and the target time and pass that to cy.tick().
await page.clock.install({ time: new Date('2024-02-02T08:00:00') })
await page.goto('/')
// Clock runs naturally until it reaches 10:00 AM, then pauses
await page.clock.pauseAt(new Date('2024-02-02T10:00:00'))
await expect(page.getByTestId('current-time')).toHaveText(
'2/2/2024, 10:00:00 AM'
)
// Advance another 30 minutes manually
await page.clock.fastForward('30:00')
await expect(page.getByTestId('current-time')).toHaveText(
'2/2/2024, 10:30:00 AM'
)
cy.clock(new Date('2024-02-02T08:00:00'))
cy.visit('/')
// Advance 2 hours (from 08:00 to 10:00) manually
cy.tick(2 * 60 * 60 * 1000)
cy.get('[data-testid="current-time"]').should(
'have.text',
'2/2/2024, 10:00:00 AM'
)
// Advance another 30 minutes
cy.tick(30 * 60 * 1000)
cy.get('[data-testid="current-time"]').should(
'have.text',
'2/2/2024, 10:30:00 AM'
)
Changing the system time mid-test
Both tools allow you to change the current time without triggering timers. In Playwright this is page.clock.setSystemTime(). In Cypress it is clock.setSystemTime() on the yielded clock object.
await page.clock.install({ time: new Date('2024-02-02T10:00:00') })
await page.goto('/')
// Jump to a new time without firing any scheduled timers
await page.clock.setSystemTime(new Date('2024-02-02T10:30:00'))
cy.clock(new Date('2024-02-02T10:00:00'))
cy.visit('/')
cy.clock().then((clock) => {
// Jump to a new time without firing any scheduled timers
clock.setSystemTime(new Date('2024-02-02T10:30:00'))
})
Overriding only specific functions
Cypress allows you to limit which native functions the clock replaces. This is useful when you only need to control Date without affecting timer behavior, or vice versa.
// Override only Date, leave setTimeout and setInterval running normally
cy.clock(Date.UTC(2024, 1, 2), ['Date'])
// Override only timers, leave Date as the system clock
cy.clock(null, ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'])
Multiple tabs and windows
You can handle multi-tab workflows using the
@cypress/puppeteer
plugin, which gives Cypress access to the underlying browser via Puppeteer. This approach requires Puppeteer as a dependency.
// Start waiting for the new tab before clicking.
const newPagePromise = page.context().waitForEvent('page')
await page.getByText('open new tab').click()
const newPage = await newPagePromise
await newPage.waitForLoadState()
await newPage.getByRole('button', { name: 'Confirm' }).click()
import { defineConfig } from 'cypress'
import { setup, retry } from '@cypress/puppeteer'
export default defineConfig({
e2e: {
setupNodeEvents(on) {
setup({
on,
onMessage: {
async switchToTabAndGetContent(browser) {
const page = await retry(async () => {
const pages = await browser.pages()
const page = pages.find((p) => p.url().includes('page-2.html'))
if (!page) throw new Error('Could not find page')
return page
})
await page.bringToFront()
const paragraph = await page.waitForSelector('p')
return await page.evaluate((el) => el.textContent, paragraph)
},
},
})
},
},
})
In your support file, import the command once:
// cypress/support/e2e.ts
import '@cypress/puppeteer/support'
cy.visit('/')
cy.contains('button', 'Open new tab').click()
cy.puppeteer('switchToTabAndGetContent').should(
'equal',
'You said: Hello from Page 1'
)
Iframes
Cypress has no equivalent single command. The standard approach is to get the
iframe element, access its contentDocument.body, and then wrap that body
element with cy.wrap() so you can use Cypress commands on its contents. The
.should('not.be.empty') assertion ensures Cypress waits for the frame's
document to load before proceeding.
// Locate and fill a field inside an iframe
const frame = page.frameLocator('#payment-frame')
await frame.getByLabel('Card number').fill('4242424242424242')
await frame.getByLabel('Expiry').fill('12/26')
await frame.getByRole('button', { name: 'Pay now' }).click()
cy.get('#payment-frame')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
.within(() => {
cy.get('input[aria-label="Card number"]').type('4242424242424242')
cy.get('input[aria-label="Expiry"]').type('12/26')
cy.contains('button', 'Pay now').click()
})
If you interact with the same iframe across multiple tests, extract the frame access into a custom command to avoid repeating the boilerplate:
declare global {
namespace Cypress {
interface Chainable {
getIframeBody(selector: string): Chainable<JQuery<HTMLElement>>
}
}
}
Cypress.Commands.add('getIframeBody', (selector: string) => {
return cy
.get(selector)
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
})
// In your test
cy.getIframeBody('#payment-frame').within(() => {
cy.contains('button', 'Pay now').click()
})
This works for same-origin iframes. Unlike Playwright's frameLocator,
Cypress cannot reach into a cross-origin iframe unless you set
chromeWebSecurity to false in
Chromium-family browsers. See
How do I test elements inside an iframe?
for the full details and restrictions.
Shadow DOM
For Shadow DOM in Cypress, you can traverse explicitly:
Cypresscy.get('[data-testid="shadow-host"]').shadow().find('button').click()
See .shadow().
Dialogs
alert()
Cypress always accepts alert() dialogs.
// Register a handler to inspect the alert text before accepting
page.on('dialog', async (dialog) => {
expect(dialog.type()).toBe('alert')
expect(dialog.message()).toBe('Unsaved changes will be lost.')
await dialog.accept()
})
await page.getByRole('button', { name: 'Leave page' }).click()
// Cypress auto-accepts alerts. Listen to assert on the message.
cy.on('window:alert', (message) => {
expect(message).to.eq('Unsaved changes will be lost.')
})
cy.get('button').contains('Leave page').click()
confirm()
Before: Playwright// Accept the confirmation
page.on('dialog', (dialog) => dialog.accept())
await page.getByRole('button', { name: 'Delete account' }).click()
await expect(page.getByText('Account deleted')).toBeVisible()
// Dismiss the confirmation
page.on('dialog', (dialog) => dialog.dismiss())
await page.getByRole('button', { name: 'Delete account' }).click()
await expect(page.getByText('Account deleted')).not.toBeVisible()
// Cypress auto-accepts confirm(). No handler needed to test the accepted path.
cy.get('button').contains('Delete account').click()
cy.contains('Account deleted').should('be.visible')
// Return false to dismiss the confirmation
cy.on('window:confirm', () => false)
cy.get('button').contains('Delete account').click()
cy.contains('Account deleted').should('not.exist')
If you need to assert on the confirmation message text and control acceptance in the same test, combine both:
cy.on('window:confirm', (message) => {
expect(message).to.eq('Are you sure you want to delete your account?')
return false // return true or omit the return to accept
})
cy.get('button').contains('Delete account').click()
For tests that need to verify the stub was called, use cy.stub() instead of
cy.on(). This lets you use Sinon assertions to confirm the dialog was
triggered the expected number of times.
const confirmStub = cy.stub().as('confirmDialog').returns(false)
cy.on('window:confirm', confirmStub)
cy.get('button').contains('Delete account').click()
cy.get('@confirmDialog').should('have.been.calledOnce')
prompt()
In Cypress, you must stub window.prompt before
the page loads using the onBeforeLoad callback in cy.visit(). This gives
you full control over the return value and lets you assert that the prompt
was called.
page.on('dialog', async (dialog) => {
expect(dialog.type()).toBe('prompt')
expect(dialog.message()).toBe('Enter your name:')
await dialog.accept('Ada Lovelace')
})
await page.getByRole('button', { name: 'Set name' }).click()
await expect(page.getByTestId('greeting')).toHaveText('Hello, Ada Lovelace!')
cy.visit('/', {
onBeforeLoad(win) {
cy.stub(win, 'prompt').as('promptDialog').returns('Ada Lovelace')
},
})
cy.get('button').contains('Set name').click()
cy.get('[data-testid="greeting"]').should('have.text', 'Hello, Ada Lovelace!')
cy.get('@promptDialog').should('have.been.calledWith', 'Enter your name:')
To simulate the user cancelling the prompt (equivalent to dialog.dismiss()
in Playwright), return null from the stub:
cy.visit('/', {
onBeforeLoad(win) {
cy.stub(win, 'prompt').returns(null)
},
})
beforeunload
If your tests need to verify that a beforeunload handler is registered and
sets a returnValue, use the window:before:unload event to assert on the
event object directly:
page.on('dialog', async (dialog) => {
expect(dialog.type()).toBe('beforeunload')
await dialog.dismiss()
})
await page.close({ runBeforeUnload: true })
// Assert that the beforeunload handler fired and set a returnValue
cy.on('window:before:unload', (event) => {
expect(event.returnValue).to.eq('')
})
// Trigger navigation to fire beforeunload
cy.get('a').contains('Leave').click()
print dialogs
In Cypress, stub window.print using onBeforeLoad in cy.visit() and
assert that the stub was called.
await page.goto('/')
await page.evaluate(
'(() => { window.waitForPrint = new Promise(f => window.print = f) })()'
)
await page.getByRole('button', { name: 'Print invoice' }).click()
await page.waitForFunction('window.waitForPrint')
cy.visit('/', {
onBeforeLoad(win) {
cy.stub(win, 'print').as('printDialog')
},
})
cy.get('button').contains('Print invoice').click()
cy.get('@printDialog').should('have.been.calledOnce')
API testing
Before: Playwright// APIRequestContext (Playwright)
const res = await request.get('/api/health')
expect(res.ok()).toBeTruthy()
cy.request('/api/health').its('status').should('eq', 200)
See cy.request().
Test generation
Cypress Studio lets you generate test code
by interacting with your application in the Cypress app. It records
actions like clicks and form inputs and writes the corresponding Cypress
commands. Cypress Studio is a replacement for Playwright's playwright codegen. With a Cypress Cloud account, you also get Studio AI, AI-powered assertion recommendations as you record.
Component Testing
Playwright Component Testing is experimental and uses a mount fixture (for example via @playwright/experimental-ct-react). Cypress Component Testing uses cy.mount(), typically scaffolded in your Cypress support file.
Framework and bundler support
| Capability | Playwright Component Testing | Cypress Component Testing |
|---|---|---|
| Frameworks | Experimental packages for React, Vue, and Svelte | Official mounting libraries for React, Vue, Angular, and Svelte |
| Bundlers | Vite | Vite & Webpack |
See Component Testing for more details.
Before: Playwrightimport { test, expect } from '@playwright/experimental-ct-react'
import { Button } from './Button'
test('renders', async ({ mount }) => {
const component = await mount(<Button>Save</Button>)
await expect(component).toContainText('Save')
})
import { Button } from './Button'
describe('Button', () => {
it('renders', () => {
cy.mount(<Button>Save</Button>)
cy.contains('button', 'Save').should('be.visible')
})
})
See Component Testing and cy.mount().
Reusable code patterns
Page object model
If you're using Page Object Model (POM) in Playwright, the same pattern works in Cypress:
Before: Playwrightexport class LoginPage {
constructor(private page: Page) {}
async login(username: string, password: string) {
await this.page.getByLabel('Username').fill(username)
await this.page.getByLabel('Password').fill(password)
await this.page.getByRole('button', { name: 'Login' }).click()
}
}
export class LoginPage {
login(username: string, password: string) {
cy.get('[data-testid="username"]').type(username)
cy.get('[data-testid="password"]').type(password)
cy.contains('button', 'Login').click()
}
}
Parameterized tests
Both Playwright and Cypress support generating tests from a data array using
standard JavaScript. The pattern is the same in both tools: call .forEach()
on your dataset outside of or inside a describe block, and generate one
test per entry.
;[
{ name: 'Maya', expected: 'Hello, Maya!' },
{ name: 'Theo', expected: 'Hello, Theo!' },
{ name: 'Nora', expected: 'Hello, Nora!' },
].forEach(({ name, expected }) => {
test(`testing with ${name}`, async ({ page }) => {
await page.goto(`https://example.com/greet?name=${name}`)
await expect(page.getByRole('heading')).toHaveText(expected)
})
})
;[
{ name: 'Maya', expected: 'Hello, Maya!' },
{ name: 'Theo', expected: 'Hello, Theo!' },
{ name: 'Nora', expected: 'Hello, Nora!' },
].forEach(({ name, expected }) => {
it(`testing with ${name}`, () => {
cy.visit(`/greet?name=${name}`)
cy.get('h1').should('have.text', expected)
})
})
Custom commands
Before: Playwrightexport const test = base.extend({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login')
await page.getByLabel('Username').fill('user')
await page.getByLabel('Password').fill('pass')
await page.getByRole('button', { name: 'Login' }).click()
await use(page)
},
})
declare global {
namespace Cypress {
interface Chainable {
login(username: string, password: string): Chainable<void>
}
}
}
Cypress.Commands.add('login', (username: string, password: string) => {
cy.session([username, password], () => {
cy.visit('/login')
cy.get('[data-testid="username"]').type(username)
cy.get('[data-testid="password"]').type(password)
cy.contains('button', 'Login').click()
cy.url().should('include', '/dashboard')
})
})
See Custom Commands for more details.
CI parallelization
Key architectural difference
Playwright parallelizes tests locally using workers and across machines using manual sharding. You define the split strategy in config or at the command line.
- Worker count controlled via config (
workers: 4) or CLI (--workers=4) - Sharding splits tests across machines (
--shard=1/4) - You define the split strategy upfront
playwright test --shard=2/4 # Machine 2
playwright test --shard=3/4 # Machine 3
playwright test --shard=4/4 # Machine 4
Cypress parallelizes tests across machines through Cypress Cloud, which distributes specs dynamically based on historical run durations. This requires a Cypress Cloud account.
- Parallelization happens in CI through Cypress Cloud.
- Specs are distributed dynamically based on historical run data.
- No manual sharding required.
cypress run --record --parallel
Smart Orchestration in Cypress Cloud
When you use Cypress Cloud, you gain Smart Orchestration features that go beyond simple parallelization:
- Distributes specs across machines based on previous run durations
- Minimizes overall run time automatically
- No need to manually configure shards or splits
- Runs specs that failed in the previous run first
- Provides faster feedback on fixes
- Reduces time to detect persistent failures
- Cancels remaining specs after a threshold of failures
- Avoids wasting CI time on runs that can't pass
- Configurable failure thresholds
See Parallelization for setup details.
Debugging with Test Replay
Test Replay is available on all Cypress Cloud plans at no additional cost. It records a full, interactive replay of every test run, including network requests, console output, and DOM snapshots. Replays are shareable via link and do not require a local trace file.
Before: Playwrightplaywright test --trace on
npx playwright show-trace test-results/**/trace.zip
cypress run --record --key <record_key>
See Test Replay for more details.
Handling flaky tests
Flaky tests are a common challenge in testing. Both frameworks offer retry mechanisms, but Cypress Cloud adds intelligent flake detection and management.
Before: Playwrightexport default {
retries: process.env.CI ? 2 : 0,
}
export default {
retries: {
runMode: 2,
openMode: 0,
},
}
Flaky test management in Cypress Cloud
Beyond basic retries, Cypress Cloud provides:
- Automatically identifies tests that pass after retry
- Tracks flake rate over time
- Provides analytics on which specs are most flaky
- Helps prioritize stability improvements
Accessibility testing
If you record test runs to Cypress Cloud, Cypress Accessibility provides automated accessibility scanning across your entire test suite with no changes to your test code.
Rather than requiring checkA11y() calls in each test, Cypress Accessibility captures DOM snapshots during test execution server-side, using the same protocol as Test Replay. Every distinct state your tests interact with is scanned automatically. This means pages and components that your tests visit but do not explicitly instrument are still checked.
Learn more about Accessibility testing.
Features without a direct equivalent
A few Playwright features have no built-in Cypress counterpart. Identify these up front so you can plan alternatives rather than discovering them mid-migration.
| Playwright feature | Cypress equivalent |
|---|---|
toHaveScreenshot() / toMatchSnapshot() visual snapshots | No built-in visual diffing. See Visual Testing for plugin options. |
expect.soft() (soft assertions) | No equivalent. Cypress fails on the first failed assertion; split into separate assertions or tests. |
test.step() | No direct equivalent. Group related commands and annotate with cy.log() or nested context() blocks. |
toMatchAriaSnapshot() | No equivalent. Assert roles and labels individually, or use Cypress Accessibility for automated scanning. |
Cheat sheet
The await required in Playwright is not included in the cheat sheet for readability.
| Playwright | Cypress |
|---|---|
page.locator('css') | cy.get('css') |
page.getByTestId('submit') | cy.get('[data-testid="submit"]') |
page.goto('/path') | cy.visit('/path') |
page.reload() | cy.reload() |
page.title() | cy.title() |
page.url() | cy.url() |
page.waitForResponse(...) | cy.intercept(...).as('x'); cy.wait('@x') |
page.setInputFiles(selector, 'file.png') | cy.get(selector).selectFile('file.png') |
page.locator(selector).click() | cy.get(selector).click() |
page.locator(selector).click({ force: true }) | cy.get(selector).click({force: true}) |
page.locator(selector).click({ button: 'right' }) | cy.get(selector).rightclick() |
page.locator(selector).click({ modifiers: ['Shift'] }) | cy.get(selector).click({shiftKey: true}) |
page.locator(selector).dblclick() | cy.get(selector).dblclick() |
page.locator(selector).hover() | cy.get(selector).trigger('mouseover') |
page.locator(selector).fill('text') | cy.get(selector).clear().type('text') |
page.locator(selector).focus() | cy.get(selector).focus() |
page.locator(selector).press('Tab') | cy.get(selector).focus(); cy.press(Cypress.Keyboard.Keys.TAB) |
page.locator(selector).selectOption('blue') | cy.get(selector).select('blue') |
page.locator(selector).scrollIntoViewIfNeeded() | cy.get(selector).scrollIntoView() |
page.locator(selector).screenshot() | cy.get(selector).screenshot() |
page.route('**/api', ...) | cy.intercept('/api', ...) |
page.screenshot() | cy.screenshot() |
use: { storageState: 'state.json' } | cy.session('user', login) |
page.clock.setFixedTime(date) | cy.clock(now) |
page.clock.install({ time }) | cy.clock(now) |
page.clock.fastForward(duration) | cy.tick(ms) |
page.clock.runFor(duration) | cy.tick(ms) |
page.clock.setSystemTime(date) | clock.setSystemTime(date) |
page.on('dialog', dialog => dialog.accept()) | Auto-accepted by default |
page.on('dialog', dialog => dialog.dismiss()) | cy.on('window:confirm', () => false) |
page.on('dialog', dialog => dialog.accept('x')) | cy.stub(win, 'prompt').returns('x') in onBeforeLoad |
page.on('dialog') with beforeunload | cy.on('window:before:unload', event => ...) |
expect(page).toHaveURL(/re/) | cy.url().should('match', /re/) |
expect(locator).toHaveText('Yes!') | cy.get(selector).should('have.text', 'Yes!') |
expect(locator).toHaveAttribute('href') | cy.get(selector).should('have.attr', 'href') |
expect(locator).toBeChecked() | cy.get(selector).should('be.checked') |
expect(locator).toBeDisabled() | cy.get(selector).should('be.disabled') |
expect(locator).toBeVisible() | cy.get(selector).should('be.visible') |
expect(locator).toBeHidden() | cy.get(selector).should('be.hidden') |
expect(locator).toBeEnabled() | cy.get(selector).should('be.enabled') |
expect(locator).toBeAttached() | cy.get(selector).should('exist') |
expect(locator).toBeFocused() | cy.get(selector).should('have.focus') |
expect(locator).toHaveValue('123') | cy.get(selector).should('have.value', '123') |
expect(locator).toContainText('Yes!') | cy.get(selector).should('contain', 'Yes!') |
expect(locator).toContainClass('light') | cy.get(selector).should('have.class', 'light') |
expect(locator).toHaveCSS('color', 'red') | cy.get(selector).should('have.css', 'color', 'red') |
browserContext.cookies() | cy.getCookies() |
browserContext.clearCookies() | cy.clearCookies() |
browserContext.addCookies(cookie) | cy.setCookie(name, value) |
page.locator(selector).first() | cy.get(selector).first() |
page.locator(selector).last() | cy.get(selector).last() |
page.locator(selector).nth(n) | cy.get(selector).eq(n) |
page.locator(selector).filter({ hasText: 'x' }) | cy.get(selector).filter(':contains("x")') |
page.waitForURL('**/dashboard') | cy.url().should('include', '/dashboard') |
page.goBack() | cy.go('back') |
page.goForward() | cy.go('forward') |