Files
2026-07-13 21:36:47 +08:00

48 KiB
Raw Permalink Blame History

从 Cypress 迁移到 Playwright

使用场景:将 Cypress 测试套件转换为 Playwright 时。请按照本指南逐条命令、逐个模式进行迁移。

关键思维转变

在转换任何代码之前,请先内化以下五点差异。它们会影响你编写的每一行代码。

1. 链式调用 vs async/await

Cypress 命令会排队并按串行链执行——看起来是同步的,但实际不是。Playwright 使用标准的 async/await——真正的异步 JavaScript,没有魔法调度。

// Cypress —— 基于链式调用,隐式排队
cy.get(".todo-list li").should("have.length", 3)
cy.get(".todo-list li").first().should("have.text", "Buy milk")

// Playwright —— async/await,显式控制流
const items = page.getByTestId("todo-item")
await expect(items).toHaveCount(3)
await expect(items.first()).toHaveText("Buy milk")

为什么重要:你可以自然地使用 if/elsefor 循环、try/catch 以及任何 JavaScript 结构。不再需要 cy.then() 变通方案。

2. 自动重试 vs 自动等待

Cypress 会重试整个命令链,直到断言通过(或超时)。Playwright 的定位器是惰性的——它们在你执行操作或断言之前什么都不做,此时 Playwright 会自动等待元素变为可操作状态(可见、可用、稳定)。

// Cypress —— 将 cy.get + .should 一起重试
cy.get('[data-testid="submit"]').should("be.visible").click()

// Playwright —— 定位器立即创建;.click() 自动等待可见性和稳定性
await page.getByTestId("submit").click()

3. 浏览器内 vs Node.js

Cypress 测试代码在浏览器内部运行(同源)。Playwright 在 Node.js 中运行,并通过 Chrome DevTools 协议或等效协议控制浏览器。这意味着:

  • Playwright 可以在单个测试中打开多个标签页、窗口甚至多个浏览器。
  • Playwright 可以从测试代码中原生访问文件系统、数据库和 API——无需 cy.task()
  • 你不能直接访问 windowdocument;需要在浏览器端执行代码时请使用 page.evaluate()

4. 单标签页 vs 多标签页、多浏览器

Cypress 仅限于单个浏览器标签页和单个浏览器。Playwright 可以在同一次测试运行中控制多个页面、多个浏览器上下文(各自拥有独立的 Cookie/存储)以及多种浏览器类型(Chromium、Firefox、WebKit)。

5. 自定义命令 vs Fixture

Cypress 通过 Cypress.Commands.add() 扩展——一个全局的可变注册表。Playwright 使用 test.extend() fixture 实现依赖注入、保证的清理以及类型安全。Fixture 强大得多。

命令映射表

Cypress Playwright 说明
cy.visit('/path') await page.goto('/path') 在配置中使用 baseURL 以避免完整 URL
cy.get('selector') page.locator('selector') 优先使用 page.getByRole() 而非 CSS 选择器
cy.get('[data-testid="x"]') page.getByTestId('x') 在 playwright.config 中配置 testIdAttribute
cy.contains('text') page.getByText('text') 对于按钮/链接,优先使用 page.getByRole('button', { name: 'text' })
cy.find('child') locator.locator('child') 链式调用定位器以限定在父元素范围内
cy.get('sel').click() await locator.click() 自动等待元素变为可操作状态
cy.get('sel').type('text') await locator.fill('text') fill() 立即设置值。如需逐字符输入,使用 locator.pressSequentially('text')
cy.get('sel').clear() await locator.clear() await locator.fill('')
cy.get('select').select('val') await locator.selectOption('val') 接受 value、label 或 { index }
cy.get('input').check() await locator.check() 如果已选中则为空操作
cy.get('input').uncheck() await locator.uncheck() 如果已取消选中则为空操作
cy.get('sel').should('be.visible') await expect(locator).toBeVisible() Web 优先的断言;自动重试
cy.get('sel').should('have.text', 'x') await expect(locator).toHaveText('x') 也支持正则:.toHaveText(/pattern/)
cy.get('sel').should('contain', 'x') await expect(locator).toContainText('x') 子串匹配
cy.get('sel').should('have.length', 3) await expect(locator).toHaveCount(3) 计数匹配的元素
cy.get('sel').should('have.value', 'x') await expect(locator).toHaveValue('x') 输入框/文本域的值
cy.get('sel').should('have.attr', 'href', '/x') await expect(locator).toHaveAttribute('href', '/x') 任意 HTML 属性
cy.get('sel').should('have.class', 'active') await expect(locator).toHaveClass(/active/) 使用正则进行部分类名匹配
cy.get('sel').should('be.disabled') await expect(locator).toBeDisabled()
cy.get('sel').should('not.exist') await expect(locator).toBeHidden() 对于真正不存在的元素,可使用 .toHaveCount(0)
cy.intercept('GET', '/api/**', { body }) await page.route('/api/**', route => route.fulfill({ body })) 在触发请求的操作之前设置
cy.intercept('POST', '/api/save').as('save') const resp = page.waitForResponse('**/api/save') 在操作之前开始等待,之后 await
cy.wait('@save') await resp 返回 Response 对象,包含 .json().status()
cy.fixture('users.json') 通过 test.extend() 或直接 import/require 使用 Fixture 参见下面的示例 5
cy.request('GET', '/api/users') const resp = await request.get('/api/users') 使用 request fixture 或 APIRequestContext
cy.request('POST', '/api/users', body) const resp = await request.post('/api/users', { data: body })
cy.wrap(value) 无需等效方法 直接用 await 使用该值
cy.then((result) => { ... }) const result = await ... 标准 async/await 取代了 .then()
Cypress.env('API_KEY') process.env.API_KEY 或者在 playwright.config 中使用 use: {} 设置测试特定值
Cypress.Commands.add('login', fn) 通过 test.extend() 自定义 fixture 参见下面的示例 5
cy.clock() / cy.tick(1000) await page.clock.install() / await page.clock.fastForward(1000) page.clock API 提供完整的时间控制
cy.screenshot('name') await page.screenshot({ path: 'name.png' }) 当配置中 screenshot: 'on' 时,失败时自动捕获
cy.viewport(1280, 720) await page.setViewportSize({ width: 1280, height: 720 }) 优先在配置或每个项目中设置
beforeEach(() => { cy.visit('/') }) test.beforeEach(async ({ page }) => { await page.goto('/') }) 从 fixture 中解构出 page
cy.scrollTo('bottom') await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) 操作会自动滚动到元素;手动滚动很少需要
cy.focused() page.locator(':focus') 或断言:await expect(locator).toBeFocused()
cy.go('back') await page.goBack() 也有 page.goForward()
cy.reload() await page.reload()
cy.title() await expect(page).toHaveTitle('text') 页面标题的 Web 优先断言
cy.url() await expect(page).toHaveURL('/path') 接受字符串或正则表达式
cy.getCookie('name') const cookies = await context.cookies() 从数组中按名称过滤
cy.setCookie('name', 'val') await context.addCookies([{ name, value, url }])
cy.clearCookies() await context.clearCookies()

迁移前后示例

示例 1:基础导航与断言

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")
  })
})

主要变化cy.get('h1') 变为 page.getByRole('heading', { level: 1 }),更加健壮。cy.contains('About').click() 变为 page.getByRole('link', { name: 'About' }).click(),以断言该元素确实是一个链接。cy.url().should('include', ...) 变为 await expect(page).toHaveURL(...),支持自动重试。


示例 2:表单交互

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")
  })
})

主要变化cy.get('#id').type() 变为 page.getByLabel('Label').fill()。无需 ID——使用标签更健壮且更具可访问性。fill() 取代 type() 并立即设置值。cy.get('form').submit() 变为显式的按钮点击,更符合真实用户行为。


示例 3:网络请求模拟

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 }) => {
    // 设置路由模拟并捕获请求
    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()

    // 对捕获的请求体进行断言
    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 })
  })
})

主要变化cy.intercept() 变为 page.route()。在触发请求的操作之前设置路由。无需 cy.wait('@alias')——Playwright 会自动等待 UI 更新。如需对请求体进行断言,可在路由处理器中捕获它。如需等待特定响应,可使用 page.waitForResponse()


示例 4:身份认证

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)——使用 storageState 实现会话复用(推荐)

// auth.setup.ts —— 运行一次,将认证状态保存到文件
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")

  // 将登录状态保存到文件
  await page.context().storageState({ path: authFile })
})
// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  projects: [
    // 设置项目 —— 先运行认证
    { name: "setup", testMatch: /.*\.setup\.ts/ },

    // 所有测试复用保存的认证状态
    {
      name: "chromium",
      dependencies: ["setup"],
      use: {
        storageState: ".auth/user.json",
      },
    },
  ],
})
// dashboard.spec.ts —— 已认证,无需登录代码
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)——使用 storageState 实现会话复用(推荐)

// 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()
  })
})

主要变化Cypress 的 cy.session() 变为 Playwright 的 storageState。认证作为设置项目运行一次,将 Cookie/localStorage 保存到 JSON 文件中,所有测试项目复用该文件。测试从不涉及登录流程——它们从已认证状态开始。请将 .auth/ 添加到 .gitignore


示例 5:自定义命令到 Fixture

Cypress——自定义命令是全局注册的

// 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)——Fixture 取代自定义命令

// fixtures.ts
import { test as base, expect } from "@playwright/test"

// 为自定义 fixture 定义类型
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)——Fixture 取代自定义命令

// 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)
})

主要变化:每个 Cypress 自定义命令要么映射为页面对象的方法(用于 UI 交互),要么映射为辅助类的方法(用于 API 调用),通过 test.extend() fixture 暴露。Fixture 提供依赖注入、类型安全和保证的清理。测试通过在函数签名中按名称声明所需内容。

迁移步骤

一套经过实战检验的流程,用于将现有 Cypress 测试套件迁移到 Playwright。

第 1 步:在 Cypress 旁安装 Playwright

先不要移除 Cypress。迁移期间并行运行两个框架。

npm init playwright@latest
# 接受默认选项:TypeScript(或 JavaScript)、测试文件夹、GitHub Actions CI、安装浏览器

这将创建 playwright.config.tstests/ 文件夹,并安装浏览器。

第 2 步:配置 playwright.config 以匹配你的 Cypress 设置

cypress.config.js 设置映射到 playwright.config.ts

TypeScript

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"

export default defineConfig({
  testDir: "./tests", // 你新的 Playwright 测试目录
  timeout: 30_000, // Cypress 默认为每条命令 4 秒;Playwright 为每个测试 30 秒
  expect: { timeout: 5_000 }, // 断言自动重试超时时间(类似 Cypress 的 defaultCommandTimeout
  fullyParallel: true, // Cypress 默认串行运行;Playwright 并行化
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI ? "html" : "list",

  use: {
    baseURL: "http://localhost:3000", // 匹配 Cypress 的 baseUrl
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },

  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    // 添加更多浏览器——这是 Cypress 做不到的:
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
  ],

  // 相当于 Cypress 的 devServer 配置
  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,
  },
})

第 3 步:将自定义命令转换为 Fixture

在迁移单个测试之前,先将 cypress/support/commands.js 转换为 Playwright fixture。这样每个已迁移的测试都能立即访问相同的辅助方法。

  1. 识别 cypress/support/commands.jscypress/support/e2e.js 中的每个自定义命令。
  2. 将 UI 命令分组为页面对象类。
  3. 将 API 命令分组为 API 辅助类。
  4. 通过共享的 fixtures.ts(或 fixtures.js)中的 test.extend() 暴露它们。
  5. 参见上面的示例 5 了解完整模式。

第 4 步:设置身份认证

cy.session()beforeEach 中的登录模式转换为 Playwright 的 storageState

  1. 创建一个 auth.setup.ts 文件(参见上面的示例 4)。
  2. playwright.config.ts 中添加一个 setup 项目。
  3. .auth/ 添加到 .gitignore
  4. 从各个测试文件中移除所有登录代码。

第 5 步:逐个文件迁移测试

从最简单的 spec 文件开始。对于每个文件:

  1. tests/ 中创建对应的 Playwright 测试文件。
  2. 使用上面的命令映射表进行转换。
  3. 用语义定位器(getByRolegetByLabelgetByText)替换 CSS 选择器。
  4. 在有头模式下运行 Playwright 测试:npx playwright test tests/my-test.spec.ts --headed
  5. 测试通过后,将该 Cypress spec 标记为已迁移(移动到 archived/ 文件夹或删除)。

按价值排序:先迁移关键路径测试(认证、结账、核心 CRUD)。

第 6 步:将 cy.intercept 模式转换为 page.route

对于每个被拦截的路由:

  1. page.route() 调用移到触发请求的操作之前(Playwright 路由必须提前设置)。
  2. cy.wait('@alias') 替换为 page.waitForResponse()(当你需要响应时),或者直接让自动等待的断言处理(当你只关心 UI 更新时)。
  3. 对于请求体断言,在 page.route() 处理器内部捕获请求体。

第 7 步:将 Cypress 插件转换为 Node.js 代码

Cypress 插件(在 cypress/plugins/ 或配置中的 setupNodeEvents 中)在单独的 Node.js 进程中运行,并通过 cy.task() 与测试代码通信。在 Playwright 中,你的测试代码已经在 Node.js 中运行,因此:

  • 数据库播种:直接从 fixture 或 globalSetup 中调用。
  • 文件操作:在测试代码或 fixture 中直接使用 fs
  • 环境设置:在配置中使用 globalSetup / globalTeardown
  • 自定义任务逻辑:迁移到 fixture 的设置/清理中。

第 8 步:更新 CI 流水线

将 Cypress CI 步骤替换为 Playwright

# GitHub Actions 示例
- 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

第 9 步:移除 Cypress

当所有测试已迁移并在 CI 中通过后:

npm uninstall cypress
rm -rf cypress/
rm cypress.config.js   # 或 .ts
# 移除所有与 Cypress 相关的 CI 配置
# 如果存在,从 .eslintrc 中移除与 Cypress 相关的条目

常见陷阱

Cypress 和 Playwright 之间看似相似但行为不同之处。

1. 定位器是惰性的,而非重试

Cypress 的 cy.get() 立即开始查询 DOM 并重试直到链式调用通过。Playwright 的 page.locator() 立即创建一个定位器对象——在你调用操作(.click().fill())或断言(expect(locator).toBeVisible())之前,它什么都不做。

// 这不会查询 DOM —— 它只是创建一个定位器引用
const button = page.getByRole("button", { name: "Submit" })

// 这才是实际 DOM 查询 + 等待发生的地方
await button.click()

这意味着将定位器存储在变量中是免费的,且推荐这样做。可以在多个操作和断言中复用它们。

2. 断言与定位器分离

Cypress 将断言链接到命令上:cy.get('x').should('be.visible').and('have.text', 'y')。Playwright 为每个断言使用独立的 expect() 调用:

// Cypress —— 链式断言
cy.get(".card").should("be.visible").and("have.text", "Hello").and("have.class", "active")

// Playwright —— 独立的 expect() 调用,每个独立自动重试
const card = page.locator(".card")
await expect(card).toBeVisible()
await expect(card).toHaveText("Hello")
await expect(card).toHaveClass(/active/)

3. fill() 与 type() 的速度差异

cy.type('text') 逐字符发送按键事件,为每个字符触发 keydownkeypressinputkeyup 事件。Playwright 的 locator.fill('text') 一次性清除字段并设置值,仅触发 inputchange 事件。

默认使用 fill()。仅在逐字符输入重要时才使用 pressSequentially()(自动补全、即输即搜、输入掩码):

// 默认 —— 快速,直接设置值
await page.getByLabel("Name").fill("Jane Doe")

// 逐字符 —— 仅当按键事件重要时
await page.getByLabel("Search").pressSequentially("play", { delay: 100 })

4. 自动滚动行为

Cypress 在任何交互之前会自动滚动以将元素带入视图。Playwright 仅在执行操作(click、fill、check)时才会自动滚动到视图内。像 toBeVisible() 这样的断言不会滚动——它们检查元素在当前位置上是否可见。

如果需要在断言可见性之前滚动:

// 先滚动到视图内,然后断言
await page.getByText("Footer content").scrollIntoViewIfNeeded()
await expect(page.getByText("Footer content")).toBeVisible()

5. 没有隐式的 cy.wrap() 等价物

Cypress 的 cy.wrap() 将非 Cypress 值带入命令链。Playwright 不需要这个,因为你已经在 async/await 的世界中:

// Cypress
const value = 42
cy.wrap(value).should("equal", 42)

// Playwright —— 直接使用该值
const value = 42
expect(value).toBe(42)

6. 路由设置时机

Cypress 的 cy.intercept() 可以在任何时候调用,并且会匹配下一个匹配的请求。Playwright 的 page.route() 必须在触发请求的操作之前设置。一个常见的错误是在 page.goto() 之后才设置路由——到那时请求可能已经发出。

// 错误 —— 路由设置太晚
await page.goto("/products")
await page.route("**/api/products", (route) => route.fulfill({ body: "[]" }))

// 正确 —— 路由在导航之前设置
await page.route("**/api/products", (route) => route.fulfill({ body: "[]" }))
await page.goto("/products")

7. Cypress 主题 vs Playwright 返回值

Cypress 命令将"主题"传递给链中的下一条命令。Playwright 的操作返回 void(或 Promise<void>)。如果你需要从浏览器中获取值,请使用 evaluatetextContentinputValue 等:

// Cypress —— 主题链式传递
cy.get("#counter")
  .invoke("text")
  .then((text) => {
    const count = parseInt(text, 10)
    expect(count).to.be.greaterThan(0)
  })

// Playwright —— 直接返回值
const text = await page.locator("#counter").textContent()
const count = parseInt(text!, 10)
expect(count).toBeGreaterThan(0)

// 更好:尽可能使用 Web 优先的断言
await expect(page.locator("#counter")).not.toHaveText("0")

8. Cypress 插件 vs Playwright 全局设置

Cypress 使用 setupNodeEvents(或旧的 plugins/index.js)处理 Node.js 端的操作,通过 cy.task() 访问。由于 Playwright 测试已经在 Node.js 中运行,你不需要单独的插件层:

// Cypress —— 插件模式
// cypress.config.js
setupNodeEvents(on) {
  on('task', {
    seedDatabase(data) { return db.seed(data); },
  });
}
// 测试:cy.task('seedDatabase', testData);

// Playwright —— 在 fixture 中直接调用
export const test = base.extend({
  seededDatabase: async ({}, use) => {
    await db.seed(testData);
    await use();
    await db.cleanup();
  },
});

9. cy.within() vs 定位器作用域

Cypress 的 cy.within() 将所有后续命令限定在某个容器元素内。Playwright 通过链式调用定位器来实现作用域:

// Cypress
cy.get('[data-testid="signup-form"]').within(() => {
  cy.get('input[name="email"]').type("user@test.com")
  cy.get("button").click()
})

// Playwright —— 链式定位器实现作用域
const form = page.getByTestId("signup-form")
await form.getByLabel("Email").fill("user@test.com")
await form.getByRole("button", { name: "Sign up" }).click()

10. 测试隔离差异

Cypress 默认会在测试之间清除 Cookie、localStorage 和 sessionStorage,但共享同一个浏览器实例。Playwright 为每个测试创建一个全新的浏览器上下文——完全隔离 Cookie、存储、缓存和服务工作线程。这意味着:

  • 你永远不需要在测试之间手动清除状态。
  • 你不能从一个测试向另一个测试"泄露"状态(这可能在 Cypress 中掩盖或导致不稳定性)。
  • Worker 作用域的 fixture 是在测试之间共享昂贵资源的方式。

Playwright 更优之处

没有 Cypress 等价物、使 Playwright 成为生产测试套件更优选择的功能。

开箱即用的多浏览器测试

在单个配置中测试 Chromium、Firefox 和 WebKit (Safari)。无需插件,无需付费仪表盘。

// playwright.config.ts —— 三个浏览器,零额外设置
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'] } },
],

多标签页和多窗口测试

测试弹窗、OAuth 流程、新标签页和多窗口交互:

// 处理弹窗窗口(例如 OAuth)
const popupPromise = page.waitForEvent("popup")
await page.getByRole("button", { name: "Sign in with Google" }).click()
const popup = await popupPromise
await popup.getByLabel("Email").fill("user@gmail.com")

单次测试中的多用户测试

使用多个独立的浏览器上下文测试协作功能:

test("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()
})

原生 API 测试

无需浏览器即可测试 API,使用相同的运行器和断言库:

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")
})

带分片的并行执行

零配置跨多台机器运行测试:

# 拆分到 4 台 CI 机器上
npx playwright test --shard=1/4  # 机器 1
npx playwright test --shard=2/4  # 机器 2
npx playwright test --shard=3/4  # 机器 3
npx playwright test --shard=4/4  # 机器 4

Trace 查看器

Playwright trace 捕获测试执行的完整记录:DOM 快照、网络请求、控制台日志和操作截图。使用以下命令打开:

npx playwright show-trace trace.zip

这取代了 Cypress 的时间旅行调试器,提供了一个更详细、可离线使用的工具。

支持框架的组件测试

在真实浏览器(而非 jsdom)中测试 React、Vue、Svelte 和 Solid 组件:

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();
});

内置代码生成

通过与应用交互来生成测试代码:

npx playwright codegen http://localhost:3000

记录你的点击、输入和断言,并输出 Playwright 测试代码。将其作为起点,然后优化定位器以使用 getByRole

时钟控制

完全控制 DatesetTimeoutsetIntervalrequestAnimationFrame

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()
})

使用 HAR 文件的网络请求拦截

从 HAR 文件录制和重放网络流量,实现确定性测试:

// 录制所有网络流量
await page.routeFromHAR("tests/fixtures/products.har", {
  url: "**/api/**",
  update: true, // 设为 true 录制一次,然后设为 false 重放
})

相关文档