Playwright Java 进阶——页面对象模式、截图对比、视频录制
Playwright Java 进阶——页面对象模式、截图对比、视频录制
适读人群:已掌握 Playwright 基础的 Java 测试工程师 | 阅读时长:约 16 分钟 | 核心价值:用 POM 模式重构测试架构,掌握视觉回归测试与调试利器
一次代码 Review 引发的重构
去年我们团队做完第一批 Playwright 测试,大概写了 30 多个测试用例,我做了一次集中 Code Review,发现一个严重问题:登录操作的代码出现在了 23 个测试文件里,每个文件都各自写了一遍:
page.navigate(BASE_URL + "/login");
page.locator("#username").fill("testuser@example.com");
page.locator("#password").fill("Test@123456");
page.locator("button[type='submit']").click();
assertThat(page).hasURL(BASE_URL + "/dashboard");然后有一天,前端把登录按钮的选择器从 button[type='submit'] 改成了 button.login-btn,测试工程师小王花了整整半天改这 23 个文件。
改完他跟我说:老张,这也太难维护了,有没有更好的写法?
有,就是页面对象模式(Page Object Model)。
页面对象模式核心思想
POM 的核心思想是:把"如何与页面交互"的细节封装在 Page Object 里,测试代码只描述"做什么",不关心"怎么做"。
测试代码读起来应该像业务流程描述,而不是 UI 操作指令。
登录页对象
package com.example.pages;
import com.microsoft.playwright.Page;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
public class LoginPage {
private final Page page;
private final String baseUrl;
// 定位器作为字段,集中管理,改 selector 只改这里
private final com.microsoft.playwright.Locator usernameInput;
private final com.microsoft.playwright.Locator passwordInput;
private final com.microsoft.playwright.Locator submitButton;
private final com.microsoft.playwright.Locator errorMessage;
public LoginPage(Page page, String baseUrl) {
this.page = page;
this.baseUrl = baseUrl;
this.usernameInput = page.getByLabel("邮箱");
this.passwordInput = page.getByLabel("密码");
this.submitButton = page.getByRole(AriaRole.BUTTON,
new Page.GetByRoleOptions().setName("登录"));
this.errorMessage = page.locator(".login-error-message");
}
public LoginPage navigate() {
page.navigate(baseUrl + "/login");
assertThat(page.locator("h1")).hasText("用户登录");
return this; // 链式调用
}
public LoginPage enterUsername(String username) {
usernameInput.fill(username);
return this;
}
public LoginPage enterPassword(String password) {
passwordInput.fill(password);
return this;
}
public DashboardPage clickLoginButton() {
submitButton.click();
return new DashboardPage(page, baseUrl);
}
// 登录失败场景——返回自身,不跳转
public LoginPage clickLoginButtonExpectingError() {
submitButton.click();
return this;
}
public void verifyErrorMessage(String expectedMessage) {
assertThat(errorMessage).isVisible();
assertThat(errorMessage).containsText(expectedMessage);
}
// 一步完成正常登录
public DashboardPage loginAs(String username, String password) {
return navigate()
.enterUsername(username)
.enterPassword(password)
.clickLoginButton();
}
}首页对象
package com.example.pages;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Locator;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
public class DashboardPage {
private final Page page;
private final String baseUrl;
private final Locator welcomeMessage;
private final Locator navBar;
private final Locator userAvatar;
private final Locator logoutButton;
public DashboardPage(Page page, String baseUrl) {
this.page = page;
this.baseUrl = baseUrl;
this.welcomeMessage = page.locator(".welcome-message");
this.navBar = page.locator("nav.main-nav");
this.userAvatar = page.locator(".user-avatar");
this.logoutButton = page.getByRole(AriaRole.BUTTON,
new Page.GetByRoleOptions().setName("退出登录"));
}
public DashboardPage verifyLoaded() {
assertThat(page).hasURL(baseUrl + "/dashboard");
assertThat(welcomeMessage).isVisible();
return this;
}
public DashboardPage verifyWelcomeMessage(String username) {
assertThat(welcomeMessage).containsText(username);
return this;
}
public ProductListPage goToProducts() {
navBar.locator("a[href='/products']").click();
return new ProductListPage(page, baseUrl);
}
public LoginPage logout() {
userAvatar.click();
logoutButton.click();
return new LoginPage(page, baseUrl);
}
}测试代码变得极其清晰
public class LoginTest extends BaseTest {
@Test
void shouldLoginWithValidCredentials() {
LoginPage loginPage = new LoginPage(page, BASE_URL);
DashboardPage dashboardPage = loginPage
.navigate()
.enterUsername("zhangwei@company.com")
.enterPassword("Welcome@123")
.clickLoginButton();
dashboardPage
.verifyLoaded()
.verifyWelcomeMessage("张威");
}
@Test
void shouldShowErrorWithWrongPassword() {
LoginPage loginPage = new LoginPage(page, BASE_URL);
loginPage
.navigate()
.enterUsername("zhangwei@company.com")
.enterPassword("wrongpassword")
.clickLoginButtonExpectingError()
.verifyErrorMessage("密码错误");
}
@Test
void shouldNavigateToProductsAfterLogin() {
LoginPage loginPage = new LoginPage(page, BASE_URL);
loginPage
.loginAs("buyer@company.com", "Buyer@123")
.goToProducts()
.verifyProductListLoaded();
}
}测试代码读起来像业务流程,和产品 PRD 几乎一样清晰。
截图对比测试(视觉回归)
这是 Playwright 相比 Selenium 另一个很实用的特性。我们用它发现了好几个"功能正常但样式崩了"的 BUG。
基本用法
@Test
void shouldMatchHomepageVisually() {
page.navigate(BASE_URL + "/home");
page.waitForLoadState(LoadState.NETWORKIDLE);
// 第一次运行会生成基准截图,保存在 test-results/snapshots/
// 后续运行会与基准截图做像素级对比
assertThat(page).hasScreenshot("homepage.png");
}
@Test
void shouldMatchLoginFormVisually() {
page.navigate(BASE_URL + "/login");
// 只对特定区域做截图对比
Locator loginForm = page.locator(".login-form");
assertThat(loginForm).hasScreenshot("login-form.png");
}配置截图对比容差
像素级对比太严格会产生大量误报(比如字体渲染差异、动画帧差异)。合理配置容差:
@Test
void shouldMatchProductCardVisually() {
page.navigate(BASE_URL + "/products");
page.locator(".product-card").first().waitFor();
assertThat(page.locator(".product-card").first())
.hasScreenshot("product-card.png",
new LocatorAssertions.HasScreenshotOptions()
.setThreshold(0.2) // 允许 20% 的像素差异
.setMaxDiffPixels(100) // 或者允许最多 100 个像素不同
);
}更新基准截图
当 UI 有意变更后,需要更新基准截图:
# 通过系统属性告诉 Playwright 更新截图
mvn test -Dplaywright.updateSnapshots=true或者在代码里:
// 通过环境变量控制
String updateSnapshot = System.getenv("UPDATE_SNAPSHOTS");
if ("true".equals(updateSnapshot)) {
// 删除旧的基准截图让 Playwright 重新生成
Files.deleteIfExists(Paths.get("test-results/snapshots/homepage.png"));
}视频录制
在 CI 环境里,测试失败的第一反应是"到底发生了什么"。视频录制完整保留了测试的每一步操作,是 Trace 之外最好的辅助工具。
全程录制
@BeforeEach
void createContextWithVideo() {
context = browser.newContext(new Browser.NewContextOptions()
.setRecordVideoDir(Paths.get("test-results/videos/"))
.setRecordVideoSize(new RecordVideoSize(1280, 720))
.setViewportSize(1280, 720)
);
page = context.newPage();
}
@AfterEach
void saveVideoOnFailure(TestInfo testInfo) {
// 先关闭 context,视频才会被保存
context.close();
// 如果需要重命名视频文件
if (page.video() != null) {
Path videoPath = page.video().path();
String newName = testInfo.getDisplayName() + ".webm";
try {
Files.move(videoPath,
videoPath.resolveSibling(newName),
StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
// 忽略重命名失败
}
}
}只在失败时保留视频
录制每个测试的视频会占用大量磁盘空间。更好的策略是只保留失败的测试视频:
public class BaseTest {
protected BrowserContext context;
protected Page page;
private boolean testFailed = false;
@BeforeEach
void setUp() {
context = browser.newContext(new Browser.NewContextOptions()
.setRecordVideoDir(Paths.get("test-results/videos-temp/"))
);
page = context.newPage();
}
@AfterEach
void tearDown(TestInfo testInfo) {
// 在 context.close() 之前标记是否失败
// JUnit 5 通过 TestInfo 可以获取测试结果
context.close(); // 这里触发视频保存
Path videoPath = page.video() != null ? page.video().path() : null;
if (videoPath != null && videoPath.toFile().exists()) {
if (testFailed) {
// 测试失败,把视频移到失败目录
Path failureDir = Paths.get("test-results/videos-failed/");
try {
Files.createDirectories(failureDir);
Files.move(videoPath,
failureDir.resolve(testInfo.getDisplayName() + ".webm"),
StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) { /* ignore */ }
} else {
// 测试通过,删除视频
try {
Files.deleteIfExists(videoPath);
} catch (IOException e) { /* ignore */ }
}
}
}
// 在测试中调用此方法标记失败
protected void markTestFailed() {
this.testFailed = true;
}
}更优雅的方案是用 JUnit 5 的 TestExecutionExceptionHandler:
@ExtendWith(VideoRecordingExtension.class)
public class LoginTest extends BaseTest { ... }
// 扩展实现
public class VideoRecordingExtension implements
AfterEachCallback, TestExecutionExceptionHandler {
private boolean failed = false;
@Override
public void handleTestExecutionException(ExtensionContext ctx, Throwable t) throws Throwable {
failed = true;
throw t; // 重新抛出,不吞掉异常
}
@Override
public void afterEach(ExtensionContext ctx) {
// 根据 failed 决定是否保留视频
if (!failed) {
// 删除视频
}
failed = false;
}
}踩坑实录
坑一:视频文件为空或损坏
现象: 录制的视频文件大小为 0KB,或者播放时报错。
原因: 视频文件在 context.close() 之后才完成写入。如果在 context.close() 之前就去访问 page.video().path(),要么路径不存在,要么文件还没写完。
解法: 必须按顺序:先 context.close(),再访问 page.video().path()。
// 错误顺序
Path videoPath = page.video().path(); // 文件还没写完
context.close();
Files.move(videoPath, ...); // 可能失败
// 正确顺序
context.close(); // 先关闭,触发视频写入完成
Path videoPath = page.video().path(); // 这时候文件才完整
Files.move(videoPath, ...);坑二:截图对比在不同操作系统上失败
现象: 本地 Mac 生成的基准截图,在 Linux CI 服务器上运行时截图对比失败,即使 UI 没有任何变化。
原因: 字体渲染在不同操作系统上有差异,即使是 Playwright 自带浏览器也会因为系统字体不同而渲染出微小差别,导致像素对比失败。
解法:
- 使用容差参数(
setThreshold)容忍小差异 - 基准截图在 CI 服务器(Linux)上生成,而不是在本地 Mac 上生成
- 对比区域避开文字密集区域,只对比关键布局结构
assertThat(page.locator(".product-card").first())
.hasScreenshot("product-card.png",
new LocatorAssertions.HasScreenshotOptions()
.setThreshold(0.3) // 适当放大容差
);坑三:POM 中的 Locator 在页面刷新后失效
现象: Page Object 初始化时正常,但页面刷新或路由切换后,Locator 操作报错。
原因: 虽然 Playwright 的 Locator 是惰性的,理论上不会持有 DOM 引用,但如果在构造函数里用了 page.querySelector() 等立即执行查询的 API,就会拿到 DOM 引用而非 Locator 描述。
解法: Page Object 的字段一律用 page.locator()、page.getByRole() 等返回 Locator 对象的方法,绝对不用 page.querySelector() 返回 ElementHandle。
多浏览器并行测试配置
<!-- pom.xml -->
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<!-- 并行执行测试类 -->
<parallel>classes</parallel>
<threadCount>3</threadCount>
<!-- 传入浏览器参数 -->
<systemPropertyVariables>
<browser>${browser}</browser>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>在 BaseTest 中根据参数选择浏览器:
@BeforeAll
static void setup() {
playwright = Playwright.create();
String browserName = System.getProperty("browser", "chromium");
BrowserType.LaunchOptions options = new BrowserType.LaunchOptions()
.setHeadless(true);
browser = switch (browserName) {
case "firefox" -> playwright.firefox().launch(options);
case "webkit" -> playwright.webkit().launch(options);
default -> playwright.chromium().launch(options);
};
}执行三浏览器测试:
mvn test -Dbrowser=chromium &
mvn test -Dbrowser=firefox &
mvn test -Dbrowser=webkit &
wait小结
这篇的核心是两件事:
- POM 模式让 E2E 测试可维护:把 UI 细节封装在 Page Object 里,测试代码描述业务流程,改 selector 只改 Page Object,不动测试代码。
- 视频录制 + Trace 让调试效率翻倍:失败时不再靠猜,直接回放操作录像找问题。
下一篇我会写 Playwright vs Selenium 4 的完整对比,帮你在项目中做出更有信心的框架选型决策。
