Playwright 完整实战——现代 E2E 测试框架的最强上手指南(Java 版)
Playwright 完整实战——现代 E2E 测试框架的最强上手指南(Java 版)
适读人群:Java 开发者 / 测试工程师 | 阅读时长:约 18 分钟 | 核心价值:从零搭建 Playwright Java 测试体系,覆盖安装、定位、断言、等待机制全流程
那个让我们连夜修 BUG 的夜晚
2023 年深秋,我们团队发布了一个电商项目的支付流程改版。上线前,QA 小刘把所有手工测试用例跑了三遍,没发现问题,PM 拍板上线。
结果上线后四小时,客服那边炸锅了——有用户反映,在某些安卓机的微信内置浏览器里,点"确认支付"按钮没反应。小刘赶紧拿自己手机测,没复现。用模拟器测,也没复现。
直到凌晨一点,前端阿明才发现:我们的按钮点击事件监听器注册逻辑有个异步时序问题,在低端机上,DOM 渲染完成后 200ms 内点击按钮是无效的。这个问题如果有自动化 E2E 测试,加一个合理的等待机制,早就能发现。
那次之后,我下决心把团队的 E2E 测试建起来。当时调研了好几个框架,最后选了 Playwright。用了将近一年,踩了不少坑,今天全部写出来。
为什么选 Playwright 而不是 Selenium
在正式写代码之前,我想先说清楚选型理由,这样你后面理解 Playwright 的设计决策会更容易。
Playwright 的核心优势:
自动等待机制:Playwright 在执行每个操作之前,会自动等待元素处于"可操作状态"——可见、稳定、未被遮挡、已启用。这一点直接干掉了 Selenium 里 90% 的
Thread.sleep()和手写WebDriverWait。跨浏览器一致性:Playwright 同时支持 Chromium、Firefox、WebKit,用同一套 API,一份代码三个浏览器跑。
网络拦截能力:可以拦截并修改 HTTP 请求和响应,做 Mock 非常方便,不需要额外的代理工具。
并行执行:天然支持测试并行,每个测试 Worker 有独立的浏览器上下文,互不干扰。
Trace Viewer:测试失败后可以回放完整操作轨迹,包括截图、网络请求、Console 日志,排查问题效率极高。
这些特性在 Selenium 里要么没有,要么需要大量额外配置。对于从零开始建 E2E 测试体系的团队,Playwright 的上手门槛更低,维护成本更小。
环境搭建
Maven 依赖
<dependencies>
<!-- Playwright Java -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.40.0</version>
</dependency>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
<!-- Allure 报告(可选) -->
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-junit5</artifactId>
<version>2.24.0</version>
<scope>test</scope>
</dependency>
</dependencies>安装浏览器驱动
Playwright 不依赖系统已安装的浏览器,它有自己的浏览器版本管理:
# 通过 Maven 执行安装命令
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install"
# 或者只安装 Chromium(CI 环境减小体积)
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"执行完毕后,Playwright 会把浏览器下载到 ~/.cache/ms-playwright/ 目录。
第一个 Playwright 测试
先写一个最简单的例子,感受一下整体流程:
import com.microsoft.playwright.*;
import org.junit.jupiter.api.*;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
public class FirstPlaywrightTest {
static Playwright playwright;
static Browser browser;
BrowserContext context;
Page page;
@BeforeAll
static void launchBrowser() {
playwright = Playwright.create();
browser = playwright.chromium().launch(
new BrowserType.LaunchOptions().setHeadless(false) // 本地调试时设为 false
);
}
@AfterAll
static void closeBrowser() {
playwright.close();
}
@BeforeEach
void createContextAndPage() {
context = browser.newContext();
page = context.newPage();
}
@AfterEach
void closeContext() {
context.close();
}
@Test
void shouldDisplayLoginPage() {
page.navigate("https://your-app.example.com/login");
// Playwright 的 assertThat 支持自动重试,默认等待 5 秒
assertThat(page.locator("h1")).hasText("用户登录");
assertThat(page.locator("#username")).isVisible();
assertThat(page.locator("#password")).isVisible();
assertThat(page.locator("button[type='submit']")).isEnabled();
}
@Test
void shouldLoginSuccessfully() {
page.navigate("https://your-app.example.com/login");
page.locator("#username").fill("testuser@example.com");
page.locator("#password").fill("Test@123456");
page.locator("button[type='submit']").click();
// 等待导航完成后断言
assertThat(page).hasURL("https://your-app.example.com/dashboard");
assertThat(page.locator(".welcome-message")).containsText("欢迎回来");
}
}注意几个关键点:
Playwright和Browser对象是重量级对象,用@BeforeAll创建,整个测试类共享BrowserContext和Page是轻量级对象,每个测试方法独立创建,保证隔离性assertThat来自 Playwright 自己的断言库,支持自动等待重试,不是 JUnit 的assertEquals
元素定位策略
Playwright 推荐优先使用"语义化定位器",其次才是 CSS/XPath。
推荐优先级
// 1. 最优先:用户可见文本
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("提交订单")).click();
page.getByLabel("用户名").fill("testuser");
page.getByPlaceholder("请输入搜索关键词").fill("Playwright");
page.getByText("登录成功").isVisible();
page.getByAltText("用户头像").isVisible();
// 2. 测试专用 ID(推荐在代码中添加 data-testid 属性)
page.getByTestId("submit-button").click(); // 对应 <button data-testid="submit-button">
// 3. CSS 选择器(当以上方式不适用时)
page.locator("#username").fill("testuser");
page.locator(".product-card:first-child .add-to-cart").click();
// 4. XPath(最后手段,尽量避免)
page.locator("//button[contains(@class, 'primary') and text()='确认']").click();定位器链式调用
// 在特定容器内定位
Locator productList = page.locator(".product-list");
Locator firstProduct = productList.locator(".product-item").first();
firstProduct.locator("button.add-to-cart").click();
// 过滤定位器
page.locator(".product-item")
.filter(new Locator.FilterOptions().setHasText("特价商品"))
.locator("button.add-to-cart")
.click();
// 按索引
page.locator(".tab-item").nth(2).click(); // 第三个 tab(从 0 开始)踩坑实录
坑一:element is not attached to the DOM
现象: 测试时偶发 com.microsoft.playwright.PlaywrightException: element is not attached to the DOM,尤其在 SPA 页面路由切换后操作元素时。
原因: 在 React/Vue 等 SPA 框架中,路由切换会销毁旧 DOM 并挂载新 DOM。如果你的 Locator 对象是在路由切换前创建的,它持有的是对旧 DOM 节点的引用,切换后这个节点被销毁,再操作就报这个错。
解法: Playwright 的 Locator 是"懒惰"的——它描述的是如何找到元素的查询,而不是元素本身的引用。只要每次操作前重新走查询流程,就不会有这个问题。正确做法是不要把 .element() 或底层 DOM 引用存到变量里,永远通过 Locator 对象操作:
// 错误做法:
ElementHandle button = page.querySelector("button.submit"); // 拿到了 DOM 引用
page.navigate("/other-page"); // 路由切换
button.click(); // 崩了,button 已经不在 DOM 里了
// 正确做法:
Locator submitButton = page.locator("button.submit"); // 只是描述了查询
page.navigate("/other-page");
submitButton.click(); // Playwright 会在 click 时重新查询 DOM坑二:Strict mode violation
现象: com.microsoft.playwright.PlaywrightException: strict mode violation: locator('.icon') resolved to 12 elements
原因: Playwright 的严格模式要求定位器必须唯一匹配一个元素,当匹配到多个时报错。这是为了防止"无意中操作了错误的元素"。
解法: 让定位器更精确,或者明确声明"我要操作第几个":
// 方案一:缩小定位范围
page.locator(".user-profile-section .icon").click();
// 方案二:加文本过滤
page.locator(".icon").filter(new Locator.FilterOptions().setHasText("编辑")).click();
// 方案三:明确指定第几个(不推荐,脆弱)
page.locator(".icon").first().click();
page.locator(".icon").nth(0).click();坑三:自动等待也有等不到的时候
现象: 点击按钮后页面数据加载,但 Playwright 的断言超时了,即使延长 timeout 也不稳定。
原因: Playwright 的自动等待只能等待 DOM 状态,无法自动感知"业务数据是否加载完毕"。比如一个列表页面,loading spinner 消失了(DOM 层面"加载完毕"),但接口还在请求中,数据还没渲染。
解法: 显式等待业务状态完成:
// 方案一:等待特定的数据元素出现
page.locator(".product-item").first().waitFor(
new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE)
);
// 方案二:等待网络请求完成
page.waitForResponse(
response -> response.url().contains("/api/products") && response.status() == 200,
() -> page.locator("button.load-more").click()
);
// 方案三:等待 loading 状态消失
page.locator(".loading-spinner").waitFor(
new Locator.WaitForOptions().setState(WaitForSelectorState.HIDDEN)
);
// 方案四:等待页面到达稳定的网络状态(谨慎使用,可能很慢)
page.waitForLoadState(LoadState.NETWORKIDLE);表单操作完整示例
@Test
void shouldCompleteCheckoutFlow() {
// 步骤一:登录
page.navigate(BASE_URL + "/login");
page.getByLabel("邮箱").fill("buyer@test.com");
page.getByLabel("密码").fill("Password123!");
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("登录")).click();
assertThat(page).hasURL(BASE_URL + "/home");
// 步骤二:搜索商品
page.getByPlaceholder("搜索商品").fill("无线耳机");
page.keyboard().press("Enter");
// 等待搜索结果加载
assertThat(page.locator(".search-result-item")).hasCountGreaterThan(0);
// 步骤三:添加到购物车
page.locator(".search-result-item").first()
.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("加入购物车"))
.click();
// 验证购物车数量更新
assertThat(page.locator(".cart-badge")).hasText("1");
// 步骤四:进入结算
page.locator(".cart-icon").click();
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("去结算")).click();
assertThat(page).hasURL(BASE_URL + "/checkout");
// 步骤五:填写收货地址
page.getByLabel("收货人姓名").fill("张三");
page.getByLabel("手机号").fill("13812345678");
page.getByLabel("详细地址").fill("北京市朝阳区某某路100号");
// 选择支付方式
page.getByLabel("微信支付").check();
// 提交订单
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("提交订单")).click();
// 验证跳转到支付页面
assertThat(page).hasURL(new Pattern(".*\\/pay\\/order.*"));
assertThat(page.locator(".order-amount")).isVisible();
}处理弹窗和新标签页
// 处理浏览器原生对话框(alert / confirm / prompt)
page.onDialog(dialog -> {
System.out.println("对话框类型: " + dialog.type());
System.out.println("对话框消息: " + dialog.message());
if (dialog.type().equals("confirm")) {
dialog.accept(); // 点击确定
} else {
dialog.dismiss(); // 点击取消
}
});
// 处理新标签页
Page newPage = context.waitForPage(
() -> page.locator("a[target='_blank']").click()
);
newPage.waitForLoadState();
assertThat(newPage).hasURL(new Pattern(".*some-expected-url.*"));
// 文件下载
Download download = page.waitForDownload(
() -> page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("导出报表")).click()
);
Path downloadPath = download.path();
System.out.println("文件下载到: " + downloadPath);网络拦截与 Mock
这是 Playwright 相比 Selenium 的重大优势之一:
@Test
void shouldShowErrorMessageWhenApiFailsWithMock() {
// 拦截特定 API 请求并返回错误
page.route("/api/user/profile", route -> {
route.fulfill(new Route.FulfillOptions()
.setStatus(500)
.setContentType("application/json")
.setBody("{\"error\": \"Internal Server Error\"}")
);
});
page.navigate(BASE_URL + "/profile");
// 验证错误提示显示
assertThat(page.locator(".error-message")).isVisible();
assertThat(page.locator(".error-message")).containsText("加载失败");
}
@Test
void shouldHandleSlowNetworkWithMock() {
// 模拟慢网络
page.route("/api/products", route -> {
try {
Thread.sleep(3000); // 延迟 3 秒
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
route.resume(); // 继续真实请求
});
page.navigate(BASE_URL + "/products");
// 验证 loading 状态显示
assertThat(page.locator(".loading-spinner")).isVisible();
// 等待加载完成
assertThat(page.locator(".loading-spinner")).isHidden(
new LocatorAssertions.IsHiddenOptions().setTimeout(10000)
);
}截图和 Trace
测试失败时,截图和 Trace 是排查问题的利器:
@AfterEach
void captureOnFailure(TestInfo testInfo) {
// 测试失败时截图
if (testInfo.getTestMethod().isPresent()) {
String screenshotPath = "test-results/screenshots/"
+ testInfo.getDisplayName() + "-"
+ System.currentTimeMillis() + ".png";
page.screenshot(new Page.ScreenshotOptions()
.setPath(Paths.get(screenshotPath))
.setFullPage(true)
);
}
}开启 Trace(推荐 CI 环境失败时自动开启):
@BeforeEach
void startTrace() {
context.tracing().start(new Tracing.StartOptions()
.setScreenshots(true)
.setSnapshots(true)
.setSources(true)
);
}
@AfterEach
void stopTrace(TestInfo testInfo) {
context.tracing().stop(new Tracing.StopOptions()
.setPath(Paths.get("test-results/traces/" + testInfo.getDisplayName() + ".zip"))
);
}Trace 文件可以用以下命令在浏览器中可视化回放:
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI \
-D exec.args="show-trace test-results/traces/mytest.zip"完整项目结构建议
src/test/java/
├── base/
│ └── BaseTest.java # Playwright 初始化、截图、Trace 等公共逻辑
├── pages/ # Page Object Model(下一篇详解)
│ ├── LoginPage.java
│ ├── HomePage.java
│ └── CheckoutPage.java
├── tests/
│ ├── LoginTest.java
│ ├── SearchTest.java
│ └── CheckoutTest.java
└── utils/
├── TestDataHelper.java # 测试数据辅助
└── WaitHelper.java # 封装等待逻辑BaseTest.java 的基本结构:
public class BaseTest {
protected static Playwright playwright;
protected static Browser browser;
protected BrowserContext context;
protected Page page;
@BeforeAll
static void setup() {
playwright = Playwright.create();
boolean headless = Boolean.parseBoolean(
System.getProperty("headless", "true")
);
browser = playwright.chromium().launch(
new BrowserType.LaunchOptions()
.setHeadless(headless)
.setSlowMo(headless ? 0 : 50) // 本地调试时加慢放
);
}
@AfterAll
static void teardown() {
if (playwright != null) playwright.close();
}
@BeforeEach
void beforeEach() {
context = browser.newContext(new Browser.NewContextOptions()
.setViewportSize(1920, 1080)
.setLocale("zh-CN")
.setTimezoneId("Asia/Shanghai")
);
page = context.newPage();
}
@AfterEach
void afterEach() {
if (context != null) context.close();
}
}小结
Playwright Java 的核心设计哲学是"让测试代码专注于业务逻辑,而不是等待逻辑"。自动等待、语义化定位器、Trace Viewer 这三个特性,是它相比 Selenium 最核心的竞争力。
给刚入门的同学几个关键建议:
- 优先使用
getByRole、getByLabel、getByTestId定位,少用 CSS,几乎不用 XPath - 用
assertThat做断言,不要用 JUnit 的assertEquals+locator.textContent() Browser类对象是重量级的,整个测试类共享;BrowserContext和Page是轻量级的,每个测试方法独立创建- 遇到稳定性问题,先开 Trace 回放,再加等待逻辑,不要无脑加
Thread.sleep()
下一篇我会写 Playwright Java 的进阶内容:页面对象模式、截图对比测试、视频录制。
