你的 UI 测试脚本是不是也写了一堆 findElement 然后改版就全崩
UI 自动化测试最脆弱的环节,从来不是断言逻辑写错了,而是定位器写死了。driver.findElement(By.cssSelector(".btn-primary")) 这种东西,页面改一个 class 名就崩一片。
我见过太多团队刚开始搞 UI 自动化时热情高涨,三个月后测试套件跑一次红一半,最后没人维护直接废弃。核心问题不是 Selenium 或 Playwright 不好用,而是大部分人把测试脚本写成了"硬编码的点击序列"——没有抽象层,没有关注点分离,没有对变化的防御性设计。
下面聊聊我踩过的坑和最终沉淀下来的一套思路。
页面对象模型不是银弹,但它是底线
Page Object Model 这个话题老生常谈,但大部分人只学到了皮毛。他们把页面元素定位器集中到一个类里,然后测试脚本直接调用这些定位器做操作。这确实比散落在各处强,但远远不够。
真正的 POM 应该做到:测试脚本里不出现任何 findElement 或 locator,甚至不直接操作元素。页面对象暴露的是"用户在页面上能做什么",而不是"页面上有什么元素"。
看一个反例:
// 这样写 POM 等于没写
public class LoginPage {
@FindBy(id = "username")
WebElement usernameInput;
@FindBy(id = "password")
WebElement passwordInput;
@FindBy(css = ".btn-login")
WebElement loginButton;
}
// 测试脚本里
loginPage.usernameInput.sendKeys("admin");
loginPage.passwordInput.sendKeys("123456");
loginPage.loginButton.click();
问题在哪?测试脚本知道了太多实现细节。如果登录流程变成两步验证,或者 username 字段改成了 email,所有测试脚本都要改。
正确的做法是页面对象提供行为方法:
public class LoginPage {
private final WebDriver driver;
// 定位器集中管理,但不暴露
private By usernameField = By.id("username");
private By passwordField = By.id("password");
private By loginButton = By.cssSelector("[data-testid='login-submit']");
// 暴露的是用户行为
public HomePage loginAs(String username, String password) {
driver.findElement(usernameField).sendKeys(username);
driver.findElement(passwordField).sendKeys(password);
driver.findElement(loginButton).click();
return new HomePage(driver); // 返回下一个页面对象
}
}
测试脚本变成一行:loginPage.loginAs("admin", "123456")。登录流程怎么变,只要这个方法内部调整,所有依赖它的测试都不受影响。
定位器策略决定框架寿命
By.id() 和 By.cssSelector(".btn") 是最脆弱的两种定位方式。id 依赖后端模板生成,css class 被前端随意改动。我见过最离谱的项目,前端换了 CSS 框架,class 名全变了,几百条用例一夜报废。
相对稳固的策略优先级应该是:
- data-testid 等自定义属性:和前端约定一个
data-testid属性,专门给测试用。这个属性不参与样式,前端改版不会碰它。需要和前端团队达成共识,把这当成契约。 - 基于文本内容定位:
By.xpath("//button[text()='提交']")或 Playwright 的page.getByText("提交")。只要文案不变,定位就有效。国际化项目要注意,需要根据 locale 切换。 - 基于角色和可访问性属性:
By.role("button", { name: "提交" }),这种定位器和 ARIA 属性绑定,改版概率低。 - 语义化的 CSS 选择器:用元素类型配合语义属性,比如
input[type="email"]或[aria-label="搜索"],而不是.col-md-3 .btn-primary这种布局类选择器。
最忌讳的是用布局类 class 和层级选择器,比如 .row > div:nth-child(3) > .btn。这种定位器页面稍微调整布局就失效。
实际项目中,我倾向于要求前端在所有可交互元素上添加 data-testid,这需要工程文化支撑。如果前端不配合,退而求其次用基于文本的定位。
把等待逻辑从测试脚本里剥离
Thread.sleep(3000) 是测试不稳定的万恶之源。显式等待是进步,但写在测试脚本里一样是灾难:
// 这种等待逻辑散落在各处,维护成本极高
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
wait.until(ExpectedConditions.elementToBeClickable(By.id("submit")));
driver.findElement(By.id("submit")).click();
等待策略应该封装在页面对象或更底层的 driver 封装里。我现在的做法是在框架层做一个 SmartElement 包装,所有元素操作自动带重试和等待:
public class SmartElement {
private final WebDriver driver;
private final By locator;
private final int timeoutSeconds = 10;
public void click() {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(timeoutSeconds));
wait.until(ExpectedConditions.elementToBeClickable(locator)).click();
}
public void type(String text) {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(timeoutSeconds));
WebElement element = wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
element.clear();
element.sendKeys(text);
}
}
测试脚本和页面对象只跟 SmartElement 打交道,不用关心等待细节。更关键的是,超时时间、重试次数这些参数集中配置,可以根据环境(本地调试 vs CI 环境)动态调整。
Playwright 在这方面做得更好,它的 locator 自带自动等待和重试,基本上不需要手动写等待逻辑。如果你还在用 Selenium,可以考虑封装一层类似的能力。
测试数据管理比你想的重要十倍
UI 测试最隐蔽的坑是数据依赖。测试 A 创建了一个订单,测试 B 依赖这个订单存在,哪天测试 A 挂了,测试 B 也莫名其妙失败。这种级联失败排错成本极高。
原则很简单:每个测试用例必须自给自足,自己准备数据,自己清理数据。
具体做法上,我倾向于用 API 做数据准备,UI 测试只验证 UI 行为。比如要测试订单列表页,不要通过 UI 先创建订单(那是在测创建订单的功能),而是直接调 API 插入几条测试数据,然后打开列表页验证展示逻辑。
@Test
public void shouldDisplayOrderList() {
// 通过 API 准备数据,不依赖 UI 操作
OrderData order1 = apiClient.createOrder(new OrderRequest("item-001", 3));
OrderData order2 = apiClient.createOrder(new OrderRequest("item-002", 1));
// 打开页面,只验证 UI 展示
OrderListPage page = new OrderListPage(driver).open();
assertThat(page.getOrderCount()).isEqualTo(2);
assertThat(page.getOrderItems()).contains(order1.getItemName(), order2.getItemName());
// 清理数据
apiClient.deleteOrder(order1.getId());
apiClient.deleteOrder(order2.getId());
}
如果你的系统没有可用的 API 或者 API 权限受限,至少要确保数据工厂(Data Factory)集中管理,不要在每个测试脚本里硬编码 SQL 或 API 调用。数据工厂负责生成唯一标识(比如用时间戳后缀),避免并发执行时的数据冲突。
组件化:高于页面对象的抽象层
POM 解决的是单页面问题,但现代前端大量使用组件。一个日期选择器可能出现在十个不同的页面,如果每个页面对象都写一遍日期选择器的操作逻辑,改一个组件要改十个文件。
在页面对象之上再抽象一层组件对象:
// 可复用的组件
public class DatePicker {
private final WebDriver driver;
private final By rootLocator;
public DatePicker(WebDriver driver, By rootLocator) {
this.driver = driver;
this.rootLocator = rootLocator;
}
public void selectDate(String year, String month, String day) {
// 操作日期选择器的具体逻辑
}
}
// 页面对象组合组件
public class FlightSearchPage {
private final DatePicker departureDate;
private final DatePicker returnDate;
private final CitySelector originCity;
public FlightSearchPage(WebDriver driver) {
this.departureDate = new DatePicker(driver, By.id("departure-date"));
this.returnDate = new DatePicker(driver, By.id("return-date"));
this.originCity = new CitySelector(driver, By.id("origin-city"));
}
public FlightResultsPage search(SearchCriteria criteria) {
originCity.select(criteria.getOrigin());
departureDate.selectDate("2025", "06", "15");
returnDate.selectDate("2025", "06", "20");
// ...
}
}
这样做的好处是,如果日期选择器的实现从 jQuery UI 换成了自定义组件,只需要改 DatePicker 一个类,所有用到它的页面自动修复。
失败时告诉我为什么,别让我猜
CI 日志里看到 NoSuchElementException: no such element: Unable to locate element,然后给你一个截图——这种失败报告基本没用,你还得本地复现、打断点、一步一步跟。
测试失败时,框架应该自动记录:
- 当前页面的 URL
- 执行了什么操作后失败的
- 完整的页面源码或 DOM 快照(至少是失败元素附近的 HTML)
- 浏览器 Console 的错误日志
- 如果是断言失败,期望值是什么、实际值是什么
我习惯在框架的基类里统一处理异常:
public class BaseTest {
protected void onTestFailure(WebDriver driver, Throwable cause) {
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
String testName = getClass().getSimpleName();
// 截图
File screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
FileUtils.copyFile(screenshot, new File("reports/" + testName + "-" + timestamp + ".png"));
// 保存页面源码
String pageSource = driver.getPageSource();
Files.write(Paths.get("reports/" + testName + "-" + timestamp + ".html"),
pageSource.getBytes());
// 抓取 Console 日志
List<LogEntry> logs = driver.manage().logs().get("browser").getAll();
logs.forEach(log -> logger.error("[Browser {}] {}", log.getLevel(), log.getMessage()));
}
}
Playwright 的 Trace Viewer 做得更彻底,直接录下整个执行过程的时间线,包括网络请求、DOM 快照、操作序列,失败时可以直接回放。Selenium 4 也加了类似的能力(RemoteWebDriverBuilder 配合 CDP),但配置略繁琐。
常见问题
我团队只有两三个测试用例,需要搞这么复杂吗?
不需要全上。两三个用例时先保证两点:用 data-testid 定位元素、把页面操作封装成行为方法。这两条成本几乎为零,但能避免后续用例增长到几十条时的重构噩梦。等用例数量超过 20 条,再逐步引入组件化和数据工厂。
前端团队不愿意加 data-testid 属性,怎么说服他们?
别从测试角度讲,从协作效率角度讲。告诉他们有了这个属性,测试脚本变更不需要频繁找前端确认元素定位,反过来前端改版时也能通过跑自动化测试快速验证是否破坏了功能。如果实在推不动,退一步用基于文本的定位(getByText、getByRole),这些不依赖前端额外工作。
测试跑得太慢,一次 CI 要 40 分钟,怎么优化?
先做两个最简单的优化:一是测试用例完全独立,可以并行跑。Selenium Grid 或 Playwright 的 worker 模式都能并行执行,3 个 worker 并行通常能把时间压到原来的 40% 左右。二是把数据准备从 UI 操作改成 API 调用,UI 只验证关键路径。如果还慢,考虑把不频繁变动的模块(比如登录、基础信息查询)的 UI 测试降级为 API 集成测试,UI 测试只覆盖核心业务流程。
Selenium 和 Playwright 选哪个?
新项目无脑选 Playwright。自动等待、网络拦截、Trace Viewer、组件定位器、多浏览器并行,这些 Selenium 要么没有要么需要额外配置。Playwright 的 locator.click() 默认等到元素可见、可操作、稳定(没有动画),Selenium 得自己写一堆等待条件。唯一选 Selenium 的理由是必须兼容 Safari(Playwright 的 WebKit 和真机 Safari 有差异)或者团队已经深度绑定了 Selenium 生态。