978 lines
39 KiB
Markdown
978 lines
39 KiB
Markdown
# 从 Selenium 迁移到 Playwright
|
||
|
||
> **适用场景**:将 Selenium/WebDriver 测试套件转换为 Playwright。涵盖 Java/Python/C# 的 Selenium 惯用法与 TypeScript 和 JavaScript 的 Playwright 等价写法。
|
||
> **前置知识**:[core/locators.md](../core/locators.md)、[core/assertions-and-waiting.md](../core/assertions-and-waiting.md)
|
||
|
||
## 关键思维转变
|
||
|
||
在翻译任何代码之前,请先内化这六项变化。它们不是语法替换——而是 Playwright 工作方式的根本差异。
|
||
|
||
### 1. 不再需要显式等待
|
||
|
||
Selenium 几乎每次交互都需要 `WebDriverWait` 和 `ExpectedConditions`。Playwright 在每个操作(`click`、`fill`、`check`、`selectOption`)和每个 web 优先断言(`expect(locator).toBeVisible()`)上都会自动等待。删除所有等待代码。它们不再需要。
|
||
|
||
```
|
||
Selenium: WebDriverWait + ExpectedConditions.visibilityOfElementLocated(...)
|
||
Playwright: 无需任何代码——自动等待内置于每个操作和断言中
|
||
```
|
||
|
||
### 2. 没有 WebDriver 协议开销
|
||
|
||
Selenium 的每条命令都通过 WebDriver (W3C) HTTP 协议发送——每次点击、每次查找、每次断言都是一次往返 HTTP 请求。Playwright 通过 CDP (Chromium)、DevTools 协议 (Firefox) 或原生 WebKit 协议进行通信。这是持久的双向连接,而非请求-响应模式。测试默认更快。
|
||
|
||
### 3. 不再需要驱动管理
|
||
|
||
Selenium 要求浏览器驱动(`chromedriver`、`geckodriver`)与浏览器版本匹配。版本不匹配是 CI 故障的常见来源。Playwright 捆绑了浏览器二进制文件。一条命令即可安装所有内容:
|
||
|
||
```bash
|
||
npx playwright install
|
||
```
|
||
|
||
无需 `WebDriverManager`。无需 `chromedriver` 路径。无需版本矩阵。
|
||
|
||
### 4. 不再有隐式等待与显式等待的混淆
|
||
|
||
Selenium 有隐式等待(全局的,适用于所有 `findElement` 调用)、显式等待(`WebDriverWait`)以及 `Thread.sleep()` / `time.sleep()`。团队混合使用它们,导致不可预测的时序行为。Playwright 只有一种机制:自动等待。每个操作都会等待元素可操作。每个 web 优先断言都会重试直到超时。无需任何配置。
|
||
|
||
### 5. 定位器是惰性的且会自动重试
|
||
|
||
在 Selenium 中,`findElement()` 会立即查询 DOM 并返回一个 `WebElement` 引用。如果 DOM 发生变化,该引用就会失效并抛出 `StaleElementReferenceException`。在 Playwright 中,`page.locator()` 返回一个惰性定位器,该定位器会在每次操作时重新查询 DOM。没有失效元素的问题。永远不会出现 `StaleElementReferenceException`。
|
||
|
||
### 6. 内置测试运行器
|
||
|
||
Selenium 是一个浏览器自动化库,而非测试框架。你需要在它之上使用 JUnit、TestNG、pytest 或 Mocha。Playwright Test 是一个完整的测试运行器,支持并行执行、重试、fixture、HTML 报告、Trace Viewer 和 UI 模式。无需额外组装。
|
||
|
||
## API 映射表
|
||
|
||
每个 Selenium API 及其直接的 Playwright 等价写法。「备注」列指出了行为差异。
|
||
|
||
### 导航
|
||
|
||
| Selenium WebDriver | Playwright | 备注 |
|
||
| --- | --- | --- |
|
||
| `driver.get(url)` | `await page.goto(url)` | Playwright 默认等待 `load` 事件;可通过 `waitUntil` 配置 |
|
||
| `driver.navigate().to(url)` | `await page.goto(url)` | 同上 |
|
||
| `driver.navigate().back()` | `await page.goBack()` | 等待导航完成 |
|
||
| `driver.navigate().forward()` | `await page.goForward()` | 等待导航完成 |
|
||
| `driver.navigate().refresh()` | `await page.reload()` | 等待 `load` 事件 |
|
||
| `driver.getCurrentUrl()` | `page.url()` | 在 Playwright 中是同步的——无需 await |
|
||
| `driver.getTitle()` | `await page.title()` | 或使用 `await expect(page).toHaveTitle('...')` 进行断言 |
|
||
|
||
### 元素定位
|
||
|
||
| Selenium WebDriver | Playwright | 备注 |
|
||
| --- | --- | --- |
|
||
| `driver.findElement(By.id("x"))` | `page.locator('#x')` 或 `page.getByTestId('x')` | 优先使用 `getByRole()` 或 `getByTestId()` 而非 ID 选择器 |
|
||
| `driver.findElement(By.css("x"))` | `page.locator('x')` | CSS 选择器可用,但推荐使用语义化定位器 |
|
||
| `driver.findElement(By.xpath("x"))` | `page.locator('xpath=x')` | 可用但尽量避免 XPath;使用 `getByRole()`、`getByLabel()`、`getByText()` |
|
||
| `driver.findElement(By.name("x"))` | `page.locator('[name="x"]')` | 如果字段有 label,也可使用 `page.getByLabel()` |
|
||
| `driver.findElement(By.linkText("x"))` | `page.getByRole('link', { name: 'x' })` | 基于角色的方式更健壮 |
|
||
| `driver.findElement(By.partialLinkText("x"))` | `page.getByRole('link', { name: /x/ })` | 使用正则表达式进行部分匹配 |
|
||
| `driver.findElement(By.className("x"))` | `page.locator('.x')` | 类选择器很脆弱;推荐使用语义化定位器 |
|
||
| `driver.findElement(By.tagName("x"))` | `page.locator('x')` | 单独使用很少有用;与 role 或 text 组合使用 |
|
||
| `driver.findElements(By.css("x"))` | `await page.locator('x').all()` | 返回定位器数组;或使用 `toHaveCount()` 断言数量 |
|
||
|
||
### 元素交互
|
||
|
||
| Selenium WebDriver | Playwright | 备注 |
|
||
| --- | --- | --- |
|
||
| `element.click()` | `await locator.click()` | 自动等待元素可见、稳定、已启用且未被遮挡 |
|
||
| `element.sendKeys("text")` | `await locator.fill("text")` | **行为变化**:`fill()` 先清除现有文本,再设置值。使用 `pressSequentially()` 可以逐个字符地输入 |
|
||
| `element.sendKeys(Keys.ENTER)` | `await locator.press('Enter')` | 或使用 `await page.keyboard.press('Enter')` |
|
||
| `element.clear()` | `await locator.clear()` | 或使用 `await locator.fill('')` |
|
||
| `element.submit()` | `await locator.press('Enter')` | 没有直接等价写法;点击提交按钮或按 Enter 键 |
|
||
| `new Select(element).selectByVisibleText("x")` | `await locator.selectOption({ label: 'x' })` | 也支持 `{ value: 'x' }` 和 `{ index: 0 }` |
|
||
| `element.isDisplayed()` | `await expect(locator).toBeVisible()` | 使用断言形式——它会自动重试。`locator.isVisible()` 不会重试 |
|
||
| `element.isEnabled()` | `await expect(locator).toBeEnabled()` | 使用断言形式以确保可靠性 |
|
||
| `element.isSelected()` | `await expect(locator).toBeChecked()` | 用于复选框和单选按钮 |
|
||
| `element.getText()` | `await locator.textContent()` | 或优先使用 `await expect(locator).toHaveText('...')`,它会自动重试 |
|
||
| `element.getAttribute("x")` | `await locator.getAttribute('x')` | 或使用 `await expect(locator).toHaveAttribute('x', 'value')` |
|
||
| `element.getCssValue("x")` | `await expect(locator).toHaveCSS('x', 'value')` | 使用断言形式;仅支持计算后的值 |
|
||
|
||
### 等待与条件
|
||
|
||
| Selenium WebDriver | Playwright | 备注 |
|
||
| --- | --- | --- |
|
||
| `WebDriverWait(driver, 10).until(...)` | 不需要 | Playwright 在所有操作和断言上自动等待 |
|
||
| `ExpectedConditions.visibilityOfElementLocated(...)` | `await expect(locator).toBeVisible()` | 内置于每个操作中;仅在需要显式验证可见性时才使用断言 |
|
||
| `ExpectedConditions.elementToBeClickable(...)` | 不需要 | `click()` 自动等待可点击状态 |
|
||
| `ExpectedConditions.presenceOfElementLocated(...)` | `await expect(locator).toBeAttached()` | 很少需要;大多数操作会自动等待元素附加 |
|
||
| `ExpectedConditions.invisibilityOfElementLocated(...)` | `await expect(locator).not.toBeVisible()` | 自动重试直到元素消失 |
|
||
| `ExpectedConditions.textToBePresentInElement(...)` | `await expect(locator).toHaveText('...')` | 自动重试直到文本匹配 |
|
||
| `ExpectedConditions.titleIs("x")` | `await expect(page).toHaveTitle('x')` | 自动重试 |
|
||
| `ExpectedConditions.urlContains("x")` | `await expect(page).toHaveURL(/x/)` | 或使用 `await page.waitForURL('**/x')` |
|
||
| `ExpectedConditions.alertIsPresent()` | `page.on('dialog', ...)` 或 `page.waitForEvent('dialog')` | 在触发对话框的操作之前注册处理程序 |
|
||
| `Thread.sleep(5000)` / `time.sleep(5)` | 永远不要用 | 删除它。改用自动等待断言 |
|
||
| `driver.manage().timeouts().implicitlyWait(10)` | 不需要 | Playwright 中没有隐式等待——自动等待处理所有情况 |
|
||
|
||
### 框架与窗口
|
||
|
||
| Selenium WebDriver | Playwright | 备注 |
|
||
| --- | --- | --- |
|
||
| `driver.switchTo().frame("name")` | `page.frameLocator('iframe[name="name"]')` | 无需切换上下文;将定位器直接链式调用到框架内 |
|
||
| `driver.switchTo().frame(element)` | `page.frameLocator('iframe#id')` | 通过任何 CSS 选择器定位 iframe |
|
||
| `driver.switchTo().defaultContent()` | 不需要 | Playwright 中无需框架切换;每个 `frameLocator` 都是作用域限定的 |
|
||
| `driver.switchTo().parentFrame()` | 不需要 | 无需撤销框架切换 |
|
||
| `driver.switchTo().window(handle)` | `context.pages()` | 通过索引访问上下文中的所有页面 |
|
||
| `driver.getWindowHandle()` | 不需要 | 直接使用 `page` 引用 |
|
||
| `driver.getWindowHandles()` | `context.pages()` | 返回所有打开的页面的数组 |
|
||
| 点击后打开的新窗口/标签页 | `page.waitForEvent('popup')` | 在点击之前注册;返回新的 `Page` 对象 |
|
||
|
||
### 浏览器与上下文
|
||
|
||
| Selenium WebDriver | Playwright | 备注 |
|
||
| --- | --- | --- |
|
||
| `driver.manage().window().setSize(w, h)` | `await page.setViewportSize({ width: w, height: h })` | 设置视口而非操作系统窗口 |
|
||
| `driver.manage().window().maximize()` | 在 `playwright.config` 的 `use.viewport` 中配置 | 或使用大视口传入 `--headed` |
|
||
| `driver.manage().addCookie(cookie)` | `await context.addCookies([cookie])` | 接收数组;在浏览器上下文中操作 |
|
||
| `driver.manage().getCookieNamed("x")` | 先使用 `await context.cookies()` 再过滤 | 返回所有 cookie;在 JS 中过滤 |
|
||
| `driver.manage().deleteAllCookies()` | `await context.clearCookies()` | 清除上下文中的所有 cookie |
|
||
| `driver.executeScript("return ...")` | `await page.evaluate(() => { ... })` | 完全访问浏览器 JS 上下文;支持返回值 |
|
||
| `driver.executeAsyncScript(...)` | `await page.evaluate(async () => { ... })` | `evaluate` 原生支持异步函数 |
|
||
| `driver.getScreenshotAs(OutputType.FILE)` | `await page.screenshot({ path: 'shot.png' })` | 还支持 `fullPage: true`,以及通过 `locator.screenshot()` 截取元素截图 |
|
||
| `driver.quit()` | 自动处理 | Playwright Test 管理浏览器生命周期。无需手动清理 |
|
||
|
||
### Actions 类
|
||
|
||
| Selenium WebDriver | Playwright | 备注 |
|
||
| --- | --- | --- |
|
||
| `new Actions(driver).moveToElement(el).perform()` | `await locator.hover()` | 单个方法,自动等待 |
|
||
| `new Actions(driver).doubleClick(el).perform()` | `await locator.dblclick()` | 单个方法,自动等待 |
|
||
| `new Actions(driver).contextClick(el).perform()` | `await locator.click({ button: 'right' })` | 右键点击选项 |
|
||
| `new Actions(driver).dragAndDrop(src, tgt).perform()` | `await source.dragTo(target)` | 两者都是定位器 |
|
||
| `new Actions(driver).keyDown(Keys.SHIFT).click(el).keyUp(Keys.SHIFT).perform()` | `await locator.click({ modifiers: ['Shift'] })` | 修饰键作为选项传入 |
|
||
| `new Actions(driver).sendKeys(Keys.chord(Keys.CONTROL, "a")).perform()` | `await page.keyboard.press('Control+a')` | 用于全局快捷键的键盘 API |
|
||
| `new Actions(driver).moveByOffset(x, y).perform()` | `await page.mouse.move(x, y)` | 用于画布/地图交互的原始鼠标 API |
|
||
|
||
## 转换前后示例
|
||
|
||
### 示例 1:登录测试
|
||
|
||
最常见的 Selenium 测试。注意 Playwright 版本中完全没有显式等待。
|
||
|
||
**Selenium (Java)**
|
||
|
||
```java
|
||
import org.openqa.selenium.*;
|
||
import org.openqa.selenium.chrome.ChromeDriver;
|
||
import org.openqa.selenium.support.ui.WebDriverWait;
|
||
import org.openqa.selenium.support.ui.ExpectedConditions;
|
||
import org.junit.jupiter.api.*;
|
||
|
||
public class LoginTest {
|
||
WebDriver driver;
|
||
|
||
@BeforeEach
|
||
void setUp() {
|
||
System.setProperty("webdriver.chrome.driver", "/path/to/chromedriver");
|
||
driver = new ChromeDriver();
|
||
driver.manage().window().maximize();
|
||
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
|
||
}
|
||
|
||
@AfterEach
|
||
void tearDown() {
|
||
if (driver != null) driver.quit();
|
||
}
|
||
|
||
@Test
|
||
void userCanLogIn() {
|
||
driver.get("https://myapp.com/login");
|
||
|
||
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
|
||
|
||
WebElement emailField = wait.until(
|
||
ExpectedConditions.visibilityOfElementLocated(By.id("email"))
|
||
);
|
||
emailField.clear();
|
||
emailField.sendKeys("user@example.com");
|
||
|
||
WebElement passwordField = driver.findElement(By.id("password"));
|
||
passwordField.clear();
|
||
passwordField.sendKeys("s3cure!Pass");
|
||
|
||
WebElement loginButton = wait.until(
|
||
ExpectedConditions.elementToBeClickable(By.cssSelector("button[type='submit']"))
|
||
);
|
||
loginButton.click();
|
||
|
||
wait.until(ExpectedConditions.urlContains("/dashboard"));
|
||
|
||
WebElement heading = wait.until(
|
||
ExpectedConditions.visibilityOfElementLocated(By.tagName("h1"))
|
||
);
|
||
Assertions.assertEquals("Dashboard", heading.getText());
|
||
}
|
||
}
|
||
```
|
||
|
||
**Playwright (TypeScript)**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("user can log in", async ({ page }) => {
|
||
await page.goto("/login")
|
||
|
||
await page.getByLabel("Email").fill("user@example.com")
|
||
await page.getByLabel("Password").fill("s3cure!Pass")
|
||
await page.getByRole("button", { name: "Sign In" }).click()
|
||
|
||
await page.waitForURL("/dashboard")
|
||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Dashboard")
|
||
})
|
||
```
|
||
|
||
**Playwright (JavaScript)**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("user can log in", async ({ page }) => {
|
||
await page.goto("/login")
|
||
|
||
await page.getByLabel("Email").fill("user@example.com")
|
||
await page.getByLabel("Password").fill("s3cure!Pass")
|
||
await page.getByRole("button", { name: "Sign In" }).click()
|
||
|
||
await page.waitForURL("/dashboard")
|
||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Dashboard")
|
||
})
|
||
```
|
||
|
||
**变化总结**:带显式等待、驱动管理和元素引用的 45 行 Java 代码变成了 9 行 Playwright 代码。无需设置、无需清理、无需等待、无需驱动路径。`fill()` 调用替代了 `clear()` + `sendKeys()`。语义化定位器(`getByLabel`、`getByRole`)替代了脆弱的 `By.id` 和 `By.cssSelector`。
|
||
|
||
---
|
||
|
||
### 示例 2:搜索并验证结果
|
||
|
||
演示如何替换 `findElements()`、用于结果数量的显式等待以及文本断言。
|
||
|
||
**Selenium (Java)**
|
||
|
||
```java
|
||
@Test
|
||
void searchReturnsResults() {
|
||
driver.get("https://myapp.com/products");
|
||
|
||
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
|
||
|
||
WebElement searchBox = wait.until(
|
||
ExpectedConditions.visibilityOfElementLocated(By.cssSelector("input[placeholder='Search products']"))
|
||
);
|
||
searchBox.clear();
|
||
searchBox.sendKeys("wireless headphones");
|
||
searchBox.sendKeys(Keys.ENTER);
|
||
|
||
wait.until(ExpectedConditions.numberOfElementsToBeMoreThan(
|
||
By.cssSelector(".product-card"), 0
|
||
));
|
||
|
||
List<WebElement> results = driver.findElements(By.cssSelector(".product-card"));
|
||
Assertions.assertTrue(results.size() >= 3);
|
||
|
||
String firstTitle = results.get(0).findElement(By.cssSelector(".product-title")).getText();
|
||
Assertions.assertTrue(firstTitle.toLowerCase().contains("wireless"));
|
||
}
|
||
```
|
||
|
||
**Playwright (TypeScript)**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("search returns results", async ({ page }) => {
|
||
await page.goto("/products")
|
||
|
||
await page.getByPlaceholder("Search products").fill("wireless headphones")
|
||
await page.getByPlaceholder("Search products").press("Enter")
|
||
|
||
const results = page.getByTestId("product-card")
|
||
await expect(results).toHaveCount(3, { timeout: 10_000 })
|
||
|
||
await expect(results.first().getByTestId("product-title")).toContainText(/wireless/i)
|
||
})
|
||
```
|
||
|
||
**Playwright (JavaScript)**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("search returns results", async ({ page }) => {
|
||
await page.goto("/products")
|
||
|
||
await page.getByPlaceholder("Search products").fill("wireless headphones")
|
||
await page.getByPlaceholder("Search products").press("Enter")
|
||
|
||
const results = page.getByTestId("product-card")
|
||
await expect(results).toHaveCount(3, { timeout: 10_000 })
|
||
|
||
await expect(results.first().getByTestId("product-title")).toContainText(/wireless/i)
|
||
})
|
||
```
|
||
|
||
**变化总结**:不再需要 `WebDriverWait` 等待元素可见。不再需要在 `sendKeys()` 之前调用 `clear()`。不再需要 `findElements()` 返回可能失效的列表。Playwright 的 `toHaveCount()` 会自动重试直到结果出现。`toContainText` 上的正则表达式断言替代了手动的 `getText()` + `toLowerCase()` + `contains()`。
|
||
|
||
---
|
||
|
||
### 示例 3:处理 iframe
|
||
|
||
Selenium 的框架切换是有状态的且容易出错。Playwright 的 `frameLocator` 是作用域限定的且无状态的。
|
||
|
||
**Selenium (Java)**
|
||
|
||
```java
|
||
@Test
|
||
void fillPaymentFormInIframe() {
|
||
driver.get("https://myapp.com/checkout");
|
||
|
||
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
|
||
|
||
// 切换到支付 iframe
|
||
wait.until(ExpectedConditions.frameToBeAvailableAndSwitchToIt(
|
||
By.cssSelector("iframe#payment-frame")
|
||
));
|
||
|
||
// 现在在 iframe 上下文中
|
||
WebElement cardNumber = wait.until(
|
||
ExpectedConditions.visibilityOfElementLocated(By.id("card-number"))
|
||
);
|
||
cardNumber.sendKeys("4242424242424242");
|
||
|
||
driver.findElement(By.id("expiry")).sendKeys("12/28");
|
||
driver.findElement(By.id("cvc")).sendKeys("123");
|
||
|
||
// 在与页面交互之前切换回主内容
|
||
driver.switchTo().defaultContent();
|
||
|
||
driver.findElement(By.id("place-order")).click();
|
||
|
||
wait.until(ExpectedConditions.visibilityOfElementLocated(
|
||
By.cssSelector(".confirmation-message")
|
||
));
|
||
}
|
||
```
|
||
|
||
**Playwright (TypeScript)**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("fill payment form in iframe", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
|
||
// 无需切换——只需限定作用域到框架内
|
||
const paymentFrame = page.frameLocator("#payment-frame")
|
||
await paymentFrame.getByLabel("Card number").fill("4242424242424242")
|
||
await paymentFrame.getByLabel("Expiry").fill("12/28")
|
||
await paymentFrame.getByLabel("CVC").fill("123")
|
||
|
||
// 无需切换回来——主页定位器仍然可用
|
||
await page.getByRole("button", { name: "Place order" }).click()
|
||
|
||
await expect(page.getByText("Order confirmed")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**Playwright (JavaScript)**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("fill payment form in iframe", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
|
||
const paymentFrame = page.frameLocator("#payment-frame")
|
||
await paymentFrame.getByLabel("Card number").fill("4242424242424242")
|
||
await paymentFrame.getByLabel("Expiry").fill("12/28")
|
||
await paymentFrame.getByLabel("CVC").fill("123")
|
||
|
||
await page.getByRole("button", { name: "Place order" }).click()
|
||
|
||
await expect(page.getByText("Order confirmed")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**变化总结**:没有 `switchTo().frame()`。没有 `switchTo().defaultContent()`。没有忘记切换回来的风险。Playwright 的 `frameLocator` 将作用域限定在 iframe 内,而不改变驱动器的全局状态。你可以按任意顺序与主页和 iframe 进行交互。
|
||
|
||
---
|
||
|
||
### 示例 4:处理弹窗和新窗口
|
||
|
||
Selenium 的窗口句柄管理出了名地脆弱。Playwright 使其成为声明式的。
|
||
|
||
**Selenium (Java)**
|
||
|
||
```java
|
||
@Test
|
||
void handlePopupWindow() {
|
||
driver.get("https://myapp.com/settings");
|
||
|
||
String originalWindow = driver.getWindowHandle();
|
||
|
||
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
|
||
|
||
driver.findElement(By.linkText("Connect OAuth Provider")).click();
|
||
|
||
// 等待新窗口出现
|
||
wait.until(ExpectedConditions.numberOfWindowsToBe(2));
|
||
|
||
// 查找并切换到新窗口
|
||
for (String handle : driver.getWindowHandles()) {
|
||
if (!handle.equals(originalWindow)) {
|
||
driver.switchTo().window(handle);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 与弹窗交互
|
||
wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("authorize-btn")));
|
||
driver.findElement(By.id("authorize-btn")).click();
|
||
|
||
// 等待弹窗关闭并切换回来
|
||
wait.until(ExpectedConditions.numberOfWindowsToBe(1));
|
||
driver.switchTo().window(originalWindow);
|
||
|
||
wait.until(ExpectedConditions.visibilityOfElementLocated(
|
||
By.cssSelector(".connection-status.success")
|
||
));
|
||
}
|
||
```
|
||
|
||
**Playwright (TypeScript)**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("handle popup window", async ({ page }) => {
|
||
await page.goto("/settings")
|
||
|
||
// 在触发弹窗的点击之前注册监听器
|
||
const popupPromise = page.waitForEvent("popup")
|
||
await page.getByRole("link", { name: "Connect OAuth Provider" }).click()
|
||
const popup = await popupPromise
|
||
|
||
// 与弹窗交互——它只是另一个 Page 对象
|
||
await popup.getByRole("button", { name: "Authorize" }).click()
|
||
|
||
// 弹窗自动关闭;在原始页面上验证结果
|
||
await expect(page.getByText("Connected successfully")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**Playwright (JavaScript)**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("handle popup window", async ({ page }) => {
|
||
await page.goto("/settings")
|
||
|
||
const popupPromise = page.waitForEvent("popup")
|
||
await page.getByRole("link", { name: "Connect OAuth Provider" }).click()
|
||
const popup = await popupPromise
|
||
|
||
await popup.getByRole("button", { name: "Authorize" }).click()
|
||
|
||
await expect(page.getByText("Connected successfully")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**变化总结**:无需遍历窗口句柄。无需 `switchTo()`。无需追踪原始窗口与新窗口。弹窗是一个你可以直接与之交互的 `Page` 对象。当它关闭后,你只需继续使用原始的 `page`。`waitForEvent('popup')` 模式是声明式的且无竞态条件。
|
||
|
||
---
|
||
|
||
### 示例 5:使用 Actions 进行拖放
|
||
|
||
Selenium 的 `Actions` 类需要链式调用和 `perform()`。Playwright 有直接的方法。
|
||
|
||
**Selenium (Java)**
|
||
|
||
```java
|
||
@Test
|
||
void dragAndDropTask() {
|
||
driver.get("https://myapp.com/kanban");
|
||
|
||
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
|
||
|
||
WebElement sourceCard = wait.until(
|
||
ExpectedConditions.visibilityOfElementLocated(
|
||
By.xpath("//div[@class='card' and contains(text(),'Fix login bug')]")
|
||
)
|
||
);
|
||
WebElement targetColumn = driver.findElement(
|
||
By.xpath("//div[@class='column' and .//h2[text()='Done']]")
|
||
);
|
||
|
||
Actions actions = new Actions(driver);
|
||
actions.dragAndDrop(sourceCard, targetColumn).perform();
|
||
|
||
// 验证卡片已移动
|
||
WebElement doneColumn = driver.findElement(
|
||
By.xpath("//div[@class='column' and .//h2[text()='Done']]")
|
||
);
|
||
WebElement movedCard = doneColumn.findElement(
|
||
By.xpath(".//div[@class='card' and contains(text(),'Fix login bug')]")
|
||
);
|
||
Assertions.assertTrue(movedCard.isDisplayed());
|
||
}
|
||
```
|
||
|
||
**Playwright (TypeScript)**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("drag and drop task to done column", async ({ page }) => {
|
||
await page.goto("/kanban")
|
||
|
||
const card = page.getByText("Fix login bug")
|
||
const doneColumn = page.getByRole("heading", { name: "Done" }).locator("..")
|
||
|
||
await card.dragTo(doneColumn)
|
||
|
||
// 验证卡片现在在 Done 列内
|
||
await expect(doneColumn.getByText("Fix login bug")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**Playwright (JavaScript)**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("drag and drop task to done column", async ({ page }) => {
|
||
await page.goto("/kanban")
|
||
|
||
const card = page.getByText("Fix login bug")
|
||
const doneColumn = page.getByRole("heading", { name: "Done" }).locator("..")
|
||
|
||
await card.dragTo(doneColumn)
|
||
|
||
await expect(doneColumn.getByText("Fix login bug")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**变化总结**:没有 `Actions` 类。没有 `perform()`。没有用于查找元素的 XPath。`dragTo()` 是定位器上的一个单一方法调用。验证使用作用域限定的定位器,而非 XPath 祖先遍历。
|
||
|
||
## 迁移步骤
|
||
|
||
一份实用的、有序的检查清单,用于将 Selenium 套件转换为 Playwright。
|
||
|
||
### 第一步:在 Selenium 旁边设置 Playwright
|
||
|
||
不要在第一天就删除 Selenium。让两者并行运行。
|
||
|
||
```bash
|
||
# 在现有项目中安装 Playwright
|
||
npm init playwright@latest
|
||
|
||
# 这会创建:
|
||
# - playwright.config.ts (或 .js)
|
||
# - tests/ 目录
|
||
# - package.json 依赖
|
||
```
|
||
|
||
在 `playwright.config` 中配置 `baseURL` 以匹配你的 Selenium 目标:
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// playwright.config.ts
|
||
import { defineConfig } from "@playwright/test"
|
||
|
||
export default defineConfig({
|
||
testDir: "./tests-playwright", // 与 Selenium 测试分开
|
||
use: {
|
||
baseURL: "https://staging.myapp.com",
|
||
trace: "on-first-retry",
|
||
},
|
||
retries: process.env.CI ? 2 : 0,
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// playwright.config.js
|
||
const { defineConfig } = require("@playwright/test")
|
||
|
||
module.exports = defineConfig({
|
||
testDir: "./tests-playwright",
|
||
use: {
|
||
baseURL: "https://staging.myapp.com",
|
||
trace: "on-first-retry",
|
||
},
|
||
retries: process.env.CI ? 2 : 0,
|
||
})
|
||
```
|
||
|
||
### 第二步:先转换 Page Object
|
||
|
||
如果你有 Selenium 的 page object,将它们转换为 Playwright 的 page object。模式类似但更简单。
|
||
|
||
**Selenium Page Object (Java)**
|
||
|
||
```java
|
||
public class LoginPage {
|
||
private WebDriver driver;
|
||
private WebDriverWait wait;
|
||
|
||
private By emailField = By.id("email");
|
||
private By passwordField = By.id("password");
|
||
private By loginButton = By.cssSelector("button[type='submit']");
|
||
private By errorMessage = By.cssSelector(".error-message");
|
||
|
||
public LoginPage(WebDriver driver) {
|
||
this.driver = driver;
|
||
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
|
||
}
|
||
|
||
public void login(String email, String password) {
|
||
wait.until(ExpectedConditions.visibilityOfElementLocated(emailField));
|
||
driver.findElement(emailField).clear();
|
||
driver.findElement(emailField).sendKeys(email);
|
||
driver.findElement(passwordField).clear();
|
||
driver.findElement(passwordField).sendKeys(password);
|
||
driver.findElement(loginButton).click();
|
||
}
|
||
|
||
public String getErrorMessage() {
|
||
return wait.until(
|
||
ExpectedConditions.visibilityOfElementLocated(errorMessage)
|
||
).getText();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Playwright Page Object (TypeScript)**
|
||
|
||
```typescript
|
||
// page-objects/login-page.ts
|
||
import { type Page, type Locator, expect } from "@playwright/test"
|
||
|
||
export class LoginPage {
|
||
private readonly emailField: Locator
|
||
private readonly passwordField: Locator
|
||
private readonly loginButton: Locator
|
||
private readonly errorMessage: Locator
|
||
|
||
constructor(private readonly page: Page) {
|
||
this.emailField = page.getByLabel("Email")
|
||
this.passwordField = page.getByLabel("Password")
|
||
this.loginButton = page.getByRole("button", { name: "Sign In" })
|
||
this.errorMessage = page.getByRole("alert")
|
||
}
|
||
|
||
async login(email: string, password: string) {
|
||
await this.emailField.fill(email)
|
||
await this.passwordField.fill(password)
|
||
await this.loginButton.click()
|
||
}
|
||
|
||
async expectError(message: string) {
|
||
await expect(this.errorMessage).toHaveText(message)
|
||
}
|
||
}
|
||
```
|
||
|
||
**Playwright Page Object (JavaScript)**
|
||
|
||
```javascript
|
||
// page-objects/login-page.js
|
||
const { expect } = require("@playwright/test")
|
||
|
||
class LoginPage {
|
||
constructor(page) {
|
||
this.page = page
|
||
this.emailField = page.getByLabel("Email")
|
||
this.passwordField = page.getByLabel("Password")
|
||
this.loginButton = page.getByRole("button", { name: "Sign In" })
|
||
this.errorMessage = page.getByRole("alert")
|
||
}
|
||
|
||
async login(email, password) {
|
||
await this.emailField.fill(email)
|
||
await this.passwordField.fill(password)
|
||
await this.loginButton.click()
|
||
}
|
||
|
||
async expectError(message) {
|
||
await expect(this.errorMessage).toHaveText(message)
|
||
}
|
||
}
|
||
|
||
module.exports = { LoginPage }
|
||
```
|
||
|
||
Page Object 的关键区别:
|
||
|
||
- 定位器在构造函数中定义,而不是作为 `By` 对象——它们是惰性的,永远不会失效
|
||
- 任何地方都不再需要 `WebDriverWait`
|
||
- `fill()` 之前不再需要 `clear()`
|
||
- 断言可以放在 page object 内部(`expectError`),因为它们会自动重试
|
||
|
||
### 第三步:按优先级转换测试
|
||
|
||
从最有价值且最不稳定的测试开始。按以下顺序转换:
|
||
|
||
1. **冒烟测试**——价值最高,每次部署都运行
|
||
2. **不稳定测试**——Playwright 的自动等待消除了大部分不稳定性
|
||
3. **慢速测试**——Playwright 的并行性和更快的协议使它们更快
|
||
4. **其余所有测试**——对剩余套件进行批量转换
|
||
|
||
对于每个测试,应用上面的 API 映射表。机械翻译步骤如下:
|
||
|
||
1. 删除所有 `WebDriverWait` 和 `ExpectedConditions`
|
||
2. 将 `findElement(By.xxx)` 替换为语义化定位器(`getByRole`、`getByLabel`、`getByText`)
|
||
3. 将 `sendKeys` 替换为 `fill`(或逐字符输入时使用 `pressSequentially`)
|
||
4. 将 `Assert` / `assertEquals` 替换为 `expect(locator).toHaveText()` / `toBeVisible()` 等
|
||
5. 删除 `setUp` 和 `tearDown`——Playwright Test 处理浏览器生命周期
|
||
6. 彻底删除 `Thread.sleep()` / `time.sleep()`
|
||
|
||
### 第四步:替换测试基础设施
|
||
|
||
| Selenium 基础设施 | Playwright 等价方案 |
|
||
| --- | --- |
|
||
| Selenium Grid / Hub | `npx playwright test --shard=1/4`(内置分片) |
|
||
| BrowserStack / SauceLabs | 通常不再需要;Playwright 在本地运行 3 个浏览器。如有需要可在 CI 上用于 Safari |
|
||
| WebDriverManager | `npx playwright install`(一条命令,所有浏览器) |
|
||
| TestNG XML 套件 | `playwright.config` 中的 `projects` 数组 |
|
||
| JUnit @Tag / pytest marks | `test.describe()` 分组 + `--grep` 过滤 |
|
||
| Allure / ExtentReports | 内置 HTML 报告器(`npx playwright show-report`)+ trace viewer |
|
||
| 失败时截图 | 内置:`use: { screenshot: 'only-on-failure' }` |
|
||
| 视频录制 | 内置:`use: { video: 'on-first-retry' }` |
|
||
|
||
### 第五步:移除 Selenium
|
||
|
||
一旦所有测试在 Playwright 中通过并且在 CI 中至少连续两周保持绿色:
|
||
|
||
1. 删除 Selenium 测试文件
|
||
2. 移除 Selenium 依赖(`selenium-webdriver`、`chromedriver`、`geckodriver`)
|
||
3. 移除 Selenium Grid 基础设施
|
||
4. 更新 CI 流水线,仅运行 Playwright
|
||
5. 删除 `setUp` / `tearDown` 样板类
|
||
|
||
## 常见陷阱
|
||
|
||
### 陷阱 1:`findElement` 会抛出异常,而 `locator()` 不会
|
||
|
||
在 Selenium 中,`driver.findElement(By.id("missing"))` 会立即抛出 `NoSuchElementException`。在 Playwright 中,`page.locator('#missing')` 会返回一个定位器对象,而不查询 DOM。它只会在你对其执行操作且元素在超时时间内未出现时才会抛出异常。
|
||
|
||
```typescript
|
||
// 这不会抛出异常——定位器是惰性的
|
||
const missing = page.locator("#does-not-exist")
|
||
|
||
// 这会在超时后抛出异常——因为操作在等待,而元素从未出现
|
||
await missing.click() // actionTimeout 后的 TimeoutError
|
||
```
|
||
|
||
**影响**:如果你的 Selenium 测试在 `findElement` 周围使用 try-catch 来检查元素是否存在,请将其替换为断言:
|
||
|
||
```typescript
|
||
// Selenium 模式(不要复制)
|
||
// try { driver.findElement(By.id("error")); fail(); } catch (NoSuchElementException e) { /* 预期中的 */ }
|
||
|
||
// Playwright 等价写法
|
||
await expect(page.locator("#error")).not.toBeVisible()
|
||
```
|
||
|
||
### 陷阱 2:`sendKeys` 追加内容,`fill` 替换内容
|
||
|
||
Selenium 的 `sendKeys("text")` 会追加到现有值之后。如果字段已经包含"hello"并且你执行 `sendKeys("world")`,你会得到"helloworld"。Playwright 的 `fill("text")` 会先清除字段,然后设置值。你会得到"text"。
|
||
|
||
```typescript
|
||
// Selenium 行为:追加
|
||
// element.sendKeys("world"); // 字段: "helloworld"
|
||
|
||
// Playwright 行为:替换
|
||
await locator.fill("world") // 字段: "world"
|
||
|
||
// 要复制 Selenium 的追加行为:
|
||
await locator.pressSequentially("world") // 逐个字符输入,追加到现有值之后
|
||
```
|
||
|
||
### 陷阱 3:没有 StaleElementReferenceException
|
||
|
||
在 Selenium 中,存储一个 `WebElement` 引用并在 DOM 变化后使用它会抛出 `StaleElementReferenceException`。这是 Selenium 测试不稳定性的最常见来源。
|
||
|
||
```java
|
||
// Selenium——这可能会抛出 StaleElementReferenceException
|
||
WebElement button = driver.findElement(By.id("submit"));
|
||
// ... 某些操作导致 DOM 重新渲染 ...
|
||
button.click(); // 崩溃——StaleElementReferenceException
|
||
```
|
||
|
||
在 Playwright 中,定位器会在每次操作时重新查询 DOM。可以自由存储定位器。
|
||
|
||
```typescript
|
||
// Playwright——这始终有效
|
||
const button = page.getByRole("button", { name: "Submit" })
|
||
// ... 某些操作导致 DOM 重新渲染 ...
|
||
await button.click() // 正常工作——自动重新查询 DOM
|
||
```
|
||
|
||
### 陷阱 4:没有全局驱动器状态
|
||
|
||
Selenium 有一个单一的 `driver` 实例,带有全局状态:当前框架、当前窗口、隐式等待超时。调用 `switchTo().frame()` 会改变所有后续调用的状态。忘记调用 `switchTo().defaultContent()` 会导致之后的每个 `findElement` 都失败。
|
||
|
||
Playwright 没有全局状态。`page.frameLocator()` 返回一个作用域限定的对象。`context.pages()` 给你所有页面。没有任何东西会改变"当前"上下文。
|
||
|
||
```typescript
|
||
// Selenium 思维模型:"我现在在哪里?"
|
||
// driver.switchTo().frame("payment"); // 我在支付框架中
|
||
// driver.findElement(...); // 这会在支付框架中查找
|
||
// driver.switchTo().defaultContent(); // 现在我在主页中
|
||
// ... 忘记这一行,一切都会崩溃
|
||
|
||
// Playwright 思维模型:"我总是明确说出我在哪里查找"
|
||
const paymentFrame = page.frameLocator("#payment")
|
||
await paymentFrame.getByLabel("Card").fill("4242...") // 限定在框架内
|
||
await page.getByRole("button", { name: "Pay" }).click() // 主页面——无需切换
|
||
```
|
||
|
||
### 陷阱 5:断言必须使用 await
|
||
|
||
在 Selenium (Java) 中,断言是同步的:`assertEquals("Dashboard", heading.getText())`。在 Playwright 中,web 优先断言是异步的,必须使用 await:
|
||
|
||
```typescript
|
||
// 错误——断言分离运行,测试可能在其解析之前就通过了
|
||
expect(page.getByRole("heading")).toHaveText("Dashboard") // 缺少 await!
|
||
|
||
// 正确
|
||
await expect(page.getByRole("heading")).toHaveText("Dashboard")
|
||
```
|
||
|
||
缺少 `await` 是 Playwright 初学者的头号错误。你的 linter 应该能标记这一点。启用 `@typescript-eslint/no-floating-promises` 或 Playwright ESLint 插件。
|
||
|
||
### 陷阱 6:默认并行
|
||
|
||
Selenium 测试通常顺序执行(一个浏览器,一个线程)。Playwright Test 默认并行运行测试文件。这意味着:
|
||
|
||
- 测试必须是隔离的——测试文件之间没有共享状态
|
||
- 每个测试都有自己独立的浏览器上下文(全新的 cookie、存储、会话)
|
||
- 并行测试之间的数据库 fixture 不能冲突
|
||
|
||
如果你的 Selenium 套件依赖于执行顺序,你必须在迁移之前修复这个问题。隔离策略请参见 [core/test-organization.md](../core/test-organization.md)。
|
||
|
||
## Playwright 的优势
|
||
|
||
这些不仅仅是语法差异。这些是 Selenium 不具备的能力。
|
||
|
||
### 无处不在的自动等待
|
||
|
||
每个操作、每个断言、每次导航都会自动等待。你无需编写任何等待代码。仅此一项就能消除 60-80% 的 Selenium 测试不稳定性。
|
||
|
||
### Trace Viewer
|
||
|
||
当测试在 CI 中失败时,Playwright 会捕获一个 trace:每一步的截图、DOM 快照、网络请求、控制台日志。使用 `npx playwright show-report` 打开并逐步查看确切的失败过程。Selenium 没有与之相当的工具。
|
||
|
||
```typescript
|
||
// playwright.config.ts——在首次重试时启用 trace
|
||
export default defineConfig({
|
||
use: {
|
||
trace: "on-first-retry",
|
||
},
|
||
})
|
||
```
|
||
|
||
### 内置并行执行
|
||
|
||
Playwright Test 零配置即可并行运行测试文件。无需 Selenium Grid。无需 TestNG 并行套件 XML。无需 pytest-xdist。
|
||
|
||
```bash
|
||
# 并行运行所有测试(默认)
|
||
npx playwright test
|
||
|
||
# 跨 CI 机器分片
|
||
npx playwright test --shard=1/4 # 机器 1
|
||
npx playwright test --shard=2/4 # 机器 2
|
||
```
|
||
|
||
### 多浏览器,同一 API
|
||
|
||
同一个测试无需修改代码即可在 Chromium、Firefox 和 WebKit 上运行。在 `playwright.config` 中配置:
|
||
|
||
```typescript
|
||
export default defineConfig({
|
||
projects: [
|
||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
|
||
],
|
||
})
|
||
```
|
||
|
||
Selenium 需要单独的驱动二进制文件,并且通常需要特定浏览器的变通方案。
|
||
|
||
### 网络拦截
|
||
|
||
Playwright 可以原生地拦截、修改和模拟网络请求。Selenium 无法做到。
|
||
|
||
```typescript
|
||
// 模拟 API 响应——在 Selenium 中不可能
|
||
await page.route("**/api/users", (route) => {
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify([{ name: "Mock User" }]),
|
||
})
|
||
})
|
||
```
|
||
|
||
### 浏览器上下文隔离
|
||
|
||
每个测试都会获得一个全新的浏览器上下文(就像隐身窗口)。Cookie、localStorage 和会话在测试之间是隔离的,无需重启浏览器。Selenium 需要 `driver.quit()` 和 `new ChromeDriver()` 才能实现真正的隔离。
|
||
|
||
### 测试 Fixture
|
||
|
||
Playwright 的 fixture 系统为测试设置和清理提供了依赖注入。即使测试崩溃,fixture 也能保证清理。Selenium 依赖于 `@BeforeEach` / `@AfterEach`,在硬性失败时会跳过清理。
|
||
|
||
```typescript
|
||
// 用于已认证用户的自定义 fixture——可在所有测试中复用
|
||
import { test as base } from "@playwright/test"
|
||
|
||
export const test = base.extend({
|
||
authenticatedPage: async ({ page }, use) => {
|
||
await page.goto("/login")
|
||
await page.getByLabel("Email").fill("admin@example.com")
|
||
await page.getByLabel("Password").fill("password")
|
||
await page.getByRole("button", { name: "Sign In" }).click()
|
||
await page.waitForURL("/dashboard")
|
||
await use(page)
|
||
// 即使崩溃,清理也会自动运行
|
||
},
|
||
})
|
||
```
|
||
|
||
### Codegen
|
||
|
||
通过录制浏览器交互来生成测试:
|
||
|
||
```bash
|
||
npx playwright codegen https://myapp.com
|
||
```
|
||
|
||
这会打开一个浏览器,并将你的点击、输入和导航记录为 Playwright 测试代码。Selenium 中没有等价工具。
|
||
|
||
### UI 模式
|
||
|
||
通过时间旅行直观地调试测试:
|
||
|
||
```bash
|
||
npx playwright test --ui
|
||
```
|
||
|
||
在每一步中前进和后退,查看每个时刻的 DOM 状态、网络请求和控制台日志。Selenium 最接近的等价工具是手动的 `Thread.sleep()` 和截图调试。
|
||
|
||
## 相关文档
|
||
|
||
- [core/locators.md](../core/locators.md)——定位器策略优先级和模式
|
||
- [core/assertions-and-waiting.md](../core/assertions-and-waiting.md)——深入介绍自动等待和 web 优先断言
|
||
- [core/configuration.md](../core/configuration.md)——配置 `playwright.config`
|
||
- [core/page-object-model.md](../core/page-object-model.md)——Playwright 的 page object 模式
|
||
- [core/fixtures-and-hooks.md](../core/fixtures-and-hooks.md)——用于测试设置和隔离的 fixture 系统
|
||
- [core/test-organization.md](../core/test-organization.md)——组织测试以支持并行执行
|
||
- [migration/from-cypress.md](from-cypress.md)——从 Cypress 迁移(替代方案)
|