
Playwright Migration
- 77 installs
- 343 repo stars
- Updated July 2, 2026
- testdino-hq/playwright-skill
Helps with ai & agent building tasks during AI-assisted development.
About
playwright-migration is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- playwright-migration
- AI & Agent Building
- AI-coding skill
Playwright Migration by the numbers
- 77 all-time installs (skills.sh)
- +3 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #5,386 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/testdino-hq/playwright-skill --skill playwright-migrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 77 |
|---|---|
| repo stars | ★ 343 |
| Last updated | July 2, 2026 |
| Repository | testdino-hq/playwright-skill ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Playwright Migration Guides
Move to Playwright with confidence — side-by-side command mappings, architecture translations, and incremental migration strategies.
2 guides covering complete migration paths from the most popular testing frameworks.
Guide Index
| Migrating from | Guide |
|---|---|
| Cypress | from-cypress.md |
| Selenium / WebDriver | from-selenium.md |
Migrating from Cypress to Playwright
When to use: When converting a Cypress test suite to Playwright. Follow this guide command-by-command, pattern-by-pattern.
Key Mindset Shifts
Before converting any code, internalize these five differences. They affect every line you write.
1. Chains vs async/await
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.
2. Automatic retry vs auto-wait
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();3. In-browser vs Node.js
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.
4. One tab vs multi-tab, multi-browser
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.
5. Custom commands vs fixtures
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.
Command Mapping Table
| 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() |
Before/After Examples
Example 1: Basic Navigation and Assertion
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.
---
Example 2: Form Interaction
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.
---
Example 3: Network Mocking
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().
---
Example 4: Authentication
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.
---
Example 5: Custom Commands to Fixtures
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.
Migration Steps
A battle-tested process for migrating an existing Cypress test suite to Playwright.
Step 1: Install Playwright alongside Cypress
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.
Step 2: Configure playwright.config to match your Cypress setup
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,
},
});Step 3: Convert custom commands to fixtures
Before migrating individual tests, convert your cypress/support/commands.js into Playwright fixtures. This gives every migrated test immediate access to the same helpers.
1. Identify each custom command in cypress/support/commands.js and cypress/support/e2e.js. 2. Group UI commands into page object classes. 3. Group API commands into API helper classes. 4. Expose them via test.extend() in a shared fixtures.ts (or fixtures.js). 5. See Example 5 above for the complete pattern.
Step 4: Set up authentication
Convert cy.session() or login-in-beforeEach patterns to Playwright's storageState:
1. Create an auth.setup.ts file (see Example 4 above). 2. Add a setup project to playwright.config.ts. 3. Add .auth/ to .gitignore. 4. Remove all login code from individual test files.
Step 5: Migrate tests file by file
Start with the simplest spec files. For each file:
1. Create the corresponding Playwright test file in tests/. 2. Convert using the Command Mapping Table above. 3. Replace CSS selectors with semantic locators (getByRole, getByLabel, getByText). 4. Run the Playwright test in headed mode: npx playwright test tests/my-test.spec.ts --headed. 5. 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).
Step 6: Convert cy.intercept patterns to page.route
For each intercepted route:
1. Move page.route() calls _before_ the action that triggers the request (Playwright routes must be set up in advance). 2. Replace cy.wait('@alias') with either page.waitForResponse() (when you need the response) or just let auto-waiting assertions handle it (when you only care about UI updates). 3. For request body assertions, capture the body inside the page.route() handler.
Step 7: Convert Cypress plugins to Node.js code
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.
Step 8: Update CI pipeline
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: 30Step 9: Remove Cypress
Once 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 presentCommon Gotchas
Things that look similar between Cypress and Playwright but behave differently.
1. Locators are lazy, not retrying
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.
2. Assertions are separate from locators
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/);3. fill() vs type() — the speed difference
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 });4. Auto-scroll behavior
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();5. No implicit cy.wrap() equivalent
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);6. Route setup timing
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');7. Cypress subject vs Playwright return values
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');8. Cypress plugins vs Playwright global setup
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();
},
});9. cy.within() vs locator scoping
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();10. Test isolation differences
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.
What's Better in Playwright
Features that have no Cypress equivalent and make Playwright the stronger choice for production test suites.
Multi-browser testing out of the box
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'] } },
],Multi-tab and multi-window testing
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');Multi-user testing in a single test
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();
});Native API testing
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');
});Parallel execution with sharding
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 4Trace viewer
Playwright 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.
Component testing with framework support
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();
});Built-in code generation
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.
Clock control
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();
});Network request interception with HAR files
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
});Related
- 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
Migrating from Selenium to Playwright
When to use: When converting a Selenium/WebDriver test suite to Playwright. Covers Java/Python/C# Selenium idioms mapped to TypeScript and JavaScript Playwright equivalents.
Prerequisites: core/locators.md, core/assertions-and-waiting.md
Key Mindset Shifts
Before translating any code, internalize these six changes. They are not syntax swaps -- they are fundamental differences in how Playwright works.
1. No More Explicit Waits
Selenium requires WebDriverWait and ExpectedConditions on nearly every interaction. Playwright auto-waits on every action (click, fill, check, selectOption) and every web-first assertion (expect(locator).toBeVisible()). Delete all your waits. They are not needed.
Selenium: WebDriverWait + ExpectedConditions.visibilityOfElementLocated(...)
Playwright: nothing — auto-waiting is built into every action and assertion2. No WebDriver Protocol Overhead
Selenium sends every command over the WebDriver (W3C) HTTP protocol -- each click, each find, each assertion is a round-trip HTTP request. Playwright communicates over CDP (Chromium), the DevTools protocol (Firefox), or native WebKit protocol. This is a persistent bidirectional connection, not request-response. Tests are faster by default.
3. No Driver Management
Selenium requires matching browser drivers (chromedriver, geckodriver) to browser versions. Version mismatches are a constant source of CI failures. Playwright bundles browser binaries. One command installs everything:
npx playwright installNo WebDriverManager. No chromedriver path. No version matrix.
4. No Implicit vs Explicit Wait Confusion
Selenium has implicit waits (global, apply to all findElement calls), explicit waits (WebDriverWait), and Thread.sleep() / time.sleep(). Teams mix these, creating unpredictable timing behavior. Playwright has one mechanism: auto-waiting. Every action waits for the element to be actionable. Every web-first assertion retries until timeout. There is nothing to configure.
5. Locators Are Lazy and Auto-Retry
In Selenium, findElement() immediately queries the DOM and returns a WebElement reference. If the DOM changes, that reference is stale and throws StaleElementReferenceException. In Playwright, page.locator() returns a lazy locator that re-queries the DOM on every action. There is no stale element. There is no StaleElementReferenceException. Ever.
6. Built-In Test Runner
Selenium is a browser automation library, not a test framework. You need JUnit, TestNG, pytest, or Mocha on top. Playwright Test is a full test runner with parallel execution, retries, fixtures, HTML reports, trace viewer, and UI mode. No assembly required.
API Mapping Table
Every Selenium API and its direct Playwright equivalent. The "Notes" column calls out behavior differences.
Navigation
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
driver.get(url) | await page.goto(url) | Playwright waits for load event by default; configurable via waitUntil |
driver.navigate().to(url) | await page.goto(url) | Same as above |
driver.navigate().back() | await page.goBack() | Waits for navigation to complete |
driver.navigate().forward() | await page.goForward() | Waits for navigation to complete |
driver.navigate().refresh() | await page.reload() | Waits for load event |
driver.getCurrentUrl() | page.url() | Synchronous in Playwright -- no await needed |
driver.getTitle() | await page.title() | Or use await expect(page).toHaveTitle('...') for assertion |
Element Location
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
driver.findElement(By.id("x")) | page.locator('#x') or page.getByTestId('x') | Prefer getByRole() or getByTestId() over ID selectors |
driver.findElement(By.css("x")) | page.locator('x') | CSS selectors work, but prefer semantic locators |
driver.findElement(By.xpath("x")) | page.locator('xpath=x') | Works but avoid XPath; use getByRole(), getByLabel(), getByText() |
driver.findElement(By.name("x")) | page.locator('[name="x"]') | Or page.getByLabel() if the field has a label |
driver.findElement(By.linkText("x")) | page.getByRole('link', { name: 'x' }) | Role-based is more resilient |
driver.findElement(By.partialLinkText("x")) | page.getByRole('link', { name: /x/ }) | Regex for partial match |
driver.findElement(By.className("x")) | page.locator('.x') | Class selectors are fragile; prefer semantic locators |
driver.findElement(By.tagName("x")) | page.locator('x') | Rarely useful alone; combine with role or text |
driver.findElements(By.css("x")) | await page.locator('x').all() | Returns array of locators; or use toHaveCount() to assert count |
Element Interaction
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
element.click() | await locator.click() | Auto-waits for element to be visible, stable, enabled, and unobscured |
element.sendKeys("text") | await locator.fill("text") | Behavior change: fill() clears existing text first, then sets the value. Use pressSequentially() to type character by character. |
element.sendKeys(Keys.ENTER) | await locator.press('Enter') | Or await page.keyboard.press('Enter') |
element.clear() | await locator.clear() | Or await locator.fill('') |
element.submit() | await locator.press('Enter') | No direct equivalent; click the submit button or press Enter |
new Select(element).selectByVisibleText("x") | await locator.selectOption({ label: 'x' }) | Also supports { value: 'x' } and { index: 0 } |
element.isDisplayed() | await expect(locator).toBeVisible() | Use assertion form -- it auto-retries. locator.isVisible() does not retry. |
element.isEnabled() | await expect(locator).toBeEnabled() | Use assertion form for reliability |
element.isSelected() | await expect(locator).toBeChecked() | For checkboxes and radio buttons |
element.getText() | await locator.textContent() | Or prefer await expect(locator).toHaveText('...') which auto-retries |
element.getAttribute("x") | await locator.getAttribute('x') | Or await expect(locator).toHaveAttribute('x', 'value') |
element.getCssValue("x") | await expect(locator).toHaveCSS('x', 'value') | Use assertion form; computed values only |
Waits and Conditions
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
WebDriverWait(driver, 10).until(...) | Not needed | Playwright auto-waits on all actions and assertions |
ExpectedConditions.visibilityOfElementLocated(...) | await expect(locator).toBeVisible() | Built into every action; use assertion only when you need to verify visibility explicitly |
ExpectedConditions.elementToBeClickable(...) | Not needed | click() auto-waits for clickability |
ExpectedConditions.presenceOfElementLocated(...) | await expect(locator).toBeAttached() | Rarely needed; most actions wait for attachment automatically |
ExpectedConditions.invisibilityOfElementLocated(...) | await expect(locator).not.toBeVisible() | Auto-retries until element disappears |
ExpectedConditions.textToBePresentInElement(...) | await expect(locator).toHaveText('...') | Auto-retries until text matches |
ExpectedConditions.titleIs("x") | await expect(page).toHaveTitle('x') | Auto-retries |
ExpectedConditions.urlContains("x") | await expect(page).toHaveURL(/x/) | Or await page.waitForURL('**/x') |
ExpectedConditions.alertIsPresent() | page.on('dialog', ...) or page.waitForEvent('dialog') | Register handler before the action that triggers the dialog |
Thread.sleep(5000) / time.sleep(5) | Never | Delete it. Use auto-waiting assertions instead. |
driver.manage().timeouts().implicitlyWait(10) | Not needed | No implicit waits in Playwright -- auto-waiting handles everything |
Frames and Windows
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
driver.switchTo().frame("name") | page.frameLocator('iframe[name="name"]') | No context switching; chain locators directly into the frame |
driver.switchTo().frame(element) | page.frameLocator('iframe#id') | Target the iframe by any CSS selector |
driver.switchTo().defaultContent() | Not needed | No frame switching in Playwright; each frameLocator is scoped |
driver.switchTo().parentFrame() | Not needed | No frame switching to undo |
driver.switchTo().window(handle) | context.pages() | Access all pages in the context by index |
driver.getWindowHandle() | Not needed | Use page references directly |
driver.getWindowHandles() | context.pages() | Returns array of all open pages |
| New window/tab opened by click | page.waitForEvent('popup') | Register before the click; returns the new Page object |
Browser and Context
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
driver.manage().window().setSize(w, h) | await page.setViewportSize({ width: w, height: h }) | Sets the viewport, not the OS window |
driver.manage().window().maximize() | Configure in playwright.config use.viewport | Or pass --headed with large viewport |
driver.manage().addCookie(cookie) | await context.addCookies([cookie]) | Takes an array; operates on the browser context |
driver.manage().getCookieNamed("x") | await context.cookies() then filter | Returns all cookies; filter in JS |
driver.manage().deleteAllCookies() | await context.clearCookies() | Clears all cookies in the context |
driver.executeScript("return ...") | await page.evaluate(() => { ... }) | Full access to browser JS context; supports return values |
driver.executeAsyncScript(...) | await page.evaluate(async () => { ... }) | evaluate supports async functions natively |
driver.getScreenshotAs(OutputType.FILE) | await page.screenshot({ path: 'shot.png' }) | Also supports fullPage: true, element screenshots via locator.screenshot() |
driver.quit() | Handled automatically | Playwright Test manages browser lifecycle. No manual cleanup. |
Actions Class
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
new Actions(driver).moveToElement(el).perform() | await locator.hover() | Single method, auto-waits |
new Actions(driver).doubleClick(el).perform() | await locator.dblclick() | Single method, auto-waits |
new Actions(driver).contextClick(el).perform() | await locator.click({ button: 'right' }) | Right-click option |
new Actions(driver).dragAndDrop(src, tgt).perform() | await source.dragTo(target) | Both are locators |
new Actions(driver).keyDown(Keys.SHIFT).click(el).keyUp(Keys.SHIFT).perform() | await locator.click({ modifiers: ['Shift'] }) | Modifier keys as option |
new Actions(driver).sendKeys(Keys.chord(Keys.CONTROL, "a")).perform() | await page.keyboard.press('Control+a') | Keyboard API for global shortcuts |
new Actions(driver).moveByOffset(x, y).perform() | await page.mouse.move(x, y) | Raw mouse API for canvas/map interactions |
Before/After Examples
Example 1: Login Test
The most common Selenium test. Notice the complete absence of explicit waits in the Playwright version.
Selenium (Java)
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.junit.jupiter.api.*;
public class LoginTest {
WebDriver driver;
@BeforeEach
void setUp() {
System.setProperty("webdriver.chrome.driver", "/path/to/chromedriver");
driver = new ChromeDriver();
driver.manage().window().maximize();
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
}
@AfterEach
void tearDown() {
if (driver != null) driver.quit();
}
@Test
void userCanLogIn() {
driver.get("https://myapp.com/login");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement emailField = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.id("email"))
);
emailField.clear();
emailField.sendKeys("user@example.com");
WebElement passwordField = driver.findElement(By.id("password"));
passwordField.clear();
passwordField.sendKeys("s3cure!Pass");
WebElement loginButton = wait.until(
ExpectedConditions.elementToBeClickable(By.cssSelector("button[type='submit']"))
);
loginButton.click();
wait.until(ExpectedConditions.urlContains("/dashboard"));
WebElement heading = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.tagName("h1"))
);
Assertions.assertEquals("Dashboard", heading.getText());
}
}Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('s3cure!Pass');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Dashboard');
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('s3cure!Pass');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Dashboard');
});What changed: 45 lines of Java with explicit waits, driver management, and element references became 9 lines of Playwright. No setup, no teardown, no waits, no driver path. The fill() call replaces clear() + sendKeys(). Semantic locators (getByLabel, getByRole) replace brittle By.id and By.cssSelector.
---
Example 2: Search and Verify Results
Demonstrates replacing findElements(), explicit waits for result count, and text assertions.
Selenium (Java)
@Test
void searchReturnsResults() {
driver.get("https://myapp.com/products");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement searchBox = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.cssSelector("input[placeholder='Search products']"))
);
searchBox.clear();
searchBox.sendKeys("wireless headphones");
searchBox.sendKeys(Keys.ENTER);
wait.until(ExpectedConditions.numberOfElementsToBeMoreThan(
By.cssSelector(".product-card"), 0
));
List<WebElement> results = driver.findElements(By.cssSelector(".product-card"));
Assertions.assertTrue(results.size() >= 3);
String firstTitle = results.get(0).findElement(By.cssSelector(".product-title")).getText();
Assertions.assertTrue(firstTitle.toLowerCase().contains("wireless"));
}Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test('search returns results', async ({ page }) => {
await page.goto('/products');
await page.getByPlaceholder('Search products').fill('wireless headphones');
await page.getByPlaceholder('Search products').press('Enter');
const results = page.getByTestId('product-card');
await expect(results).toHaveCount(3, { timeout: 10_000 });
await expect(results.first().getByTestId('product-title')).toContainText(/wireless/i);
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test('search returns results', async ({ page }) => {
await page.goto('/products');
await page.getByPlaceholder('Search products').fill('wireless headphones');
await page.getByPlaceholder('Search products').press('Enter');
const results = page.getByTestId('product-card');
await expect(results).toHaveCount(3, { timeout: 10_000 });
await expect(results.first().getByTestId('product-title')).toContainText(/wireless/i);
});What changed: No WebDriverWait for element visibility. No clear() before sendKeys(). No findElements() returning a stale list. The Playwright toHaveCount() auto-retries until the results appear. The regex assertion on toContainText replaces manual getText() + toLowerCase() + contains().
---
Example 3: Working with Iframes
Selenium's frame switching is stateful and error-prone. Playwright's frameLocator is scoped and stateless.
Selenium (Java)
@Test
void fillPaymentFormInIframe() {
driver.get("https://myapp.com/checkout");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// Switch into the payment iframe
wait.until(ExpectedConditions.frameToBeAvailableAndSwitchToIt(
By.cssSelector("iframe#payment-frame")
));
// Now inside the iframe context
WebElement cardNumber = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.id("card-number"))
);
cardNumber.sendKeys("4242424242424242");
driver.findElement(By.id("expiry")).sendKeys("12/28");
driver.findElement(By.id("cvc")).sendKeys("123");
// Switch back to main content before interacting with the page
driver.switchTo().defaultContent();
driver.findElement(By.id("place-order")).click();
wait.until(ExpectedConditions.visibilityOfElementLocated(
By.cssSelector(".confirmation-message")
));
}Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test('fill payment form in iframe', async ({ page }) => {
await page.goto('/checkout');
// No switching — just scope into the frame
const paymentFrame = page.frameLocator('#payment-frame');
await paymentFrame.getByLabel('Card number').fill('4242424242424242');
await paymentFrame.getByLabel('Expiry').fill('12/28');
await paymentFrame.getByLabel('CVC').fill('123');
// No switching back — main page locators still work
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test('fill payment form in iframe', async ({ page }) => {
await page.goto('/checkout');
const paymentFrame = page.frameLocator('#payment-frame');
await paymentFrame.getByLabel('Card number').fill('4242424242424242');
await paymentFrame.getByLabel('Expiry').fill('12/28');
await paymentFrame.getByLabel('CVC').fill('123');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});What changed: No switchTo().frame(). No switchTo().defaultContent(). No risk of forgetting to switch back. Playwright's frameLocator scopes into the iframe without changing the driver's global state. You can interact with the main page and the iframe in any order.
---
Example 4: Handling Popups and New Windows
Selenium window handle management is notoriously fragile. Playwright makes it declarative.
Selenium (Java)
@Test
void handlePopupWindow() {
driver.get("https://myapp.com/settings");
String originalWindow = driver.getWindowHandle();
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
driver.findElement(By.linkText("Connect OAuth Provider")).click();
// Wait for new window to appear
wait.until(ExpectedConditions.numberOfWindowsToBe(2));
// Find and switch to the new window
for (String handle : driver.getWindowHandles()) {
if (!handle.equals(originalWindow)) {
driver.switchTo().window(handle);
break;
}
}
// Interact with the popup
wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("authorize-btn")));
driver.findElement(By.id("authorize-btn")).click();
// Wait for popup to close and switch back
wait.until(ExpectedConditions.numberOfWindowsToBe(1));
driver.switchTo().window(originalWindow);
wait.until(ExpectedConditions.visibilityOfElementLocated(
By.cssSelector(".connection-status.success")
));
}Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test('handle popup window', async ({ page }) => {
await page.goto('/settings');
// Register listener BEFORE the click that opens the popup
const popupPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Connect OAuth Provider' }).click();
const popup = await popupPromise;
// Interact with the popup — it's just another Page object
await popup.getByRole('button', { name: 'Authorize' }).click();
// Popup closes automatically; verify result on original page
await expect(page.getByText('Connected successfully')).toBeVisible();
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test('handle popup window', async ({ page }) => {
await page.goto('/settings');
const popupPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Connect OAuth Provider' }).click();
const popup = await popupPromise;
await popup.getByRole('button', { name: 'Authorize' }).click();
await expect(page.getByText('Connected successfully')).toBeVisible();
});What changed: No window handle iteration. No switchTo(). No tracking original vs new window. The popup is a Page object you interact with directly. When it closes, you just continue using the original page. The waitForEvent('popup') pattern is declarative and race-condition-free.
---
Example 5: Drag and Drop with Actions
Selenium's Actions class requires chaining and perform(). Playwright has direct methods.
Selenium (Java)
@Test
void dragAndDropTask() {
driver.get("https://myapp.com/kanban");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement sourceCard = wait.until(
ExpectedConditions.visibilityOfElementLocated(
By.xpath("//div[@class='card' and contains(text(),'Fix login bug')]")
)
);
WebElement targetColumn = driver.findElement(
By.xpath("//div[@class='column' and .//h2[text()='Done']]")
);
Actions actions = new Actions(driver);
actions.dragAndDrop(sourceCard, targetColumn).perform();
// Verify the card moved
WebElement doneColumn = driver.findElement(
By.xpath("//div[@class='column' and .//h2[text()='Done']]")
);
WebElement movedCard = doneColumn.findElement(
By.xpath(".//div[@class='card' and contains(text(),'Fix login bug')]")
);
Assertions.assertTrue(movedCard.isDisplayed());
}Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test('drag and drop task to done column', async ({ page }) => {
await page.goto('/kanban');
const card = page.getByText('Fix login bug');
const doneColumn = page.getByRole('heading', { name: 'Done' }).locator('..');
await card.dragTo(doneColumn);
// Verify card is now inside the Done column
await expect(doneColumn.getByText('Fix login bug')).toBeVisible();
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test('drag and drop task to done column', async ({ page }) => {
await page.goto('/kanban');
const card = page.getByText('Fix login bug');
const doneColumn = page.getByRole('heading', { name: 'Done' }).locator('..');
await card.dragTo(doneColumn);
await expect(doneColumn.getByText('Fix login bug')).toBeVisible();
});What changed: No Actions class. No perform(). No XPath for finding elements. dragTo() is a single method call on a locator. The verification uses scoped locators instead of XPath ancestor traversal.
Migration Steps
A practical, ordered checklist for converting a Selenium suite to Playwright.
Step 1: Set Up Playwright Alongside Selenium
Do not rip out Selenium on day one. Run both in parallel.
# Install Playwright in your existing project
npm init playwright@latest
# This creates:
# - playwright.config.ts (or .js)
# - tests/ directory
# - package.json dependenciesConfigure baseURL in playwright.config to match your Selenium target:
TypeScript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests-playwright', // separate from Selenium tests
use: {
baseURL: 'https://staging.myapp.com',
trace: 'on-first-retry',
},
retries: process.env.CI ? 2 : 0,
});JavaScript
// playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests-playwright',
use: {
baseURL: 'https://staging.myapp.com',
trace: 'on-first-retry',
},
retries: process.env.CI ? 2 : 0,
});Step 2: Convert Page Objects First
If you have Selenium page objects, convert them to Playwright page objects. The pattern is similar but simpler.
Selenium Page Object (Java)
public class LoginPage {
private WebDriver driver;
private WebDriverWait wait;
private By emailField = By.id("email");
private By passwordField = By.id("password");
private By loginButton = By.cssSelector("button[type='submit']");
private By errorMessage = By.cssSelector(".error-message");
public LoginPage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
public void login(String email, String password) {
wait.until(ExpectedConditions.visibilityOfElementLocated(emailField));
driver.findElement(emailField).clear();
driver.findElement(emailField).sendKeys(email);
driver.findElement(passwordField).clear();
driver.findElement(passwordField).sendKeys(password);
driver.findElement(loginButton).click();
}
public String getErrorMessage() {
return wait.until(
ExpectedConditions.visibilityOfElementLocated(errorMessage)
).getText();
}
}Playwright Page Object (TypeScript)
// page-objects/login-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
private readonly emailField: Locator;
private readonly passwordField: Locator;
private readonly loginButton: Locator;
private readonly errorMessage: Locator;
constructor(private readonly page: Page) {
this.emailField = page.getByLabel('Email');
this.passwordField = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Sign In' });
this.errorMessage = page.getByRole('alert');
}
async login(email: string, password: string) {
await this.emailField.fill(email);
await this.passwordField.fill(password);
await this.loginButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toHaveText(message);
}
}Playwright Page Object (JavaScript)
// page-objects/login-page.js
const { expect } = require('@playwright/test');
class LoginPage {
constructor(page) {
this.page = page;
this.emailField = page.getByLabel('Email');
this.passwordField = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Sign In' });
this.errorMessage = page.getByRole('alert');
}
async login(email, password) {
await this.emailField.fill(email);
await this.passwordField.fill(password);
await this.loginButton.click();
}
async expectError(message) {
await expect(this.errorMessage).toHaveText(message);
}
}
module.exports = { LoginPage };Key differences in page objects:
- Locators are defined in the constructor, not as
Byobjects -- they are lazy and never go stale - No
WebDriverWaitanywhere - No
clear()beforefill() - Assertions can live inside the page object (
expectError) because they auto-retry
Step 3: Convert Tests by Priority
Start with your most valuable and most flaky tests. Convert them in order:
1. Smoke tests -- highest value, run on every deploy 2. Flaky tests -- Playwright's auto-waiting eliminates most flakiness 3. Slow tests -- Playwright's parallelism and faster protocol make these faster 4. Everything else -- bulk conversion of the remaining suite
For each test, apply the API mapping table above. The mechanical translation is: 1. Remove all WebDriverWait and ExpectedConditions 2. Replace findElement(By.xxx) with semantic locators (getByRole, getByLabel, getByText) 3. Replace sendKeys with fill (or pressSequentially for character-by-character input) 4. Replace Assert / assertEquals with expect(locator).toHaveText() / toBeVisible() / etc. 5. Remove setUp and tearDown -- Playwright Test handles browser lifecycle 6. Remove Thread.sleep() / time.sleep() entirely
Step 4: Replace Test Infrastructure
| Selenium Infrastructure | Playwright Equivalent |
|---|---|
| Selenium Grid / Hub | npx playwright test --shard=1/4 (built-in sharding) |
| BrowserStack / SauceLabs | Often unnecessary; Playwright runs 3 browsers locally. Use for Safari on CI if needed. |
| WebDriverManager | npx playwright install (one command, all browsers) |
| TestNG XML suites | playwright.config projects array |
| JUnit @Tag / pytest marks | test.describe() grouping + --grep filtering |
| Allure / ExtentReports | Built-in HTML reporter (npx playwright show-report) + trace viewer |
| Screenshot on failure | Built-in: use: { screenshot: 'only-on-failure' } |
| Video recording | Built-in: use: { video: 'on-first-retry' } |
Step 5: Remove Selenium
Once all tests pass in Playwright and have run green in CI for at least two weeks:
1. Delete Selenium test files 2. Remove Selenium dependencies (selenium-webdriver, chromedriver, geckodriver) 3. Remove Selenium Grid infrastructure 4. Update CI pipelines to run only Playwright 5. Remove setUp / tearDown boilerplate classes
Common Gotchas
Gotcha 1: findElement Throws, locator() Does Not
In Selenium, driver.findElement(By.id("missing")) throws NoSuchElementException immediately. In Playwright, page.locator('#missing') returns a locator object without querying the DOM. It only throws when you perform an action on it and the element does not appear within the timeout.
// This does NOT throw — locators are lazy
const missing = page.locator('#does-not-exist');
// This throws after timeout — because the action waits and the element never appears
await missing.click(); // TimeoutError after actionTimeoutImpact: If your Selenium tests use try-catch around findElement to check element existence, replace with assertions:
// Selenium pattern (do not replicate)
// try { driver.findElement(By.id("error")); fail(); } catch (NoSuchElementException e) { /* expected */ }
// Playwright equivalent
await expect(page.locator('#error')).not.toBeVisible();Gotcha 2: sendKeys Appends, fill Replaces
Selenium's sendKeys("text") appends to the existing value. If the field already contains "hello" and you sendKeys("world"), you get "helloworld". Playwright's fill("text") clears the field first, then sets the value. You get "text".
// Selenium behavior: appends
// element.sendKeys("world"); // field: "helloworld"
// Playwright behavior: replaces
await locator.fill('world'); // field: "world"
// To replicate Selenium's append behavior:
await locator.pressSequentially('world'); // types each character, appending to existing valueGotcha 3: No StaleElementReferenceException
In Selenium, storing a WebElement reference and using it after the DOM changes throws StaleElementReferenceException. This is the single most common source of Selenium test flakiness.
// Selenium — this can throw StaleElementReferenceException
WebElement button = driver.findElement(By.id("submit"));
// ... some action causes the DOM to re-render ...
button.click(); // BOOM — StaleElementReferenceExceptionIn Playwright, locators re-query the DOM on every action. Store locators freely.
// Playwright — this always works
const button = page.getByRole('button', { name: 'Submit' });
// ... some action causes the DOM to re-render ...
await button.click(); // Works — re-queries the DOM automaticallyGotcha 4: No Global Driver State
Selenium has a single driver instance with global state: the current frame, the current window, implicit wait timeout. Calling switchTo().frame() changes state for all subsequent calls. Forgetting to switchTo().defaultContent() causes every following findElement to fail.
Playwright has no global state. page.frameLocator() returns a scoped object. context.pages() gives you all pages. Nothing changes the "current" context.
// Selenium mental model: "Where am I now?"
// driver.switchTo().frame("payment"); // I'm in the payment frame
// driver.findElement(...); // This looks in the payment frame
// driver.switchTo().defaultContent(); // Now I'm back in the main page
// ... forget this line and everything breaks
// Playwright mental model: "I always say exactly where I'm looking"
const paymentFrame = page.frameLocator('#payment');
await paymentFrame.getByLabel('Card').fill('4242...'); // scoped to frame
await page.getByRole('button', { name: 'Pay' }).click(); // main page — no switchingGotcha 5: Assertions Must Be Awaited
In Selenium (Java), assertions are synchronous: assertEquals("Dashboard", heading.getText()). In Playwright, web-first assertions are async and must be awaited:
// WRONG — assertion runs detached, test may pass before it resolves
expect(page.getByRole('heading')).toHaveText('Dashboard'); // missing await!
// CORRECT
await expect(page.getByRole('heading')).toHaveText('Dashboard');Missing await is the number one Playwright beginner mistake. Your linter should flag this. Enable @typescript-eslint/no-floating-promises or the Playwright ESLint plugin.
Gotcha 6: Parallel by Default
Selenium tests typically run sequentially (one browser, one thread). Playwright Test runs test files in parallel by default. This means:
- Tests must be isolated -- no shared state between test files
- Each test gets its own browser context (fresh cookies, storage, session)
- Database fixtures must not collide between parallel tests
If your Selenium suite depends on execution order, you must fix that before migrating. See core/test-organization.md for isolation strategies.
What's Better in Playwright
These are not just syntax differences. These are capabilities that Selenium does not have.
Auto-Waiting Everywhere
Every action, every assertion, every navigation auto-waits. You write zero wait code. This alone eliminates 60-80% of Selenium test flakiness.
Trace Viewer
When a test fails in CI, Playwright captures a trace: screenshots at every step, DOM snapshots, network requests, console logs. Open it with npx playwright show-report and step through the exact failure. Selenium has nothing comparable.
// playwright.config.ts — enable traces on first retry
export default defineConfig({
use: {
trace: 'on-first-retry',
},
});Built-In Parallel Execution
Playwright Test runs test files in parallel with zero configuration. No Selenium Grid. No TestNG parallel suite XML. No pytest-xdist.
# Run all tests in parallel (default)
npx playwright test
# Shard across CI machines
npx playwright test --shard=1/4 # machine 1
npx playwright test --shard=2/4 # machine 2Multiple Browsers, One API
The same test runs on Chromium, Firefox, and WebKit without code changes. Configure in playwright.config:
export default defineConfig({
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});Selenium requires separate driver binaries and often browser-specific workarounds.
Network Interception
Playwright can intercept, modify, and mock network requests natively. Selenium cannot.
// Mock an API response — impossible in Selenium
await page.route('**/api/users', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ name: 'Mock User' }]),
});
});Browser Context Isolation
Each test gets a fresh browser context (like an incognito window). Cookies, localStorage, and sessions are isolated between tests without restarting the browser. Selenium requires driver.quit() and new ChromeDriver() for true isolation.
Test Fixtures
Playwright's fixture system provides dependency injection for test setup and teardown. Fixtures guarantee cleanup even if a test crashes. Selenium relies on @BeforeEach / @AfterEach which skip teardown on hard failures.
// Custom fixture for authenticated user — reusable across all tests
import { test as base } from '@playwright/test';
export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
await use(page);
// Cleanup runs automatically, even on crash
},
});Codegen
Generate tests by recording browser interactions:
npx playwright codegen https://myapp.comThis opens a browser and records your clicks, fills, and navigations as Playwright test code. No equivalent exists in Selenium.
UI Mode
Debug tests visually with time-travel:
npx playwright test --uiStep forward and backward through each action, see the DOM state, network requests, and console logs at every point. Selenium's closest equivalent is manual Thread.sleep() and screenshot debugging.
Related
- core/locators.md -- locator strategy priority and patterns
- core/assertions-and-waiting.md -- auto-waiting and web-first assertions in depth
- core/configuration.md -- setting up
playwright.config - core/page-object-model.md -- page object patterns for Playwright
- core/fixtures-and-hooks.md -- fixtures system for test setup and isolation
- core/test-organization.md -- organizing tests for parallel execution
- migration/from-cypress.md -- migrating from Cypress instead