Skip to content

Latest commit

 

History

History
1209 lines (935 loc) · 41.9 KB

File metadata and controls

1209 lines (935 loc) · 41.9 KB

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 window or document directly; use page.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 browsers

This 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 fs directly in test code or fixtures.
  • Environment setup: use globalSetup / globalTeardown in 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: 30

Step 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 present

Common 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 4

Trace 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.zip

This 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:3000

Records 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