1. 概述

在使用Selenium测试Web应用时,我们经常会遇到StaleElementReferenceException这个常见错误。当元素变得过期(stale),即页面刷新或DOM更新后,Selenium会抛出此异常。

在这个教程中,我们将了解StaleElementReferenceException在Selenium(Java Selenium与JUnit和TestNG)中的含义以及它为何会出现。然后,我们将探讨如何在我们的Selenium测试中避免这种异常。

2. 避免StaleElementReferenceException的策略

为了避免StaleElementReferenceException,关键是要确保动态地查找和交互元素,而不是存储对它们的引用。这意味着每次需要时都应该重新找到元素,而不是将它们存储在变量中。

在某些情况下,这种方法不可行,我们需要在再次交互元素之前先刷新它。因此,我们的解决方案是在捕获到StaleElementReferenceException后,对元素进行刷新并尝试重新操作。我们可以在测试代码中直接实现,也可以在全球范围内为所有测试设置。

为了我们的测试,我们将定义一些定位器常量:

By LOCATOR_REFRESH = By.xpath("//a[.='click here']");
By LOCATOR_DYNAMIC_CONTENT = By.xpath("(//div[@id='content']//div[@class='large-10 columns'])[1]");

对于设置,我们使用WebDriverManager的自动化配置方法

2.1. 生成StaleElementReferenceException

首先,我们来看一个以StaleElementReferenceException结束的测试示例:

void givenDynamicPage_whenRefreshingAndAccessingSavedElement_thenSERE() {
    driver.navigate().to("https://the-internet.herokuapp.com/dynamic_content?with_content=static");
    final WebElement element = driver.findElement(LOCATOR_DYNAMIC_CONTENT);

    driver.findElement(LOCATOR_REFRESH).click();
    Assertions.assertThrows(StaleElementReferenceException.class, element::getText);
}

这个测试将元素存储起来,然后通过点击页面上的链接更新DOM。当试图重新访问已不再存在的元素时,就会抛出StaleElementReferenceException

2.2. 刷新元素

让我们使用重试逻辑,在重新访问元素之前先刷新它:

boolean retryingFindClick(By locator) {
    boolean result = false;
    int attempts = 0;
    while (attempts < 5) {
       try {
            driver.findElement(locator).click();
            result = true;
            break;
        } catch (StaleElementReferenceException ex) {
            System.out.println(ex.getMessage());
        }
        attempts++;
    }
    return result;
}

每当出现StaleElementReferenceException时,我们将在重新点击之前使用存储的元素定位器再次找到元素。

现在,让我们更新测试以使用新的重试逻辑:

void givenDynamicPage_whenRefreshingAndAccessingSavedElement_thenHandleSERE() {
    driver.navigate().to("https://the-internet.herokuapp.com/dynamic_content?with_content=static");
    final WebElement element = driver.findElement(LOCATOR_DYNAMIC_CONTENT);

    if (!retryingFindClick(LOCATOR_REFRESH)) {
        Assertions.fail("Element is still stale after 5 attempts");
    }
    Assertions.assertDoesNotThrow(() -> retryingFindGetText(LOCATOR_DYNAMIC_CONTENT));
}

我们看到,如果需要对许多测试进行此类更改,这可能会很麻烦。幸运的是,我们可以将这种逻辑放在一个中心位置,而无需更新所有测试。

3. 避免StaleElementReferenceException的通用策略

我们将创建两个新类来实现通用解决方案:RobustWebDriverRobustWebElement

3.1. RobustWebDriver

首先,我们需要创建一个新的类,它实现了WebDriver实例。我们将它写成WebDriver的包装器,调用WebDriver的方法,其中findElementfindElements方法将返回RobustWebElement

class RobustWebDriver implements WebDriver {

    WebDriver originalWebDriver;

    RobustWebDriver(WebDriver webDriver) {
        this.originalWebDriver = webDriver;
    }
...
    @Override
    public List<WebElement> findElements(By by) {
        return originalWebDriver.findElements(by)
                 .stream().map(e -> new RobustWebElement(e, by, this))
                 .collect(Collectors.toList());
    }

    @Override
    public WebElement findElement(By by) {
        return new RobustWebElement(originalWebDriver.findElement(by), by, this);
    }
...
}

3.2. RobustWebElement

RobustWebElementWebElement的包装器。该类实现了WebElement接口,并包含重试逻辑:

class RobustWebElement implements WebElement {

    WebElement originalElement;
    RobustWebDriver driver;
    By by;

    int MAX_RETRIES = 10;
    String SERE = "Element is no longer attached to the DOM";

    RobustWebElement(WebElement element, By by, RobustWebDriver driver) {
        originalElement = element;
        by = by;
        driver = driver;
    }
...
}

我们必须实现WebElement接口的所有方法,以便在抛出StaleElementReferenceException时刷新元素。为此,我们可以引入一些包含刷新逻辑的辅助方法,并在重写的方法中调用它们。

我们可以利用函数式接口并创建一个辅助类来调用WebElement的各种方法:

class WebElementUtils {

    private WebElementUtils(){
    }

    static void callMethod(WebElement element, Consumer<WebElement> method) {
        method.accept(element);
    }

    static <U> void callMethod(WebElement element, BiConsumer<WebElement, U> method, U parameter) {
        method.accept(element, parameter);
    }

    static <T> T callMethodWithReturn(WebElement element, Function<WebElement, T> method) {
        return method.apply(element);
    }

    static <T, U> T callMethodWithReturn(WebElement element, BiFunction<WebElement, U, T> method, U parameter) {
        return method.apply(element, parameter);
    }
}

WebElement中,我们实现四个包含刷新逻辑的方法,并调用之前引入的WebElementUtils

void executeMethodWithRetries(Consumer<WebElement> method) { ... }

<T> T executeMethodWithRetries(Function<WebElement, T> method) { ... }

<U> void executeMethodWithRetriesVoid(BiConsumer<WebElement, U> method, U parameter) { ... }

<T, U> T executeMethodWithRetries(BiFunction<WebElement, U, T> method, U parameter) { ... }

click方法可能看起来像这样:

@Override
public void click() {
    executeMethodWithRetries(WebElement::click);
}

现在,我们的测试只需更改WebDriver实例即可:

driver = new RobustWebDriver(new ChromeDriver(options));

其他一切保持不变,StaleElementReferenceException应该不会再发生了。

4. 总结

在这篇教程中,我们了解到当DOM发生改变且元素未刷新后访问元素时,可能会出现StaleElementReferenceException。我们在测试中引入了重试逻辑,以便在遇到StaleElementReferenceException时刷新元素。

如往常一样,所有这些示例的完整实现可在GitHub上找到:https://github.com/eugenp/tutorials/tree/master/testing-modules/selenium