# 从 Cypress 迁移到 Playwright > **使用场景**:将 Cypress 测试套件转换为 Playwright 时。请按照本指南逐条命令、逐个模式进行迁移。 ## 关键思维转变 在转换任何代码之前,请先内化以下五点差异。它们会影响你编写的每一行代码。 ### 1. 链式调用 vs async/await Cypress 命令会排队并按串行链执行——看起来是同步的,但实际不是。Playwright 使用标准的 `async`/`await`——真正的异步 JavaScript,没有魔法调度。 ```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`/`else`、`for` 循环、`try`/`catch` 以及任何 JavaScript 结构。不再需要 `cy.then()` 变通方案。 ### 2. 自动重试 vs 自动等待 Cypress 会重试整个命令链,直到断言通过(或超时)。Playwright 的定位器是惰性的——它们在你执行操作或断言之前什么都不做,此时 Playwright 会自动等待元素变为可操作状态(可见、可用、稳定)。 ```javascript // 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()`。 - 你不能直接访问 `window` 或 `document`;需要在浏览器端执行代码时请使用 `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** ```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") }) }) ``` **主要变化**:`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** ```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") }) }) ``` **主要变化**:`cy.get('#id').type()` 变为 `page.getByLabel('Label').fill()`。无需 ID——使用标签更健壮且更具可访问性。`fill()` 取代 `type()` 并立即设置值。`cy.get('form').submit()` 变为显式的按钮点击,更符合真实用户行为。 --- ### 示例 3:网络请求模拟 **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 }) => { // 设置路由模拟并捕获请求 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)** ```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** ```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)**——使用 `storageState` 实现会话复用(推荐) ```typescript // 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 }) }) ``` ```typescript // 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", }, }, ], }) ``` ```typescript // 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` 实现会话复用(推荐) ```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() }) }) ``` **主要变化**:Cypress 的 `cy.session()` 变为 Playwright 的 `storageState`。认证作为设置项目运行一次,将 Cookie/localStorage 保存到 JSON 文件中,所有测试项目复用该文件。测试从不涉及登录流程——它们从已认证状态开始。请将 `.auth/` 添加到 `.gitignore`。 --- ### 示例 5:自定义命令到 Fixture **Cypress**——自定义命令是全局注册的 ```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)**——Fixture 取代自定义命令 ```typescript // 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({ 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)**——Fixture 取代自定义命令 ```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) }) ``` **主要变化**:每个 Cypress 自定义命令要么映射为页面对象的方法(用于 UI 交互),要么映射为辅助类的方法(用于 API 调用),通过 `test.extend()` fixture 暴露。Fixture 提供依赖注入、类型安全和保证的清理。测试通过在函数签名中按名称声明所需内容。 ## 迁移步骤 一套经过实战检验的流程,用于将现有 Cypress 测试套件迁移到 Playwright。 ### 第 1 步:在 Cypress 旁安装 Playwright 先不要移除 Cypress。迁移期间并行运行两个框架。 ```bash npm init playwright@latest # 接受默认选项:TypeScript(或 JavaScript)、测试文件夹、GitHub Actions CI、安装浏览器 ``` 这将创建 `playwright.config.ts`、`tests/` 文件夹,并安装浏览器。 ### 第 2 步:配置 playwright.config 以匹配你的 Cypress 设置 将 `cypress.config.js` 设置映射到 `playwright.config.ts`: **TypeScript** ```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** ```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.js` 和 `cypress/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. 用语义定位器(`getByRole`、`getByLabel`、`getByText`)替换 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: ```yaml # 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 中通过后: ```bash 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()`)之前,它什么都不做。 ```typescript // 这不会查询 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()` 调用: ```typescript // 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')` 逐字符发送按键事件,为每个字符触发 `keydown`、`keypress`、`input` 和 `keyup` 事件。Playwright 的 `locator.fill('text')` 一次性清除字段并设置值,仅触发 `input` 和 `change` 事件。 默认使用 `fill()`。仅在逐字符输入重要时才使用 `pressSequentially()`(自动补全、即输即搜、输入掩码): ```typescript // 默认 —— 快速,直接设置值 await page.getByLabel("Name").fill("Jane Doe") // 逐字符 —— 仅当按键事件重要时 await page.getByLabel("Search").pressSequentially("play", { delay: 100 }) ``` ### 4. 自动滚动行为 Cypress 在任何交互之前会自动滚动以将元素带入视图。Playwright 仅在执行操作(click、fill、check)时才会自动滚动到视图内。像 `toBeVisible()` 这样的断言不会滚动——它们检查元素在当前位置上是否可见。 如果需要在断言可见性之前滚动: ```typescript // 先滚动到视图内,然后断言 await page.getByText("Footer content").scrollIntoViewIfNeeded() await expect(page.getByText("Footer content")).toBeVisible() ``` ### 5. 没有隐式的 cy.wrap() 等价物 Cypress 的 `cy.wrap()` 将非 Cypress 值带入命令链。Playwright 不需要这个,因为你已经在 async/await 的世界中: ```typescript // 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()` 之后才设置路由——到那时请求可能已经发出。 ```typescript // 错误 —— 路由设置太晚 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`)。如果你需要从浏览器中获取值,请使用 `evaluate`、`textContent`、`inputValue` 等: ```typescript // 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 中运行,你不需要单独的插件层: ```typescript // 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 通过链式调用定位器来实现作用域: ```typescript // 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)。无需插件,无需付费仪表盘。 ```typescript // 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 流程、新标签页和多窗口交互: ```typescript // 处理弹窗窗口(例如 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") ``` ### 单次测试中的多用户测试 使用多个独立的浏览器上下文测试协作功能: ```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() }) ``` ### 原生 API 测试 无需浏览器即可测试 API,使用相同的运行器和断言库: ```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") }) ``` ### 带分片的并行执行 零配置跨多台机器运行测试: ```bash # 拆分到 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 快照、网络请求、控制台日志和操作截图。使用以下命令打开: ```bash npx playwright show-trace trace.zip ``` 这取代了 Cypress 的时间旅行调试器,提供了一个更详细、可离线使用的工具。 ### 支持框架的组件测试 在真实浏览器(而非 jsdom)中测试 React、Vue、Svelte 和 Solid 组件: ```typescript import { test, expect } from '@playwright/experimental-ct-react'; import { Button } from './Button'; test('button renders with label', async ({ mount }) => { const component = await mount(