你的 UI 测试脚本是不是也写了一堆 findElement 然后改版就全崩

UI 自动化测试最脆弱的环节,从来不是断言逻辑写错了,而是定位器写死了。driver.findElement(By.cssSelector(".btn-primary")) 这种东西,页面改一个 class 名就崩一片。

我见过太多团队刚开始搞 UI 自动化时热情高涨,三个月后测试套件跑一次红一半,最后没人维护直接废弃。核心问题不是 Selenium 或 Playwright 不好用,而是大部分人把测试脚本写成了"硬编码的点击序列"——没有抽象层,没有关注点分离,没有对变化的防御性设计。

下面聊聊我踩过的坑和最终沉淀下来的一套思路。

页面对象模型不是银弹,但它是底线

Page Object Model 这个话题老生常谈,但大部分人只学到了皮毛。他们把页面元素定位器集中到一个类里,然后测试脚本直接调用这些定位器做操作。这确实比散落在各处强,但远远不够。

真正的 POM 应该做到:测试脚本里不出现任何 findElementlocator,甚至不直接操作元素。页面对象暴露的是"用户在页面上能做什么",而不是"页面上有什么元素"。

看一个反例:

// 这样写 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 名全变了,几百条用例一夜报废。

相对稳固的策略优先级应该是:

  1. data-testid 等自定义属性:和前端约定一个 data-testid 属性,专门给测试用。这个属性不参与样式,前端改版不会碰它。需要和前端团队达成共识,把这当成契约。
  2. 基于文本内容定位By.xpath("//button[text()='提交']") 或 Playwright 的 page.getByText("提交")。只要文案不变,定位就有效。国际化项目要注意,需要根据 locale 切换。
  3. 基于角色和可访问性属性By.role("button", { name: "提交" }),这种定位器和 ARIA 属性绑定,改版概率低。
  4. 语义化的 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,然后给你一个截图——这种失败报告基本没用,你还得本地复现、打断点、一步一步跟。

测试失败时,框架应该自动记录:

  1. 当前页面的 URL
  2. 执行了什么操作后失败的
  3. 完整的页面源码或 DOM 快照(至少是失败元素附近的 HTML)
  4. 浏览器 Console 的错误日志
  5. 如果是断言失败,期望值是什么、实际值是什么

我习惯在框架的基类里统一处理异常:

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 属性,怎么说服他们?

别从测试角度讲,从协作效率角度讲。告诉他们有了这个属性,测试脚本变更不需要频繁找前端确认元素定位,反过来前端改版时也能通过跑自动化测试快速验证是否破坏了功能。如果实在推不动,退一步用基于文本的定位(getByTextgetByRole),这些不依赖前端额外工作。

测试跑得太慢,一次 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 生态。