Selenium 环境搭建与元素定位¶
UI 自动化的失败,八成不是"定位写错",而是环境不对、时机不对、上下文不对。这篇按"装得上 → 定得准 → 找得到"三段写,报错都是实际遇到的原文。
一、装得上:驱动与浏览器的版本对齐¶
1.1 版本矩阵¶
Selenium 4.6 起自带 Selenium Manager,会自动下载匹配的驱动:
但自动下载有两个前提:能访问外网、浏览器版本能被识别。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 的两个致命参数¶
根因是 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 侧还要给共享内存:
中文页面渲染成方框,是镜像缺字体:
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" 会直接报错:
要用 CSS:By.CSS_SELECTOR, ".btn.btn-primary"。
2.1 定位优先级(按稳定性排)¶
data-testid/data-test—— 推动开发加,最稳。- 稳定
id、name。 - 语义属性:
[aria-label]、[role]、[name]。 - 文本定位(
text=在 Playwright;Selenium 用 XPathcontains(text(),'登录'))。 - 结构 CSS。
- 绝对 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 无法直接访问,只能:
仍为 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 等待:只用一个隐式等待¶
隐式等待与显式等待混用会让实际超时时间叠加,出现"明明设了 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