[测试技术] Selenium WebDriver 入门与实战:稳定等待、Page Object 与失败排障

原创内容,未获授权禁止转载、转发、抄袭。

Selenium 用例难维护,通常不是因为不会点击按钮,而是定位器依赖 DOM 层级、异步页面使用固定休眠、浏览器会话没有释放,以及失败后只剩一段超时日志。用例数量一多,这些问题会集中表现为执行慢、偶发失败和排障困难。

本文使用一个"订单提交"页面,通过 7 条可执行测试覆盖成功提交、非法金额和重复点击,并把 Selenium Manager、显式等待、Page Object、JUnit 生命周期和失败截图串成一个完整 Java 工程。

Selenium WebDriver 适合解决什么问题

Selenium WebDriver 是浏览器自动化 API,不负责测试发现、断言和报告,Java 项目通常需要配合 JUnit 或 TestNG。它通过浏览器厂商提供的驱动控制真实浏览器,适合 Web 端兼容性测试、核心流程回归和跨机器执行。

能力 典型用途
WebDriver 标准 使用统一 API 控制 Chrome、Firefox、Edge 和 Safari
Selenium Manager 自动发现浏览器并管理匹配的驱动
显式等待 等待元素进入可点击、可见或满足业务状态
Page Object 集中管理定位器和页面操作,降低页面变化影响
Grid 在不同浏览器和机器上分发、并行执行
截图与浏览器信息 为失败用例保留页面证据和运行环境

截至 2026 年 7 月 24 日,Maven Central 上 selenium-java 最新稳定版为 4.46.0,对应的 GitHub 官方版本发布于 2026 年 7 月 11 日。Selenium Java 绑定要求 Java 11+;本文搭配的 JUnit 6.1.2 要求 Java 17+,因此示例统一使用 Java 17。

环境与依赖

示例目录如下:

text 复制代码
selenium-order-demo/
├── pom.xml
└── src/test/
    ├── java/demo/
    │   ├── OrderPage.java
    │   ├── OrderPageTest.java
    │   └── ScreenshotOnFailure.java
    └── resources/
        └── order.html

创建 pom.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>demo</groupId>
    <artifactId>selenium-order-demo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.release>17</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <selenium.version>4.46.0</selenium.version>
        <junit.version>6.1.2</junit.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.junit</groupId>
                <artifactId>junit-bom</artifactId>
                <version>${junit.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.seleniumhq.selenium</groupId>
            <artifactId>selenium-java</artifactId>
            <version>${selenium.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.13.0</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.5</version>
            </plugin>
        </plugins>
    </build>
</project>

selenium-java 和 JUnit 都只用于测试,因此依赖作用域设为 test;JUnit BOM 只统一 JUnit 模块版本,不管理 Selenium。编译插件和 Surefire 也固定版本,避免开发机与 CI 随插件自动解析结果变化。

执行 new ChromeDriver() 时,Selenium Manager 会发现本机 Chrome,并在驱动不可用时解析、下载匹配的 ChromeDriver。首次执行需要访问浏览器驱动下载源;受限网络或离线 CI 应预先准备兼容的浏览器与驱动缓存,不能把自动下载当成永远可用的前提。团队项目还应提交 Maven Wrapper,让本地和 CI 使用同一 Maven 版本。

准备被测页面

创建 src/test/resources/order.html。页面提交后延迟 150 ms 返回结果,用于演示显式等待;提交期间按钮会被禁用,防止重复创建订单。

html 复制代码
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <title>订单提交</title>
</head>
<body>
  <main>
    <h1>订单提交</h1>
    <form id="order-form">
      <label for="amount">订单金额</label>
      <input id="amount" name="amount" type="text" inputmode="decimal" autocomplete="off">
      <button type="submit" data-testid="submit-order">提交订单</button>
    </form>
    <p id="success" role="status" aria-live="polite" hidden></p>
    <p id="error" role="alert" hidden></p>
    <output id="submit-count" hidden>0</output>
  </main>
  <script>
    const form = document.querySelector('#order-form');
    const amountInput = document.querySelector('#amount');
    const submitButton = document.querySelector('[data-testid="submit-order"]');
    const success = document.querySelector('#success');
    const error = document.querySelector('#error');
    const submitCount = document.querySelector('#submit-count');

    form.addEventListener('submit', event => {
      event.preventDefault();
      success.hidden = true;
      error.hidden = true;
      success.textContent = '';
      error.textContent = '';

      const rawAmount = amountInput.value.trim();
      const amount = Number(rawAmount);
      if (rawAmount === '' || !Number.isInteger(amount) || amount <= 0) {
        error.textContent = '订单金额必须是正整数';
        error.hidden = false;
        return;
      }

      submitButton.disabled = true;
      setTimeout(() => {
        const count = Number(submitCount.value) + 1;
        submitCount.value = String(count);
        success.textContent = '订单创建成功:ORDER-' + (1000 + count);
        success.hidden = false;
        submitButton.disabled = false;
      }, 150);
    });
  </script>
</body>
</html>

输入框显式使用 type="text",让 abc 等非法字符能够进入前端业务校验;如果改成 type="number",浏览器可能先过滤部分输入,测试目标就会从"业务规则"变成"浏览器原生校验"。labelstatusalert 同时保留了可访问语义,定位器不必依赖样式类名。

真实项目应访问测试环境地址。这里使用本地资源,是为了隔离网络、账号和后端数据波动,让示例只验证 WebDriver 的交互与等待逻辑;它不覆盖 HTTP、鉴权、接口调用和服务端写库,不能替代真实环境端到端测试。

Page Object:定位与断言分离

创建 src/test/java/demo/OrderPage.java

java 复制代码
package demo;

import java.time.Duration;

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

final class OrderPage {
    private final WebDriver driver;
    private final WebDriverWait wait;

    private final By amountInput = By.id("amount");
    private final By submitButton = By.cssSelector("[data-testid='submit-order']");
    private final By successMessage = By.id("success");
    private final By errorMessage = By.id("error");
    private final By submitCount = By.id("submit-count");

    OrderPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(5));
        wait.until(ExpectedConditions.titleIs("订单提交"));
    }

    void enterAmount(String amount) {
        var input = driver.findElement(amountInput);
        input.clear();
        input.sendKeys(amount);
    }

    void clickSubmit() {
        driver.findElement(submitButton).click();
    }

    void submit(String amount) {
        enterAmount(amount);
        clickSubmit();
    }

    String waitForSuccess() {
        wait.until(ExpectedConditions.textToBePresentInElementLocated(
                successMessage, "订单创建成功"
        ));
        return driver.findElement(successMessage).getText();
    }

    String waitForError() {
        wait.until(ExpectedConditions.textToBePresentInElementLocated(
                errorMessage, "订单金额必须是正整数"
        ));
        return driver.findElement(errorMessage).getText();
    }

    int submissionCount() {
        return Integer.parseInt(driver.findElement(submitCount).getDomProperty("value"));
    }

    boolean waitUntilSubmitEnabled() {
        return wait.until(ExpectedConditions.elementToBeClickable(submitButton)).isEnabled();
    }

    boolean isSuccessVisible() {
        return driver.findElement(successMessage).isDisplayed();
    }

    boolean isErrorVisible() {
        return driver.findElement(errorMessage).isDisplayed();
    }
}

定位器优先使用稳定且唯一的 id;没有业务标识时,可让开发提供 data-testid。不要默认使用绝对 XPath 或包含多层序号的 CSS。Page Object 保存的是 By,每次操作时重新查找元素,页面刷新或局部重绘后不会继续使用已经失效的 WebElement

构造方法等待标题出现,只用于确认当前页面加载正确;业务断言仍留在测试类。waitForSuccess() 等待"成功文案已经写入"这一业务状态,比只等待元素可见更准确。示例把隐式等待保持为 0,固定 Thread.sleep() 无法感知页面提前完成,还会在页面变慢时直接失败;隐式等待与显式等待混用则可能放大实际超时时间。

失败时自动截图

Selenium 没有内置 Trace。创建 src/test/java/demo/ScreenshotOnFailure.java,在测试方法失败后、浏览器退出前保存截图:

java 复制代码
package demo;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Supplier;

import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;

final class ScreenshotOnFailure implements AfterTestExecutionCallback {
    private final Supplier<WebDriver> driverSupplier;

    ScreenshotOnFailure(Supplier<WebDriver> driverSupplier) {
        this.driverSupplier = driverSupplier;
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        if (context.getExecutionException().isEmpty()) {
            return;
        }

        WebDriver driver = driverSupplier.get();
        if (!(driver instanceof TakesScreenshot screenshot)) {
            return;
        }

        try {
            Path directory = Path.of("target", "screenshots");
            Files.createDirectories(directory);
            String name = Integer.toHexString(context.getUniqueId().hashCode()) + ".png";
            Files.write(directory.resolve(name), screenshot.getScreenshotAs(OutputType.BYTES));
        } catch (IOException | RuntimeException error) {
            context.publishReportEntry("screenshot-error", error.toString());
        }
    }
}

截图文件名使用测试唯一 ID 的哈希,避免并行执行时相互覆盖,也不会把账号、订单号等业务数据写进文件名。截图或文件写入失败只登记到测试报告,不覆盖原始断言异常。

AfterTestExecutionCallback 在测试方法结束后、@AfterEach 退出浏览器前执行,适合捕获断言失败;如果 @BeforeEach 在浏览器启动阶段就失败,它不会生成截图,此时需要依赖 Selenium Manager 日志和 CI 环境信息。WebDriver 截图通常只包含当前视口,也不能还原网络请求,排障时仍应同时保留浏览器日志、服务端链路标识和 Surefire 报告。

编写 Selenium 测试

创建 src/test/java/demo/OrderPageTest.java

java 复制代码
package demo;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.net.URL;
import java.time.Duration;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;

class OrderPageTest {
    private WebDriver driver;
    private OrderPage page;

    @RegisterExtension
    final ScreenshotOnFailure screenshots = new ScreenshotOnFailure(() -> driver);

    @BeforeEach
    void setUp() {
        ChromeOptions options = new ChromeOptions();
        if (Boolean.parseBoolean(System.getProperty("headless", "true"))) {
            options.addArguments("--headless=new");
        }
        options.addArguments("--window-size=1280,900");

        driver = new ChromeDriver(options);
        driver.manage().timeouts().implicitlyWait(Duration.ZERO);
        driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(15));

        URL fixture = getClass().getClassLoader().getResource("order.html");
        assertNotNull(fixture, "order.html 不存在");
        driver.get(fixture.toExternalForm());
        page = new OrderPage(driver);
    }

    @AfterEach
    void tearDown() {
        if (driver != null) {
            driver.quit();
        }
    }

    @Test
    void submitsValidAmountAndShowsOrderId() {
        page.submit("198");

        assertAll(
                () -> assertEquals("订单创建成功:ORDER-1001", page.waitForSuccess()),
                () -> assertEquals(1, page.submissionCount()),
                () -> assertTrue(page.waitUntilSubmitEnabled()),
                () -> assertFalse(page.isErrorVisible())
        );
    }

    @ParameterizedTest
    @ValueSource(strings = {"", "0", "-1", "1.5", "abc"})
    void rejectsInvalidAmountWithoutSubmitting(String amount) {
        page.submit(amount);

        assertAll(
                () -> assertEquals("订单金额必须是正整数", page.waitForError()),
                () -> assertEquals(0, page.submissionCount()),
                () -> assertFalse(page.isSuccessVisible())
        );
    }

    @Test
    void ignoresSecondClickWhileSubmitting() {
        page.enterAmount("198");
        page.clickSubmit();
        page.clickSubmit();

        assertAll(
                () -> assertEquals("订单创建成功:ORDER-1001", page.waitForSuccess()),
                () -> assertEquals(1, page.submissionCount())
        );
    }
}

无头模式仍固定 1280×900 窗口,避免响应式布局因开发机分辨率不同而切换。pageLoadTimeout 只限制页面导航,不会替代等待异步业务结果;订单结果仍由 Page Object 中的显式等待负责。

每条测试创建独立浏览器会话,并在 @AfterEach 中调用 quit() 关闭全部窗口和驱动进程。close() 只关闭当前窗口,不适合作为测试清理方案。driver != null 的判断只能处理 Driver 已经赋值的情况;浏览器创建前失败时没有页面可截图,应直接查看启动日志。

参数化测试覆盖空字符串、0、负数、小数和非数字,并断言提交次数仍为 0、成功提示没有出现;正常路径则反向确认错误提示未出现。重复点击用例不仅检查成功提示,还检查实际提交次数,避免"页面看起来成功,但后台创建两次"的漏测。@RegisterExtension 使用 Driver Supplier,是因为扩展对象创建时浏览器尚未在 @BeforeEach 中初始化。

执行与验证

默认使用无头 Chrome:

bash 复制代码
mvn test

本地排查时显示浏览器:

bash 复制代码
mvn test -Dheadless=false

构建产物不应提交代码库:

gitignore 复制代码
target/

本文示例在 Temurin 17.0.19、Maven 3.9.9、Selenium 4.46.0 和 Chrome 150.0.7871.183 环境实际执行:

text 复制代码
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0

其中两个普通测试加上五组非法金额参数,共计 7 条。首次运行可能因 Selenium Manager 下载驱动而较慢,不能据此判断页面性能;CI 应归档 target/surefire-reports,失败时再同时保留 target/screenshots

CI、并行与 Grid

CI 应固定 JDK、浏览器和 Selenium 依赖版本,并使用批处理模式执行 mvn -B -ntp test。无头模式仍需要安装真实浏览器及其系统依赖;不要因为没有界面就省略浏览器准备。Selenium Manager 缓存只能用于提速,浏览器升级后仍要允许它获取匹配驱动;跨操作系统或 CPU 架构不能复用同一份驱动缓存。完全离线环境应把兼容的浏览器与驱动作为构建镜像的一部分。

并行执行时,每个线程必须使用独立 WebDriver、账号和业务数据,不能把单例 Driver 在测试间共享,WebDriver 实例本身也不保证线程安全。需要线程级管理时可以使用 ThreadLocal<WebDriver>,但必须在清理阶段同时执行 quit()remove(),否则线程池复用时仍会泄漏会话。浏览器隔离不等于数据库隔离,订单号、手机号等数据应加入 worker 前缀并在执行后清理。

单机资源不足或需要跨浏览器时,可将 ChromeDriver 替换为连接 Selenium Grid 的 RemoteWebDriver;浏览器选项仍通过 capabilities 传给 Grid,节点负责提供浏览器和驱动。先在少量核心用例上验证 Chrome,再把 Firefox、Edge 和 Safari 兼容性覆盖放到夜间或发布前任务,避免每次提交都承担完整矩阵成本。

常见问题

问题 处理方式
找不到浏览器驱动 检查浏览器是否安装、网络是否能访问驱动源,以及代理是否传给 Selenium Manager
NoSuchElementException 先确认 URL、窗口和 iframe 上下文,再检查定位器;iframe 元素必须先等待并切入
StaleElementReferenceException 页面刷新或局部重绘后用 By 重新定位,不要缓存 WebElement 或全局捕获后无限重试
ElementClickInterceptedException 检查遮罩、弹窗和滚动位置,等待真正可点击状态,不要直接用 JavaScript 绕过用户交互
显式等待仍然很慢 检查是否混用了隐式等待,以及等待条件是否对应真实业务状态
本地通过、CI 失败 对齐浏览器版本、窗口大小、时区、语言、字体和测试数据,并归档截图与报告
并行后浏览器互相影响 不共享 Driver、下载目录、账号和业务主键,限制并发数量

工程实践建议

  • 定位器优先级通常是稳定 iddata-testid、语义属性,再考虑短 CSS 或 XPath
  • 等待业务状态,不等待固定秒数;不要混用隐式等待与显式等待
  • Page Object 封装页面操作,断言留在测试类;窗口或 iframe 切换后及时恢复上下文
  • 每条测试独立创建并释放 Driver,失败时保留截图、浏览器日志和服务端链路标识;截图可能包含账号和业务数据,CI 产物应限制访问并设置保留期限
  • UI 自动化只覆盖关键流程,字段组合和异常规则优先下沉到接口或单元测试
  • 重试只处理已经确认的环境抖动,并记录首次失败证据;具有下单、发券等副作用的流程必须先保证幂等

总结

Selenium WebDriver 不替代测试框架,也不会自动解决页面同步和数据隔离。把驱动管理、业务状态等待、页面封装、会话清理和失败证据设为工程基线,再把高成本 UI 用例集中在关键链路,回归集才会稳定且可维护。

相关推荐
祉猷并茂,雯华若锦6 小时前
Win下完美解决Allure报错,生成Web自动化测试报告
android·python·selenium·自动化
gongzhxu7 小时前
DumpAny —— 一款支持 HTTP / gRPC / MySQL / Redis 的全能网络调试工具
网络·测试工具·http·rpc
思-无-涯9 小时前
测试面试新趋势:工程思维成关键
人工智能·功能测试·测试工具·职场和发展·可用性测试
guyiICtestsocket10 小时前
国内支持定制的国产存储芯片测试座厂家测试精度高
测试工具
我的xiaodoujiao1 天前
API 接口自动化测试详细图文教程学习系列32--Allure测试报告2
python·学习·测试工具·pytest
ClouGence1 天前
哪些场景适合做自动化测试?这 4 类最值得优先投入
selenium·测试
测试老哥1 天前
接口自动化测试分层设计与实践总结
自动化测试·软件测试·python·测试工具·职场和发展·测试用例·接口测试
ClouGence1 天前
Selenium vs CueCast:自动化测试一定要写代码吗?
selenium·测试