跳转至

Web 自动化稳定性:六类真实异常与修复代码

UI 用例“有时过有时不过”,90% 能归到这六类异常。每类给出原始报错、根因和可直接用的修复。

1. 异常对照表

异常 典型消息 根因
NoSuchElementException Unable to locate element: {...} 未加载、iframe、Shadow DOM、新窗口
ElementNotInteractableException element not interactable 元素隐藏、宽度为 0、被禁用
ElementClickInterceptedException element click intercepted: Other element would receive the click 遮罩、动画、吸底栏
StaleElementReferenceException element is not attached to the page document DOM 重渲染
TimeoutException Expected condition failed: waiting for visibility of element 等待条件不对、异步未完成
WebDriverException session deleted because of page crash 容器 /dev/shm 太小

2. 等待:不要用 sleep 堆

from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

class BasePage:
    def __init__(self, driver, timeout=10):
        self.driver = driver
        self.wait = WebDriverWait(driver, timeout, poll_frequency=0.2,
                                  ignored_exceptions=(StaleElementReferenceException,))

    def visible(self, locator):
        return self.wait.until(EC.visibility_of_element_located(locator))

    def clickable(self, locator):
        return self.wait.until(EC.element_to_be_clickable(locator))

要点:

  • poll_frequency=0.2 比默认 0.5 反应更快,又不会空转太多
  • ignored_exceptions 忽略 stale,等元素重新挂载
  • 不要同时开隐式等待:implicitly_wait 与显式等待叠加,实际等待时间会变成两者之和,超时时间不可控。统一用显式等待,隐式设为 0

3. StaleElement:不要缓存 WebElement

# 错误:列表刷新后 item 失效
items = driver.find_elements(By.CSS_SELECTOR, ".order-item")
for item in items:
    item.click()          # 第二次点击可能 Stale

# 正确:每次重新定位
for i in range(len(items)):
    driver.find_elements(By.CSS_SELECTOR, ".order-item")[i].click()

更通用的做法是封装重试:

import functools, time
from selenium.common.exceptions import StaleElementReferenceException

def retry_stale(times=3, delay=0.3):
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            last = None
            for _ in range(times):
                try:
                    return fn(*args, **kwargs)
                except StaleElementReferenceException as e:
                    last = e
                    time.sleep(delay)
            raise last
        return wrapper
    return deco

@retry_stale()
def click_first_item(page):
    page.clickable((By.CSS_SELECTOR, ".order-item")).click()

4. ElementClickIntercepted:先等遮挡物消失

def safe_click(driver, locator, overlay=(By.CSS_SELECTOR, ".loading-mask")):
    wait = WebDriverWait(driver, 10)
    try:
        wait.until(EC.invisibility_of_element_located(overlay))
    except TimeoutException:
        pass
    el = wait.until(EC.element_to_be_clickable(locator))
    driver.execute_script("arguments[0].scrollIntoView({block:'center'})", el)
    try:
        el.click()
    except ElementClickInterceptedException:
        # 最后手段:JS 点击(会绕过部分前端校验,慎用)
        driver.execute_script("arguments[0].click()", el)

注意:JS 点击不会触发真实鼠标事件,某些组件(如基于 mousedown 的日期选择器)会失效。能修页面定位就优先修定位。

5. iframe 与 Shadow DOM

iframe:

wait.until(EC.frame_to_be_available_and_switch_to_it((By.ID, "pay-frame")))
driver.find_element(By.ID, "card-no").send_keys("6222...")
driver.switch_to.default_content()

Shadow DOM(Selenium 4 支持直接穿透部分场景,但嵌套 shadow root 仍需 JS):

host = driver.find_element(By.CSS_SELECTOR, "order-list")
inner = driver.execute_script(
    "return arguments[0].shadowRoot.querySelector('button.submit')", host)
inner.click()

6. 新窗口与下载

# 新标签页
main = driver.current_window_handle
driver.find_element(By.LINK_TEXT, "发票").click()
WebDriverWait(driver, 10).until(lambda d: len(d.window_handles) == 2)
new = [h for h in driver.window_handles if h != main][0]
driver.switch_to.window(new)
# 操作完关掉并切回
driver.close()
driver.switch_to.window(main)

无头模式下载:

prefs = {
    "download.default_directory": "/tmp/downloads",
    "download.prompt_for_download": False,
    "plugins.always_open_pdf_externally": True,
}
o.add_experimental_option("prefs", prefs)

无头 Chrome 下载有时需要 CDP:

driver.execute_cdp_cmd("Page.setDownloadBehavior", {
    "behavior": "allow", "downloadPath": "/tmp/downloads"})

7. CI 容器参数

o.add_argument("--headless=new")
o.add_argument("--no-sandbox")
o.add_argument("--disable-dev-shm-usage")
o.add_argument("--disable-gpu")
o.add_argument("--window-size=1440,900")
报错 对应参数
Chrome failed to start: crashed --no-sandbox
session deleted because of page crash --disable-dev-shm-usage 或 --shm-size=2g
元素位置偏移、点击落空 --window-size=1440,900(默认 800×600 会触发移动布局)
中文变方块 镜像安装 fonts-noto-cjk

8. 用接口准备数据,不要用 UI 铺路

# 错误:为了测“取消订单”,用 UI 下 5 单,耗时 40s 且不稳定
# 正确:接口造数,UI 只验证取消动作
order_id = api.create_order(sku_id=1001)["data"]["orderId"]
page.open_order(order_id).cancel()

UI 用例只覆盖“必须通过界面验证”的部分,其余用接口准备与断言。

9. 重试策略

@pytest.mark.flaky(reruns=2, reruns_delay=1)
def test_cancel_order(logged_in, order_id):
    ...

原则:

  • 只对已知偶发用例加重试,不要全局开
  • 重试次数 ≤ 2,延迟 ≥ 1s
  • 记录重试率;重试率 > 3% 时停下来修根因,而不是继续加次数

10. 稳定性验收

# 同一用例连跑 20 次,统计通过率
pytest tests/test_order.py::test_cancel_order --count=20
目标:首次通过率 ≥ 98%
红线:任何用例连续 3 次失败 → 必须排查
观察:平均耗时是否随用例数线性增长(定位泄漏/未清理)

相关阅读