跳转至

Selenium 环境搭建与元素定位

UI 自动化的失败,八成不是"定位写错",而是环境不对、时机不对、上下文不对。这篇按"装得上 → 定得准 → 找得到"三段写,报错都是实际遇到的原文。

一、装得上:驱动与浏览器的版本对齐

1.1 版本矩阵

pip install "selenium>=4.16" pytest
python -c "import selenium; print(selenium.__version__)"

Selenium 4.6 起自带 Selenium Manager,会自动下载匹配的驱动:

from selenium import webdriver
driver = webdriver.Chrome()      # 不再需要 executable_path

但自动下载有两个前提:能访问外网、浏览器版本能被识别。CI 内网环境下会报:

selenium.common.exceptions.NoSuchDriverException: Unable to obtain chromedriver
using Selenium Manager; Message: Unable to discover location of chromedriver

处理方式三选一:

# A. 指定驱动路径(内网预置)
webdriver.Chrome(service=Service("/opt/drivers/chromedriver"))

# B. 指定浏览器二进制,Selenium Manager 按该版本找驱动
options.binary_location = "/usr/bin/chromium"

# C. 显式固定版本,走内网镜像
pip install chromedriver-binary==114.0.5735.90

1.2 Chrome 115+ 的驱动变化

Chrome 115 之后,驱动下载地址从 chromedriver.storage.googleapis.com 迁到 Chrome for Testing(googlechromelabs.github.io/chrome-for-testing/)。老脚本里写死的下载 URL 会 404:

# 老地址(已失效)
https://chromedriver.storage.googleapis.com/114.0.5735.90/chromedriver_linux64.zip

# 新地址(JSON 里有全部版本)
curl https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json

镜像里同时要装浏览器本体和驱动,且大版本必须一致。常见报错:

SessionNotCreatedException: Message: session not created:
This version of ChromeDriver only supports Chrome version 114
Current browser version is 120.0.6099.109

1.3 容器里跑 Chrome 的两个致命参数

WebDriverException: unknown error: DevToolsActivePort file doesn't exist

根因是 Chrome 在容器里以 root 运行、/dev/shm 太小。参数缺一不可:

options = webdriver.ChromeOptions()
options.add_argument("--headless=new")          # 新版无头,行为更接近有头
options.add_argument("--no-sandbox")            # 容器内 root 必需
options.add_argument("--disable-dev-shm-usage") # /dev/shm 默认 64MB,会崩
options.add_argument("--window-size=1440,900")  # 不设会导致元素不可见
options.add_argument("--disable-gpu")
options.add_argument("--lang=zh-CN")

docker run 侧还要给共享内存:

docker run --shm-size=2g --ipc=host ...

中文页面渲染成方框,是镜像缺字体:

RUN apt-get update && apt-get install -y fonts-noto-cjk fonts-wqy-zenhei

1.4 无头与有头的差异

无头模式下经常出现"有头能过、无头不过":

差异 表现 处理
窗口尺寸 元素在视口外,点击失败 固定 --window-size
UA 被识别为 HeadlessChrome,触发风控 --user-agent 覆盖
字体 中文乱码/方框 装 CJK 字体
动画 无头渲染更快,元素提前出现 用显式等待,别用 sleep
下载 无头默认不落盘 set_download_behaviour
driver.execute_cdp_cmd("Page.setDownloadBehavior", {
    "behavior": "allow", "downloadPath": "/tmp/downloads"
})

二、定得准:八种定位方式与失效边界

方式 示例 什么时候会失效
ID By.ID, "submit" 前端框架动态生成 id(el-id-1234)
Name By.NAME, "username" 同名多个元素,返回第一个
Class By.CLASS_NAME, "btn" 复合类名不能带空格
Tag By.TAG_NAME, "input" 页面上大量同类元素
Link Text By.LINK_TEXT, "登录" 文本前后有空格或换行
Partial Link By.PARTIAL_LINK_TEXT, "登" 匹配到多个
XPath //button[@type='submit'] 依赖层级、文案变化
CSS form button[type=submit] 依赖结构,但比 XPath 快

By.CLASS_NAME 传 "btn btn-primary" 会直接报错:

invalid selector: An invalid or illegal selector was specified

要用 CSS:By.CSS_SELECTOR, ".btn.btn-primary"。

2.1 定位优先级(按稳定性排)

  1. data-testid / data-test —— 推动开发加,最稳。
  2. 稳定 id、name。
  3. 语义属性:[aria-label]、[role]、[name]。
  4. 文本定位(text= 在 Playwright;Selenium 用 XPath contains(text(),'登录'))。
  5. 结构 CSS。
  6. 绝对 XPath —— 基本等于不可维护。

反例:

# 一改布局就废
driver.find_element(By.XPATH, "/html/body/div[3]/div[2]/div/button")
# 相对定位 + 语义,稳得多
driver.find_element(By.CSS_SELECTOR, "[data-testid='order-submit']")

2.2 文本定位的坑

# 文本里带空格/换行,LINK_TEXT 匹配失败
driver.find_element(By.LINK_TEXT, "登录")          # 实际是 " 登录 "
driver.find_element(By.XPATH, "//a[normalize-space()='登录']")   # 用这个

normalize-space() 会去掉首尾空白并压缩中间空格,是文本定位的通用写法。

三、找得到:上下文与时机

3.1 iframe

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

WebDriverWait(driver, 10).until(
    EC.frame_to_be_available_and_switch_to_it((By.ID, "login-frame"))
)
driver.find_element(By.ID, "username").send_keys("qa")
driver.switch_to.default_content()      # 必须切回,否则后续全部找不到

多层嵌套:driver.switch_to.parent_frame() 逐层回退。定位 iframe 本身失败时,先用 driver.find_elements(By.TAG_NAME, "iframe") 打印所有 iframe 的 src。

3.2 Shadow DOM

Selenium 4 支持直接取 shadow root:

host = driver.find_element(By.CSS_SELECTOR, "my-component")
shadow = host.shadow_root                       # 只对 open shadow root 有效
shadow.find_element(By.CSS_SELECTOR, "button").click()

closed shadow root 无法直接访问,只能:

shadow = driver.execute_script("return arguments[0].shadowRoot", host)

仍为 None 说明是 closed,只能改用组件暴露的接口或事件触发,不要硬绕。

3.3 新窗口 / 新标签

original = driver.current_window_handle
driver.find_element(By.ID, "open-new").click()

WebDriverWait(driver, 10).until(EC.number_of_windows_to_be(2))
new_window = [h for h in driver.window_handles if h != original][0]
driver.switch_to.window(new_window)
# ... 操作完
driver.close()
driver.switch_to.window(original)

不加等待直接 [-1] 取窗口,是偶发失败的高频原因。

3.4 等待:只用一个隐式等待

driver.implicitly_wait(0)      # 关键:不要和显式等待混用

隐式等待与显式等待混用会让实际超时时间叠加,出现"明明设了 10 秒却等了 30 秒"。

wait = WebDriverWait(driver, 10, poll_frequency=0.2,
                     ignored_exceptions=[StaleElementReferenceException])
wait.until(EC.element_to_be_clickable((By.ID, "submit"))).click()

ignored_exceptions 里加 StaleElementReferenceException 非常有用:列表刷新导致的过期元素,会在轮询中自动重新定位。

3.5 点击失败的三种真实原因

异常 根因 处理
ElementNotInteractableException 元素不可见/被禁用/在视口外 滚动到可视区,或等 element_to_be_clickable
ElementClickInterceptedException 被弹窗、loading 遮罩挡住 先等遮罩消失,或用 JS 点击(治标)
StaleElementReferenceException DOM 被重渲染 重新定位,或封装重试
def safe_click(driver, locator, retries=3):
    for i in range(retries):
        try:
            el = WebDriverWait(driver, 10).until(
                EC.element_to_be_clickable(locator))
            driver.execute_script(
                "arguments[0].scrollIntoView({block:'center'})", el)
            el.click()
            return
        except (StaleElementReferenceException, ElementClickInterceptedException):
            if i == retries - 1:
                raise

JS 点击的副作用:不触发部分事件(如 blur、原生校验),能不用就不用。

四、调试:失败时你到底看到了什么

import os, datetime

def dump(driver, name):
    stamp = datetime.datetime.now().strftime("%H%M%S")
    os.makedirs("artifacts", exist_ok=True)
    driver.save_screenshot(f"artifacts/{name}_{stamp}.png")
    with open(f"artifacts/{name}_{stamp}.html", "w", encoding="utf-8") as f:
        f.write(driver.page_source)
  • page_source 是渲染后的 DOM,比在浏览器里看源码更接近真实。
  • 截图看不到 iframe 内部内容时,先 switch_to.frame 再截。
  • 报 invalid selector 时,把定位字符串打印出来,多半是多了一个空格或引号。

pytest 夹具:

import pytest
from selenium import webdriver
from selenium.webdriver.chrome.service import Service

@pytest.fixture
def driver():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--window-size=1440,900")
    d = webdriver.Chrome(service=Service(), options=options)
    d.implicitly_wait(0)
    yield d
    d.quit()

五、环境自检脚本

上线前跑一遍,能省掉一半"环境问题":

def self_check():
    d = webdriver.Chrome(options=opts)
    d.get("data:text/html,<h1 id='t'>ok</h1>")
    assert d.find_element(By.ID, "t").text == "ok"
    print("browser:", d.capabilities["browserVersion"])
    print("driver:", d.capabilities["chrome"]["chromedriverVersion"])
    print("window:", d.get_window_size())
    d.quit()

六、检查清单

  • 浏览器与驱动大版本一致,来源是 Chrome for Testing
  • 容器内 --no-sandbox、--disable-dev-shm-usage、--shm-size、CJK 字体齐全
  • 无头模式固定窗口尺寸,与有头一致
  • 隐式等待为 0,统一用显式等待
  • iframe 切换后有 switch_to.default_content()
  • 新窗口有 number_of_windows_to_be 等待
  • 失败自动截图 + 保存 page_source
  • 定位优先 data-testid,无绝对 XPath

相关阅读