48 KiB
从 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/else、for 循环、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()。 - 你不能直接访问
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
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.ts、tests/ 文件夹,并安装浏览器。
第 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。这样每个已迁移的测试都能立即访问相同的辅助方法。
- 识别
cypress/support/commands.js和cypress/support/e2e.js中的每个自定义命令。 - 将 UI 命令分组为页面对象类。
- 将 API 命令分组为 API 辅助类。
- 通过共享的
fixtures.ts(或fixtures.js)中的test.extend()暴露它们。 - 参见上面的示例 5 了解完整模式。
第 4 步:设置身份认证
将 cy.session() 或 beforeEach 中的登录模式转换为 Playwright 的 storageState:
- 创建一个
auth.setup.ts文件(参见上面的示例 4)。 - 在
playwright.config.ts中添加一个setup项目。 - 将
.auth/添加到.gitignore。 - 从各个测试文件中移除所有登录代码。
第 5 步:逐个文件迁移测试
从最简单的 spec 文件开始。对于每个文件:
- 在
tests/中创建对应的 Playwright 测试文件。 - 使用上面的命令映射表进行转换。
- 用语义定位器(
getByRole、getByLabel、getByText)替换 CSS 选择器。 - 在有头模式下运行 Playwright 测试:
npx playwright test tests/my-test.spec.ts --headed。 - 测试通过后,将该 Cypress spec 标记为已迁移(移动到
archived/文件夹或删除)。
按价值排序:先迁移关键路径测试(认证、结账、核心 CRUD)。
第 6 步:将 cy.intercept 模式转换为 page.route
对于每个被拦截的路由:
- 将
page.route()调用移到触发请求的操作之前(Playwright 路由必须提前设置)。 - 将
cy.wait('@alias')替换为page.waitForResponse()(当你需要响应时),或者直接让自动等待的断言处理(当你只关心 UI 更新时)。 - 对于请求体断言,在
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') 逐字符发送按键事件,为每个字符触发 keydown、keypress、input 和 keyup 事件。Playwright 的 locator.fill('text') 一次性清除字段并设置值,仅触发 input 和 change 事件。
默认使用 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>)。如果你需要从浏览器中获取值,请使用 evaluate、textContent、inputValue 等:
// 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。
时钟控制
完全控制 Date、setTimeout、setInterval 和 requestAnimationFrame:
test("shows expired badge after timeout", async ({ page }) => {
await page.clock.install({ time: new Date("2024-01-01T00:00:00Z") })
await page.goto("/offers")
await page.clock.fastForward("24:00:00")
await expect(page.getByText("Offer expired")).toBeVisible()
})
使用 HAR 文件的网络请求拦截
从 HAR 文件录制和重放网络流量,实现确定性测试:
// 录制所有网络流量
await page.routeFromHAR("tests/fixtures/products.har", {
url: "**/api/**",
update: true, // 设为 true 录制一次,然后设为 false 重放
})
相关文档
- core/locators.md —— Playwright 定位器策略(取代 Cypress 的
cy.get()模式) - core/fixtures-and-hooks.md —— fixture 深入详解(取代 Cypress 的自定义命令)
- core/assertions-and-waiting.md —— Web 优先的断言(取代 Cypress 的
should链) - core/configuration.md —— playwright.config 设置
- core/authentication.md —— 认证模式(取代 Cypress 的
cy.session()) - core/network-mocking.md —— 路由模拟(取代 Cypress 的
cy.intercept()) - ci/ci-github-actions.md —— Playwright 的 CI 设置