When to use: When converting a Cypress test suite to Playwright. Follow this guide command-by-command, pattern-by-pattern.
Before converting any code, internalize these five differences. They affect every line you write.
Cypress commands are enqueued and run in a serial chain that looks synchronous but is not. Playwright uses standard async/await -- real asynchronous JavaScript with no magic scheduling.
// Cypress — chain-based, implicitly queued
cy.get('.todo-list li').should('have.length', 3);
cy.get('.todo-list li').first().should('have.text', 'Buy milk');
// Playwright — async/await, explicit control flow
const items = page.getByTestId('todo-item');
await expect(items).toHaveCount(3);
await expect(items.first()).toHaveText('Buy milk');Why it matters: You can use if/else, for loops, try/catch, and any JavaScript construct naturally. No cy.then() workarounds.
Cypress retries the entire command chain until assertions pass (or time out). Playwright locators are lazy -- they do nothing until you perform an action or assertion, at which point Playwright auto-waits for the element to be actionable (visible, enabled, stable).
// Cypress — retries cy.get + .should together
cy.get('[data-testid="submit"]').should('be.visible').click();
// Playwright — locator is created instantly; .click() auto-waits for visibility + stability
await page.getByTestId('submit').click();Cypress test code runs inside the browser (same origin). Playwright runs in Node.js and controls browsers over the Chrome DevTools Protocol or equivalent. This means:
- Playwright can open multiple tabs, windows, and even multiple browsers in a single test.
- Playwright has native access to the file system, databases, and APIs from test code -- no
cy.task()needed. - You cannot access
windowordocumentdirectly; usepage.evaluate()when you need browser-side code.
Cypress is limited to a single browser tab and a single browser. Playwright can control multiple pages, multiple browser contexts (each with isolated cookies/storage), and even multiple browser types (Chromium, Firefox, WebKit) in the same test run.
Cypress extends via Cypress.Commands.add() -- a global mutable registry. Playwright uses test.extend() fixtures with dependency injection, guaranteed teardown, and type safety. Fixtures are dramatically more powerful.
| Cypress | Playwright | Notes |
|---|---|---|
cy.visit('/path') |
await page.goto('/path') |
Use baseURL in config to avoid full URLs |
cy.get('selector') |
page.locator('selector') |
Prefer page.getByRole() over CSS selectors |
cy.get('[data-testid="x"]') |
page.getByTestId('x') |
Configure testIdAttribute in playwright.config |
cy.contains('text') |
page.getByText('text') |
For buttons/links, prefer page.getByRole('button', { name: 'text' }) |
cy.find('child') |
locator.locator('child') |
Chain locators to scope within a parent |
cy.get('sel').click() |
await locator.click() |
Auto-waits for element to be actionable |
cy.get('sel').type('text') |
await locator.fill('text') |
fill() sets value instantly. Use locator.pressSequentially('text') for character-by-character typing |
cy.get('sel').clear() |
await locator.clear() |
Or await locator.fill('') |
cy.get('select').select('val') |
await locator.selectOption('val') |
Accepts value, label, or { index } |
cy.get('input').check() |
await locator.check() |
No-op if already checked |
cy.get('input').uncheck() |
await locator.uncheck() |
No-op if already unchecked |
cy.get('sel').should('be.visible') |
await expect(locator).toBeVisible() |
Web-first assertion; auto-retries |
cy.get('sel').should('have.text', 'x') |
await expect(locator).toHaveText('x') |
Also accepts regex: .toHaveText(/pattern/) |
cy.get('sel').should('contain', 'x') |
await expect(locator).toContainText('x') |
Substring match |
cy.get('sel').should('have.length', 3) |
await expect(locator).toHaveCount(3) |
Counts matching elements |
cy.get('sel').should('have.value', 'x') |
await expect(locator).toHaveValue('x') |
Input/textarea value |
cy.get('sel').should('have.attr', 'href', '/x') |
await expect(locator).toHaveAttribute('href', '/x') |
Any HTML attribute |
cy.get('sel').should('have.class', 'active') |
await expect(locator).toHaveClass(/active/) |
Regex for partial class match |
cy.get('sel').should('be.disabled') |
await expect(locator).toBeDisabled() |
|
cy.get('sel').should('not.exist') |
await expect(locator).toBeHidden() |
Or .toHaveCount(0) for truly absent elements |
cy.intercept('GET', '/api/**', { body }) |
await page.route('/api/**', route => route.fulfill({ body })) |
Set up before the action that triggers the request |
cy.intercept('POST', '/api/save').as('save') |
const resp = page.waitForResponse('**/api/save') |
Start before the action, await after |
cy.wait('@save') |
await resp |
Returns Response object with .json(), .status() |
cy.fixture('users.json') |
Fixtures via test.extend() or direct import/require |
See Example 5 below |
cy.request('GET', '/api/users') |
const resp = await request.get('/api/users') |
Use the request fixture or APIRequestContext |
cy.request('POST', '/api/users', body) |
const resp = await request.post('/api/users', { data: body }) |
|
cy.wrap(value) |
No equivalent needed | Just use the value directly with await |
cy.then((result) => { ... }) |
const result = await ... |
Standard async/await replaces .then() chains |
Cypress.env('API_KEY') |
process.env.API_KEY |
Or use use: {} in playwright.config for test-specific values |
Cypress.Commands.add('login', fn) |
Custom fixture via test.extend() |
See Example 5 below |
cy.clock() / cy.tick(1000) |
await page.clock.install() / await page.clock.fastForward(1000) |
page.clock API for full time control |
cy.screenshot('name') |
await page.screenshot({ path: 'name.png' }) |
Auto-captured on failure when screenshot: 'on' in config |
cy.viewport(1280, 720) |
await page.setViewportSize({ width: 1280, height: 720 }) |
Prefer setting in config or per-project |
beforeEach(() => { cy.visit('/') }) |
test.beforeEach(async ({ page }) => { await page.goto('/') }) |
Destructure page from fixtures |
cy.scrollTo('bottom') |
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) |
Actions auto-scroll to elements; manual scroll is rarely needed |
cy.focused() |
page.locator(':focus') |
Or assert: await expect(locator).toBeFocused() |
cy.go('back') |
await page.goBack() |
Also page.goForward() |
cy.reload() |
await page.reload() |
|
cy.title() |
await expect(page).toHaveTitle('text') |
Web-first assertion on page title |
cy.url() |
await expect(page).toHaveURL('/path') |
Accepts string or regex |
cy.getCookie('name') |
const cookies = await context.cookies() |
Filter by name from the array |
cy.setCookie('name', 'val') |
await context.addCookies([{ name, value, url }]) |
|
cy.clearCookies() |
await context.clearCookies() |
Cypress
describe('Homepage', () => {
beforeEach(() => {
cy.visit('/');
});
it('displays the welcome heading', () => {
cy.get('h1').should('have.text', 'Welcome to Acme');
cy.get('[data-testid="hero-subtitle"]').should('be.visible');
cy.url().should('include', '/');
});
it('navigates to the about page', () => {
cy.contains('About').click();
cy.url().should('include', '/about');
cy.get('h1').should('have.text', 'About Us');
});
});Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test.describe('Homepage', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('displays the welcome heading', async ({ page }) => {
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Welcome to Acme');
await expect(page.getByTestId('hero-subtitle')).toBeVisible();
await expect(page).toHaveURL('/');
});
test('navigates to the about page', async ({ page }) => {
await page.getByRole('link', { name: 'About' }).click();
await expect(page).toHaveURL(/\/about/);
await expect(page.getByRole('heading', { level: 1 })).toHaveText('About Us');
});
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test.describe('Homepage', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('displays the welcome heading', async ({ page }) => {
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Welcome to Acme');
await expect(page.getByTestId('hero-subtitle')).toBeVisible();
await expect(page).toHaveURL('/');
});
test('navigates to the about page', async ({ page }) => {
await page.getByRole('link', { name: 'About' }).click();
await expect(page).toHaveURL(/\/about/);
await expect(page.getByRole('heading', { level: 1 })).toHaveText('About Us');
});
});Key changes: cy.get('h1') becomes page.getByRole('heading', { level: 1 }) for resilience. cy.contains('About').click() becomes page.getByRole('link', { name: 'About' }).click() to assert the element is actually a link. cy.url().should('include', ...) becomes await expect(page).toHaveURL(...) which auto-retries.
Cypress
describe('Registration Form', () => {
it('submits a valid form', () => {
cy.visit('/register');
cy.get('#first-name').type('Jane');
cy.get('#last-name').type('Doe');
cy.get('#email').type('jane@example.com');
cy.get('#password').type('s3cure!Pass');
cy.get('#country').select('United States');
cy.get('#terms').check();
cy.get('form').submit();
cy.get('.success-message').should('be.visible');
cy.get('.success-message').should('contain', 'Welcome, Jane');
});
it('shows validation errors for empty fields', () => {
cy.visit('/register');
cy.get('[type="submit"]').click();
cy.get('.error').should('have.length', 4);
cy.get('#first-name').should('have.attr', 'aria-invalid', 'true');
});
});Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test.describe('Registration Form', () => {
test('submits a valid form', async ({ page }) => {
await page.goto('/register');
await page.getByLabel('First name').fill('Jane');
await page.getByLabel('Last name').fill('Doe');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password').fill('s3cure!Pass');
await page.getByLabel('Country').selectOption('United States');
await page.getByLabel('I agree to the terms').check();
await page.getByRole('button', { name: 'Register' }).click();
const successMessage = page.getByText('Welcome, Jane');
await expect(successMessage).toBeVisible();
});
test('shows validation errors for empty fields', async ({ page }) => {
await page.goto('/register');
await page.getByRole('button', { name: 'Register' }).click();
await expect(page.getByRole('alert')).toHaveCount(4);
await expect(page.getByLabel('First name')).toHaveAttribute('aria-invalid', 'true');
});
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test.describe('Registration Form', () => {
test('submits a valid form', async ({ page }) => {
await page.goto('/register');
await page.getByLabel('First name').fill('Jane');
await page.getByLabel('Last name').fill('Doe');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password').fill('s3cure!Pass');
await page.getByLabel('Country').selectOption('United States');
await page.getByLabel('I agree to the terms').check();
await page.getByRole('button', { name: 'Register' }).click();
const successMessage = page.getByText('Welcome, Jane');
await expect(successMessage).toBeVisible();
});
test('shows validation errors for empty fields', async ({ page }) => {
await page.goto('/register');
await page.getByRole('button', { name: 'Register' }).click();
await expect(page.getByRole('alert')).toHaveCount(4);
await expect(page.getByLabel('First name')).toHaveAttribute('aria-invalid', 'true');
});
});Key changes: cy.get('#id').type() becomes page.getByLabel('Label').fill(). No IDs needed -- labels are resilient and accessible. fill() replaces type() and sets the value instantly. cy.get('form').submit() becomes an explicit button click, matching real user behavior.
Cypress
describe('Product List', () => {
it('displays products from the API', () => {
cy.intercept('GET', '/api/products', {
statusCode: 200,
body: [
{ id: 1, name: 'Widget', price: 9.99 },
{ id: 2, name: 'Gadget', price: 24.99 },
],
}).as('getProducts');
cy.visit('/products');
cy.wait('@getProducts');
cy.get('[data-testid="product-card"]').should('have.length', 2);
cy.get('[data-testid="product-card"]').first().should('contain', 'Widget');
});
it('shows error state when API fails', () => {
cy.intercept('GET', '/api/products', {
statusCode: 500,
body: { error: 'Internal server error' },
}).as('getProducts');
cy.visit('/products');
cy.wait('@getProducts');
cy.get('[data-testid="error-message"]').should('contain', 'Something went wrong');
});
it('submits a new product', () => {
cy.intercept('POST', '/api/products', {
statusCode: 201,
body: { id: 3, name: 'Doohickey', price: 14.99 },
}).as('createProduct');
cy.visit('/products/new');
cy.get('#product-name').type('Doohickey');
cy.get('#product-price').type('14.99');
cy.get('button[type="submit"]').click();
cy.wait('@createProduct').its('request.body').should('deep.equal', {
name: 'Doohickey',
price: 14.99,
});
});
});Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test.describe('Product List', () => {
test('displays products from the API', async ({ page }) => {
await page.route('**/api/products', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Widget', price: 9.99 },
{ id: 2, name: 'Gadget', price: 24.99 },
]),
})
);
await page.goto('/products');
await expect(page.getByTestId('product-card')).toHaveCount(2);
await expect(page.getByTestId('product-card').first()).toContainText('Widget');
});
test('shows error state when API fails', async ({ page }) => {
await page.route('**/api/products', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
})
);
await page.goto('/products');
await expect(page.getByTestId('error-message')).toContainText('Something went wrong');
});
test('submits a new product', async ({ page }) => {
// Set up route mock AND capture the request
let requestBody: unknown;
await page.route('**/api/products', (route) => {
requestBody = route.request().postDataJSON();
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 3, name: 'Doohickey', price: 14.99 }),
});
});
await page.goto('/products/new');
await page.getByLabel('Product name').fill('Doohickey');
await page.getByLabel('Price').fill('14.99');
await page.getByRole('button', { name: 'Create product' }).click();
// Assert on the captured request body
expect(requestBody).toEqual({ name: 'Doohickey', price: 14.99 });
});
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test.describe('Product List', () => {
test('displays products from the API', async ({ page }) => {
await page.route('**/api/products', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Widget', price: 9.99 },
{ id: 2, name: 'Gadget', price: 24.99 },
]),
})
);
await page.goto('/products');
await expect(page.getByTestId('product-card')).toHaveCount(2);
await expect(page.getByTestId('product-card').first()).toContainText('Widget');
});
test('shows error state when API fails', async ({ page }) => {
await page.route('**/api/products', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
})
);
await page.goto('/products');
await expect(page.getByTestId('error-message')).toContainText('Something went wrong');
});
test('submits a new product', async ({ page }) => {
let requestBody;
await page.route('**/api/products', (route) => {
requestBody = route.request().postDataJSON();
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 3, name: 'Doohickey', price: 14.99 }),
});
});
await page.goto('/products/new');
await page.getByLabel('Product name').fill('Doohickey');
await page.getByLabel('Price').fill('14.99');
await page.getByRole('button', { name: 'Create product' }).click();
expect(requestBody).toEqual({ name: 'Doohickey', price: 14.99 });
});
});Key changes: cy.intercept() becomes page.route(). Set up routes before the action that triggers the request. No cy.wait('@alias') needed -- Playwright auto-waits for the UI to update. To assert on request bodies, capture them in the route handler. For waiting on a specific response, use page.waitForResponse().
Cypress
// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.session([email, password], () => {
cy.visit('/login');
cy.get('#email').type(email);
cy.get('#password').type(password);
cy.get('button[type="submit"]').click();
cy.url().should('include', '/dashboard');
});
});
// cypress/e2e/dashboard.cy.js
describe('Dashboard', () => {
beforeEach(() => {
cy.login('admin@example.com', 'password123');
cy.visit('/dashboard');
});
it('shows admin content', () => {
cy.get('[data-testid="admin-panel"]').should('be.visible');
});
});Playwright (TypeScript) -- using storageState for session reuse (recommended)
// auth.setup.ts — runs once, saves auth state to a file
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.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Save signed-in state to file
await page.context().storageState({ path: authFile });
});// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
// Setup project — runs auth first
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
// All tests reuse the saved auth state
{
name: 'chromium',
dependencies: ['setup'],
use: {
storageState: '.auth/user.json',
},
},
],
});// dashboard.spec.ts — already authenticated, no login code needed
import { test, expect } from '@playwright/test';
test.describe('Dashboard', () => {
test('shows admin content', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByTestId('admin-panel')).toBeVisible();
});
});Playwright (JavaScript) -- using storageState for session reuse (recommended)
// auth.setup.js
const { test: setup, expect } = require('@playwright/test');
const path = require('path');
const authFile = path.join(__dirname, '../.auth/user.json');
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: authFile });
});// playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.js/ },
{
name: 'chromium',
dependencies: ['setup'],
use: {
storageState: '.auth/user.json',
},
},
],
});// dashboard.spec.js
const { test, expect } = require('@playwright/test');
test.describe('Dashboard', () => {
test('shows admin content', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByTestId('admin-panel')).toBeVisible();
});
});Key changes: Cypress cy.session() becomes Playwright's storageState. Auth runs once as a setup project, saves cookies/localStorage to a JSON file, and all test projects reuse it. Tests never touch login flows -- they start authenticated. Add .auth/ to .gitignore.
Cypress -- custom commands are globally registered
// cypress/support/commands.js
Cypress.Commands.add('createTodo', (text) => {
cy.get('[data-testid="new-todo"]').type(`${text}{enter}`);
});
Cypress.Commands.add('getTodos', () => {
return cy.get('[data-testid="todo-item"]');
});
Cypress.Commands.add('apiCreateUser', (userData) => {
return cy.request('POST', '/api/users', userData);
});
// cypress/e2e/todos.cy.js
describe('Todos', () => {
beforeEach(() => {
cy.visit('/todos');
});
it('adds and verifies todos', () => {
cy.createTodo('Buy milk');
cy.createTodo('Walk dog');
cy.getTodos().should('have.length', 2);
});
});Playwright (TypeScript) -- fixtures replace custom commands
// fixtures.ts
import { test as base, expect } from '@playwright/test';
// Define the types for your custom fixtures
type TodoFixtures = {
todosPage: TodosPage;
apiHelper: ApiHelper;
};
class TodosPage {
constructor(private page: import('@playwright/test').Page) {}
async createTodo(text: string) {
await this.page.getByTestId('new-todo').fill(text);
await this.page.getByTestId('new-todo').press('Enter');
}
get todos() {
return this.page.getByTestId('todo-item');
}
}
class ApiHelper {
constructor(private request: import('@playwright/test').APIRequestContext) {}
async createUser(userData: { name: string; email: string }) {
const response = await this.request.post('/api/users', { data: userData });
return response.json();
}
}
export const test = base.extend<TodoFixtures>({
todosPage: async ({ page }, use) => {
await page.goto('/todos');
await use(new TodosPage(page));
},
apiHelper: async ({ request }, use) => {
await use(new ApiHelper(request));
},
});
export { expect };// todos.spec.ts
import { test, expect } from './fixtures';
test('adds and verifies todos', async ({ todosPage }) => {
await todosPage.createTodo('Buy milk');
await todosPage.createTodo('Walk dog');
await expect(todosPage.todos).toHaveCount(2);
});
test('creates a user via API then verifies in UI', async ({ todosPage, apiHelper, page }) => {
const user = await apiHelper.createUser({ name: 'Jane', email: 'jane@test.com' });
await page.goto(`/users/${user.id}`);
await expect(page.getByRole('heading')).toHaveText('Jane');
});Playwright (JavaScript) -- fixtures replace custom commands
// fixtures.js
const { test: base, expect } = require('@playwright/test');
class TodosPage {
constructor(page) {
this.page = page;
}
async createTodo(text) {
await this.page.getByTestId('new-todo').fill(text);
await this.page.getByTestId('new-todo').press('Enter');
}
get todos() {
return this.page.getByTestId('todo-item');
}
}
class ApiHelper {
constructor(request) {
this.request = request;
}
async createUser(userData) {
const response = await this.request.post('/api/users', { data: userData });
return response.json();
}
}
const test = base.extend({
todosPage: async ({ page }, use) => {
await page.goto('/todos');
await use(new TodosPage(page));
},
apiHelper: async ({ request }, use) => {
await use(new ApiHelper(request));
},
});
module.exports = { test, expect };// todos.spec.js
const { test, expect } = require('./fixtures');
test('adds and verifies todos', async ({ todosPage }) => {
await todosPage.createTodo('Buy milk');
await todosPage.createTodo('Walk dog');
await expect(todosPage.todos).toHaveCount(2);
});Key changes: Every Cypress custom command maps to either a method on a page object (for UI interactions) or a helper class (for API calls), exposed via test.extend() fixtures. Fixtures provide dependency injection, type safety, and guaranteed teardown. Tests declare what they need by name in the function signature.
A battle-tested process for migrating an existing Cypress test suite to Playwright.
Do not remove Cypress yet. Run both frameworks in parallel during migration.
npm init playwright@latest
# Accept defaults: TypeScript (or JavaScript), tests folder, GitHub Actions CI, install browsersThis creates playwright.config.ts, a tests/ folder, and installs browsers.
Map your cypress.config.js settings to playwright.config.ts:
TypeScript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests', // Your new Playwright test directory
timeout: 30_000, // Cypress default is 4s per command; Playwright uses 30s per test
expect: { timeout: 5_000 }, // Assertion auto-retry timeout (like Cypress defaultCommandTimeout)
fullyParallel: true, // Cypress runs serially by default; Playwright parallelizes
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'html' : 'list',
use: {
baseURL: 'http://localhost:3000', // Matches Cypress baseUrl
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
// Add more browsers — something Cypress cannot do:
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
// Equivalent to Cypress's devServer config
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});JavaScript
// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'html' : 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});Before migrating individual tests, convert your cypress/support/commands.js into Playwright fixtures. This gives every migrated test immediate access to the same helpers.
- Identify each custom command in
cypress/support/commands.jsandcypress/support/e2e.js. - Group UI commands into page object classes.
- Group API commands into API helper classes.
- Expose them via
test.extend()in a sharedfixtures.ts(orfixtures.js). - See Example 5 above for the complete pattern.
Convert cy.session() or login-in-beforeEach patterns to Playwright's storageState:
- Create an
auth.setup.tsfile (see Example 4 above). - Add a
setupproject toplaywright.config.ts. - Add
.auth/to.gitignore. - Remove all login code from individual test files.
Start with the simplest spec files. For each file:
- Create the corresponding Playwright test file in
tests/. - Convert using the Command Mapping Table above.
- Replace CSS selectors with semantic locators (
getByRole,getByLabel,getByText). - Run the Playwright test in headed mode:
npx playwright test tests/my-test.spec.ts --headed. - Once passing, mark the Cypress spec as migrated (move to an
archived/folder or delete).
Prioritize by value: migrate your critical path tests first (auth, checkout, core CRUD).
For each intercepted route:
- Move
page.route()calls before the action that triggers the request (Playwright routes must be set up in advance). - Replace
cy.wait('@alias')with eitherpage.waitForResponse()(when you need the response) or just let auto-waiting assertions handle it (when you only care about UI updates). - For request body assertions, capture the body inside the
page.route()handler.
Cypress plugins (in cypress/plugins/ or setupNodeEvents in config) run in a separate Node.js process and communicate with test code via cy.task(). In Playwright, your test code already runs in Node.js, so:
- Database seeding: call directly from fixtures or
globalSetup. - File operations: use
fsdirectly in test code or fixtures. - Environment setup: use
globalSetup/globalTeardownin config. - Custom task logic: move into fixture setup/teardown.
Replace the Cypress CI step with Playwright:
# GitHub Actions example
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- name: Upload Playwright Report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30Once all tests are migrated and passing in CI:
npm uninstall cypress
rm -rf cypress/
rm cypress.config.js # or .ts
# Remove any Cypress-related CI config
# Remove Cypress-related entries from .eslintrc if presentThings that look similar between Cypress and Playwright but behave differently.
Cypress cy.get() immediately starts querying the DOM and retries until the chain passes. Playwright page.locator() creates a locator object instantly -- it does nothing until you call an action (.click(), .fill()) or assertion (expect(locator).toBeVisible()).
// This does NOT query the DOM — it just creates a locator reference
const button = page.getByRole('button', { name: 'Submit' });
// This is where the actual DOM query + waiting happens
await button.click();This means storing locators in variables is free and encouraged. Reuse them across multiple actions and assertions.
Cypress chains assertions onto commands: cy.get('x').should('be.visible').and('have.text', 'y'). Playwright uses separate expect() calls for each assertion:
// Cypress — chained assertions
cy.get('.card').should('be.visible').and('have.text', 'Hello').and('have.class', 'active');
// Playwright — separate expect() calls, each auto-retries independently
const card = page.locator('.card');
await expect(card).toBeVisible();
await expect(card).toHaveText('Hello');
await expect(card).toHaveClass(/active/);cy.type('text') sends keystrokes one at a time, triggering keydown, keypress, input, and keyup events for each character. Playwright's locator.fill('text') clears the field and sets the value in one shot, only triggering input and change events.
Use fill() by default. Use pressSequentially() only when character-by-character input matters (autocomplete, search-as-you-type, input masks):
// Default — fast, sets value directly
await page.getByLabel('Name').fill('Jane Doe');
// Character-by-character — only when keystroke events matter
await page.getByLabel('Search').pressSequentially('play', { delay: 100 });Cypress auto-scrolls to bring elements into view before any interaction. Playwright auto-scrolls into view only when performing actions (click, fill, check). Assertions like toBeVisible() do not scroll -- they check if the element is visible in its current position.
If you need to scroll before asserting visibility:
// Scroll into view first, then assert
await page.getByText('Footer content').scrollIntoViewIfNeeded();
await expect(page.getByText('Footer content')).toBeVisible();Cypress cy.wrap() brings a non-Cypress value into the command chain. Playwright does not need this because you are already in async/await land:
// Cypress
const value = 42;
cy.wrap(value).should('equal', 42);
// Playwright — just use the value
const value = 42;
expect(value).toBe(42);Cypress cy.intercept() can be called at any point and will match the next matching request. Playwright page.route() must be set up before the action that triggers the request. A common mistake is setting up the route after page.goto() -- by then the request may have already fired.
// WRONG — route set up too late
await page.goto('/products');
await page.route('**/api/products', (route) => route.fulfill({ body: '[]' }));
// CORRECT — route set up before navigation
await page.route('**/api/products', (route) => route.fulfill({ body: '[]' }));
await page.goto('/products');Cypress commands yield a "subject" to the next command in the chain. Playwright actions return void (or a Promise<void>). If you need a value from the browser, use evaluate, textContent, inputValue, etc.:
// Cypress — subject chaining
cy.get('#counter').invoke('text').then((text) => {
const count = parseInt(text, 10);
expect(count).to.be.greaterThan(0);
});
// Playwright — direct return values
const text = await page.locator('#counter').textContent();
const count = parseInt(text!, 10);
expect(count).toBeGreaterThan(0);
// Better: use a web-first assertion when possible
await expect(page.locator('#counter')).not.toHaveText('0');Cypress uses setupNodeEvents (or the legacy plugins/index.js) for Node.js-side operations, accessed via cy.task(). Since Playwright tests already run in Node.js, you do not need a separate plugin layer:
// Cypress — plugin pattern
// cypress.config.js
setupNodeEvents(on) {
on('task', {
seedDatabase(data) { return db.seed(data); },
});
}
// Test: cy.task('seedDatabase', testData);
// Playwright — direct call in fixture
export const test = base.extend({
seededDatabase: async ({}, use) => {
await db.seed(testData);
await use();
await db.cleanup();
},
});Cypress cy.within() scopes all subsequent commands to a container element. Playwright scopes by chaining locators:
// Cypress
cy.get('[data-testid="signup-form"]').within(() => {
cy.get('input[name="email"]').type('user@test.com');
cy.get('button').click();
});
// Playwright — chain locators for scoping
const form = page.getByTestId('signup-form');
await form.getByLabel('Email').fill('user@test.com');
await form.getByRole('button', { name: 'Sign up' }).click();Cypress clears cookies, localStorage, and sessionStorage between tests by default but shares the same browser instance. Playwright creates a fresh browser context for each test -- complete isolation of cookies, storage, cache, and service workers. This means:
- You never need to manually clear state between tests.
- You cannot "leak" state from one test to another (which can mask or cause flakiness in Cypress).
- Worker-scoped fixtures are the way to share expensive resources across tests.
Features that have no Cypress equivalent and make Playwright the stronger choice for production test suites.
Test on Chromium, Firefox, and WebKit (Safari) in a single config. No plugins, no paid dashboard.
// playwright.config.ts — three browsers, zero extra setup
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
],Test popups, OAuth flows, new tabs, and multi-window interactions:
// Handle a popup window (e.g., OAuth)
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
const popup = await popupPromise;
await popup.getByLabel('Email').fill('user@gmail.com');Test collaboration features with multiple independent browser contexts:
test('two users can collaborate', async ({ browser }) => {
const aliceContext = await browser.newContext({ storageState: 'auth/alice.json' });
const bobContext = await browser.newContext({ storageState: 'auth/bob.json' });
const alicePage = await aliceContext.newPage();
const bobPage = await bobContext.newPage();
await alicePage.goto('/doc/shared');
await bobPage.goto('/doc/shared');
await alicePage.getByRole('textbox').fill('Hello from Alice');
await expect(bobPage.getByText('Hello from Alice')).toBeVisible();
await aliceContext.close();
await bobContext.close();
});Test APIs without a browser, using the same runner and assertion library:
test('API returns user list', async ({ request }) => {
const response = await request.get('/api/users');
expect(response.ok()).toBeTruthy();
const users = await response.json();
expect(users).toHaveLength(3);
expect(users[0]).toHaveProperty('email');
});Run tests across multiple machines with zero configuration:
# Split across 4 CI machines
npx playwright test --shard=1/4 # Machine 1
npx playwright test --shard=2/4 # Machine 2
npx playwright test --shard=3/4 # Machine 3
npx playwright test --shard=4/4 # Machine 4Playwright traces capture a complete record of test execution: DOM snapshots, network requests, console logs, and action screenshots. Open with:
npx playwright show-trace trace.zipThis replaces Cypress's time-travel debugger with a more detailed, offline-capable tool.
Test React, Vue, Svelte, and Solid components in real browsers (not jsdom):
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';
test('button renders with label', async ({ mount }) => {
const component = await mount(<Button label="Click me" />);
await expect(component).toContainText('Click me');
await component.click();
});Generate test code by interacting with your app:
npx playwright codegen http://localhost:3000Records your clicks, fills, and assertions, and outputs Playwright test code. Use it as a starting point, then refine locators to use getByRole.
Full control over Date, setTimeout, setInterval, and requestAnimationFrame:
test('shows expired badge after timeout', async ({ page }) => {
await page.clock.install({ time: new Date('2024-01-01T00:00:00Z') });
await page.goto('/offers');
await page.clock.fastForward('24:00:00');
await expect(page.getByText('Offer expired')).toBeVisible();
});Record and replay network traffic from HAR files for deterministic tests:
// Record all network traffic
await page.routeFromHAR('tests/fixtures/products.har', {
url: '**/api/**',
update: true, // Set to true once to record, then false to replay
});- core/locators.md -- locator strategy for Playwright (replaces Cypress
cy.get()patterns) - core/fixtures-and-hooks.md -- deep dive on fixtures (replaces Cypress custom commands)
- core/assertions-and-waiting.md -- web-first assertions (replaces Cypress
shouldchains) - core/configuration.md -- playwright.config setup
- core/authentication.md -- auth patterns (replaces Cypress
cy.session()) - core/network-mocking.md -- route mocking (replaces Cypress
cy.intercept()) - ci/ci-github-actions.md -- CI setup for Playwright