# 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. ```javascript // 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). ```javascript // 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** ```javascript 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)** ```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)** ```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** ```javascript 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)** ```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)** ```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** ```javascript 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)** ```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)** ```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** ```javascript // 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) ```typescript // 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 }) }) ``` ```typescript // 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", }, }, ], }) ``` ```typescript // 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) ```javascript // 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 }) }) ``` ```javascript // 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", }, }, ], }) ``` ```javascript // 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 ```javascript // 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 ```typescript // 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({ todosPage: async ({ page }, use) => { await page.goto("/todos") await use(new TodosPage(page)) }, apiHelper: async ({ request }, use) => { await use(new ApiHelper(request)) }, }) export { expect } ``` ```typescript // 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 ```javascript // 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 } ``` ```javascript // 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. ```bash 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** ```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** ```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: ```yaml # 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: ```bash 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()`). ```typescript // 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: ```typescript // 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): ```typescript // 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: ```typescript // 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: ```typescript // 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. ```typescript // 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`). If you need a value _from_ the browser, use `evaluate`, `textContent`, `inputValue`, etc.: ```typescript // 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: ```typescript // 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: ```typescript // 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. ```typescript // 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: ```typescript // 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: ```typescript 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: ```typescript 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: ```bash # 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: ```bash 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): ```typescript import { test, expect } from '@playwright/experimental-ct-react'; import { Button } from './Button'; test('button renders with label', async ({ mount }) => { const component = await mount(