上周三下午,前端把结算页的"立即支付"按钮从<button id="pay-submit">换成了组件库封装的自定义元素,渲染出来只剩一个<div class="btn-primary">,带随机后缀的 ID 每次刷新都不一样。那天晚上跑回归,三十多条用例挂了二十一条,全是NoSuchElementException。我盯着报错日志想了很久,最后把定位方式从 ID 改成按文本找"立即支付",改完一次通过。这件事之后,我在项目里逐步把大部分交互元素的定位策略换成了文本定位——也就是 Selenium 通过元素的可见文本找到它,再执行点击、输入、读取等操作。这篇文章想聊的就是这套做法:它包含哪些可用的定位手段、每种的边界在哪里、拿到元素之后怎么安全地操作、非原生下拉框这种"文本在 li 里、状态在 JS 里"的场景怎么处理,以及我踩过的那些坑。不管你是刚开始学 Selenium 的新手,还是已经写了几百条用例想优化维护成本的老手,下面这些内容应该都能直接用上。
1. 为什么我越来越依赖文本定位:从一次改版说起
1.1 改版当天挂掉的二十一条用例
先说清楚那个下午发生了什么。前端做了一次"设计规范对齐",把所有按钮、标签页、菜单项换成了统一的组件库实现。组件库内部给每个实例生成的 ID 是自增加时间戳的形式,class 是语义化的但会被样式复用。结果就是:原来靠id="pay-submit"定位的用例全挂,靠class="btn-primary"定位的用例匹配到了页面上一堆元素,报ElementNotInteractableException或者干脆点错。
这个过程里唯一没变的,是"立即支付"这四个字。因为这是业务语言,是产品经理写在需求文档里、运营写在文案规范里的东西,前端改组件库不会顺手改它。我当时的判断是:一个元素的业务含义比它的技术标识稳定得多。文本定位的本质,就是拿"业务含义"当锚点,而不是拿 DOM 属性当锚点。
1.2 文本才是业务语言的锚点
这个思路其实不新鲜,做测试的人都知道要"面向业务设计用例"。但落到代码层面,很多人还是习惯性地打开 F12,复制id或者css selector,粘贴进find_element。这样写出来的用例能跑,但它和页面的耦合点选错了地方——你耦合的是实现细节,而不是业务意图。
举个更直观的对比。假设页面有个"导出报表"按钮,两种写法的维护成本是这样的:
| 定位方式 | 表达式 | 前端换组件库时 | 文案调整时 | 可读性 |
|---|---|---|---|---|
| ID 定位 | By.ID, "export-btn-3821" | 直接失效 | 无影响 | 差,看不出业务含义 |
| CSS 类名 | By.CSS_SELECTOR, ".ant-btn.primary" | 可能失效或误匹配 | 无影响 | 一般 |
| XPath 文本 | By.XPATH, "//button[normalize-space()='导出报表']" | 基本不受影响 | 需同步更新 | 好,一眼看出在干什么 |
| 相对定位 | with_text("导出报表") | 基本不受影响 | 需同步更新 | 好 |
表格里"文案调整时需同步更新"这一列,恰恰是很多团队拒绝文本定位的理由:文案一变,用例就挂。但我的经验是,文案变更通常走的是同一个需求流程,用例跟着改是应该的——它本来就是业务变更,用例不挂反而说明你没测到点子上。相比之下,前端的一万次重构不该影响用例。
1.3 文本定位的代价:它从来不是免费的
我得把话说明白,文本定位不是银弹,它有几个天然的成本:
第一是性能。XPath 的文本匹配需要遍历节点并计算文本内容,比 ID 查找慢一个数量级。我在一个列表页做过粗测,By.ID定位平均 8 毫秒,//*[text()='xxx']全文档扫描平均 120 毫秒左右,差 15 倍。单条用例感觉不出来,但如果一个用例里有几十次查找、跑上千条用例,累积起来就很可观。
第二是唯一性。同一个文本"确定"可能在弹窗、表单、底部按钮里各有一份。文本匹配不像 ID 天生唯一,你必须用结构信息把它约束住。
第三是文本本身的不确定性。空格、换行、全角半角、动态拼接的数字、加载中的省略号,都会让"看起来一样"的文本匹配不上。这三条我在后面会用专门的章节拆开讲。
2. 四套按文本找元素的武器,以及各自的能力边界
2.1 linkText 与 partialLinkText:只认 a 标签的老实人方案
Selenium 原生提供的文本定位方法只有两个:By.LINK_TEXT和By.PARTIAL_LINK_TEXT。它们的实现极其朴素——只匹配<a>标签的可见文本,且是精确匹配(前者)和包含匹配(后者)。很多人第一次学 Selenium 时被这两个方法误导,以为 Selenium 内置了通用的文本定位能力,结果拿它去点<button>,怎么都找不到。
from selenium import webdriver from selenium.webdriver.common.by import By driver = webdriver.Chrome() driver.get("https://example.com") # 只对 <a> 标签生效 driver.find_element(By.LINK_TEXT, "忘记密码").click() # 包含匹配,页面上有多个含"密码"的链接时会匹配第一个(或报错) driver.find_element(By.PARTIAL_LINK_TEXT, "密码")我个人的使用习惯是:只在明确知道目标是<a>且全站唯一时用LINK_TEXT。原因有两个,一是它的匹配语义是"整个链接文本完全相同",文本里多一个空格、多一个图标对应的不可见字符就会失败;二是它不支持任何结构约束,没有"在页脚的链接里找"这种表达。相比之下 XPath 能表达的东西多得多,所以日常我更倾向统一用 XPath,减少心智负担。
提示:
PARTIAL_LINK_TEXT在页面上有多个匹配时不会抛异常,它会返回第一个。这在调试阶段很危险,容易让你误以为定位正确,直到某个版本页面顺序调整后才暴露。
2.2 XPath 的 text() 与 contains():主力方案
真正的通用方案是 XPath。最基础的两个写法是精确匹配和包含匹配:
# 精确匹配:元素的文本恰好等于"提交订单" By.XPATH, "//*[text()='提交订单']" # 包含匹配:元素文本中包含"提交" By.XPATH, "//*[contains(text(), '提交')]" # 限定标签类型,能显著提速并减少误匹配 By.XPATH, "//button[text()='提交订单']" By.XPATH, "//span[contains(text(), '提交')]"这里有一个新手最容易踩的细节:text()在 XPath 1.0 里是"该节点的直接文本子节点",不包含后代元素的文本。看下面这段 HTML:
<div class="price"> <span class="symbol">¥</span> <span class="num">199.00</span> </div>//div[text()='¥199.00']匹配不到,因为 div 的直接文本子节点只有空白,¥和199.00分别在两个 span 里。正确的写法是//div[normalize-space()='¥199.00'],或者用点号代表整个元素的字符串值://div[normalize-space(.)='¥199.00']。这个区别我在带新人的时候讲过无数次,但只要没实际踩过,看文档很难建立直觉。
另一个高频坑是contains(text(), 'xx'),它的原理是判断"第一个文本子节点是否包含 xx"。如果元素文本被拆成了多个子节点,同样会失效。稳妥写法是contains(., 'xx')或contains(normalize-space(.), 'xx')。我现在的习惯是统一用.加normalize-space(),不再纠结text()的语义边界。
2.3 normalize-space():处理换行、制表符与首尾空白
normalize-space()是我在文本定位里用得最多的函数,没有之一。它的作用是把字符串首尾空白去掉,并把中间的连续空白字符(空格、制表符、换行)压缩成一个空格。为什么需要它?因为现代前端几乎不会把按钮文字写成一行整齐的 HTML:
<button class="submit"> 确认 提交 </button>这段 HTML 在浏览器里渲染出来是"确认 提交",视觉上是一行,但 DOM 里的文本内容是"\n 确认\n 提交\n"。如果你用//button[text()='确认 提交']去匹配,必然失败。而//button[normalize-space()='确认 提交']就能正确命中。
同样的问题在 Vue 的模板里特别常见,因为模板缩进会被保留成文本节点。我现在的做法是:只要不是能确保文本纯净的简单场景,一律在 XPath 里加normalize-space()。多打十来个字符,省下的是半小时的排查时间。
2.4 Selenium 4 的 withText 相对定位与"文本加结构"的组合
Selenium 4 引入了相对定位器(Relative Locators),其中with_text()可以用文本作为过滤条件:
from selenium.webdriver.support.relative_locator import with_tag_name # 找到文本为"用户名"的标签右侧的输入框 label = driver.find_element(By.XPATH, "//label[normalize-space()='用户名']") input_box = driver.find_element( with_tag_name("input").to_right_of(label) )另外 Selenium 4 在 Python 里没有官方getByText,但很多团队会自己封装一个byText工厂函数(我在第 6 节会给完整实现)。相对定位的价值在于,它把"文本"和"空间关系"结合起来,表达力比纯 XPath 更贴近人的思维方式——"收货人后面的那个输入框",这句话本身就是一个相对定位。
如果需要在 JS 生态里做同样的事,@testing-library系列的getByText思路值得借鉴:它默认忽略大小写、自动归一化空白、并能指定exact参数。这个设计后来被很多自动化框架抄了过去,包括我们自己封装的工具函数。
3. 写对定位表达式:从"能跑"到"不脆"
3.1 精确匹配还是包含匹配,这是个判断题
包含匹配在页面上几乎没有独一无二的可能,因为按钮文字往往互相包含:"提交"、"提交订单"、"提交并支付"。你用contains(., '提交')去找,很可能拿到文本框旁边的一个提示,或者一个隐藏的 aria-label。我的判断规则很简单:
- 待匹配文本是短词或通用词(确定、取消、提交、保存),一律用精确匹配加标签约束,
//button[normalize-space()='确定']。 - 待匹配文本是长句或唯一性强的短语("您的订单已提交成功"),可以用包含匹配,因为撞车的概率低。
- 文本里含动态内容("共 128 条记录"),必须用包含匹配并把动态部分切掉,只匹配"共"和"条记录"之间的稳定片段。
第三种情况有个技术细节值得单独说。XPath 1.0 没有正则,所以处理"共 N 条记录"这种必须靠starts-with()和contains()组合,或者干脆用contains(., '条记录')加//div的 class 约束。如果需要更复杂的匹配,可以退一步:用find_elements拿到一批候选,再用 Python 的re在内存里过滤。这个思路在第 5 节会展开。
3.2 用祖先锚点把匹配范围收窄
假设一个电商页面,"确定"按钮出现在三处:地址选择弹窗、优惠券弹窗、删除确认弹窗。三个弹窗可能同时存在于 DOM 里(只是隐藏了)。这时候//button[normalize-space()='确定']会匹配到三个,Selenium 抛InvalidSelectorException或者返回第一个,行为不可控。
解法是用容器把它锚住:
# 只在优惠券弹窗内找确定按钮 By.XPATH, "//div[contains(@class,'coupon-dialog')]//button[normalize-space()='确定']" # 用弹窗标题作为兄弟节点锚点 By.XPATH, "//div[.//h3[normalize-space()='选择优惠券']]//button[normalize-space()='确定']"第二种写法看起来绕,但它其实很实用:它表达的是"包含'选择优惠券'标题的那个弹窗容器里的确定按钮",完全不依赖容器的 class 名。前端换样式系统、改 class 命名,这段表达式都不受影响。我现在写定位时,只要元素本身没有强唯一性,就一定会往上找一两个有语义的祖先做锚点。
注意:锚点的层级不要贪多。有些同事为了"绝对准确",写出六层嵌套的 XPath,结果页面加一个 div 就全挂。我的经验是锚点不超过两层,且必须选语义稳定的节点。
3.3 大小写、全角半角与不可见字符
这三个是文本定位里最阴的坑,因为它们在页面上看起来完全一样。
大小写:XPath 1.0 的=和contains()都是大小写敏感的。中文界面无所谓,但 SaaS 产品里"Save"和"SAVE"可能共存。Selenium 4.9 之后的 XPath 仍然不支持lower-case(),解决办法是在页面结构约束上做文章,或者在 Python 侧过滤。
全角半角:中文输入法状态下打出的":"",""()"是全角,而很多前端组件的文案是由设计稿导出的,混用严重。全角空格 U+3000 尤其恶劣,因为它看起来和普通空格一模一样,normalize-space()也不会把它压缩掉(它只处理 XML 规范里的空白字符)。
不可见字符:零宽空格 U+200B、零宽非连接符 U+200C、字节顺序标记 U+FEFF,这些经常被后端模板或富文本编辑器偷偷塞进来。我遇到过一次最离谱的:运营在后台配置按钮文案时,从 Word 里复制粘贴,带进来一个零宽字符,导致按文本定位的用例在那一个环境的预览站上全挂,正式环境却正常。
处理办法是打印出元素的文本并转成 Unicode 码点看:
el = driver.find_element(By.CSS_SELECTOR, ".submit-btn") raw = el.get_attribute("textContent") print(repr(raw)) # 直接看到 \\u200b、\\u3000 这类字符 print([hex(ord(c)) for c in raw])只要打印一次,问题立刻现形。我建议在项目里准备一个调试用的小脚本,专门干这件事,比反复猜要快得多。
3.4 多语言与文案频繁变更下的写法定型
如果项目要做多语言,按文本定位有个天然的麻烦:同一套用例要在中英文两套页面跑。我的做法是不把文本写进用例,而是写进资源文件:
locators = { "zh": {"submit_order": "提交订单", "confirm": "确定"}, "en": {"submit_order": "Place Order", "confirm": "Confirm"}, } def by_text(key, lang="zh"): return (By.XPATH, f"//*[normalize-space()='{locators[lang][key]}']")用例里只出现by_text("submit_order"),具体文案由配置文件决定。这样一来,加一种语言只需要加一个字典,不用动任何用例代码。这个小改造在一个做跨境电商的项目里帮我省了大量重复工作。
4. 定位之后:点击、输入、读取的正确姿势
4.1 点击失败的三种形态,以及各自的原因
定位成功不等于操作成功。Selenium 的click()在内部做的是"计算元素中心点坐标,然后派发一个鼠标事件到该坐标",所以只要坐标上有别的元素挡着,事件就落到别人头上。这也是为什么文本定位到的元素经常点不动——你找到的是那个<span>,但真正接收点击的是它的父级或兄弟层。
我遇到的点击失败基本分三类:
| 现象 | 典型原因 | 处理方式 |
|---|---|---|
ElementNotInteractableException | 元素不可见、尺寸为零、被pointer-events:none屏蔽 | 等可见性,或点击可交互祖先 |
ElementClickInterceptedException | 有遮罩、悬浮层、加载动画盖在上面 | 等遮罩消失,或用 JS 点击兜底 |
| 点击无反应、无异常 | 事件绑在父级,子元素不接收事件 | 向上找带@click的祖先元素 |
第三种最难查,因为它不报错。我的排查方法是在浏览器控制台里执行document.elementFromPoint(x, y),看看那个坐标上实际是哪个元素,对比一下就清楚了。
关于 JS 兜底点击driver.execute_script("arguments[0].click()", el),我的态度是:能用,但要当成最后手段。它绕过浏览器的真实事件链路,可能导致被测代码里的event.target不是预期值,掩盖真实 bug。我在项目里给它加了使用标注,每次用到都要在代码注释里说明为什么不走原生点击。
4.2 输入框写入、清空,以及把光标定位到指定文本框
输入操作的核心问题是清空。element.clear()在某些前端框架(尤其是受控组件)里会失效,因为框架会在input事件里把值重置回去。我踩过一次:React 的受控输入框,clear()之后打印get_attribute("value")是空的,但下一个字符输入进去,原来的内容又回来了。
稳妥的清空方式是组合操作:
from selenium.webdriver.common.keys import Keys def set_text(el, text): el.click() # 先聚焦,把光标定位到该输入框 el.send_keys(Keys.CONTROL, "a") # 全选 el.send_keys(Keys.DELETE) # 删除 el.send_keys(text) # 触发框架的变更通知 driver.execute_script( "arguments[0].dispatchEvent(new Event('input', {bubbles: true}));", el, )el.click()这一步就是"把鼠标光标定位到某个文本框中"的常见做法。它比el.send_keys()直接写入更可靠,因为某些组件把聚焦逻辑绑在mousedown上,不聚焦的话输入不会触发校验和联动。如果点击会被遮挡,可以用driver.execute_script("arguments[0].focus()", el)替代,效果类似但更轻量。
另外提醒一句:send_keys输入中文在部分驱动版本上会有问题,特别是无头模式。我遇到过的现象是中文丢失、只输入了拼音或者干脆没输入。稳妥做法是优先用execute_script直接设值加派发事件,或者确保使用最新版驱动。这个坑在 CI 上特别难复现,因为本地跑往往正常。
4.3 读取文本:text 与 textContent 的区别
拿到元素之后要读文本做断言,这里有两个属性,很多人混用:
element.text:Selenium 的实现是读innerText,会受 CSS 影响。display:none的元素返回空字符串,text-transform:uppercase的元素返回大写后的结果。element.get_attribute("textContent"):读原始 DOM 内容,不受 CSS 影响,隐藏元素也能读到,保留换行和空格。element.get_attribute("innerText"):介于两者之间,受 CSS 影响但不做 Selenium 的额外可见性处理。
我的选择规则是:做用户视角的断言用text,做数据校验用textContent。比如断言按钮显示"已提交",用text更贴近真实;校验表格里某单元格的原始值,用textContent更可靠,因为隐藏列也能读到。
5. 非原生下拉框实战:div 加 ul 加 li 的完整操作链路
5.1 先枚举所有 li,把元素当数据看
原生<select>在 Selenium 里有专门的Select类,select_by_visible_text()一行搞定。但现代前端几乎没人用原生 select,尤其是组件库,基本都是<div class="select"><ul><li>...</li></ul></div>这套结构,或者干脆把下拉渲染到document.body下的一个浮层里。
这种场景的操作逻辑和原生完全不同:没有"选择"这个动作,只有"点击触发、点击目标、点击确认"三步。而"点击目标"之前,你得先知道有哪些选项。
我的第一步永远是枚举:
def dump_options(driver): options = driver.find_elements( By.XPATH, "//ul[contains(@class,'dropdown-menu')]/li" ) for i, li in enumerate(options): print(i, repr(li.text), li.get_attribute("data-value"))把 index、显示文本、>from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import ElementClickInterceptedException wait = WebDriverWait(driver, 10) # 1. 打开下拉:点击触发器 trigger = wait.until(EC.element_to_be_clickable( (By.XPATH, "//div[contains(@class,'select-trigger')]" "[.//span[normalize-space()='请选择城市']]") )) trigger.click() # 2. 等选项容器出现并可见 panel = wait.until(EC.visibility_of_element_located( (By.XPATH, "//ul[contains(@class,'dropdown-menu') and not(contains(@style,'display: none'))]") )) # 3. 在面板内定位目标项,必要时先滚动到可见 target = panel.find_element(By.XPATH, ".//li[normalize-space()='杭州市']") driver.execute_script("arguments[0].scrollIntoView({block:'center'});", target) # 4. 点击,并对遮挡做一次重试 try: target.click() except ElementClickInterceptedException: driver.execute_script("arguments[0].click()", target) # 5. 校验:等面板收起,等触发器文本更新 wait.until(EC.invisibility_of_element(panel)) wait.until(EC.text_to_be_present_in_element( (By.XPATH, "//div[contains(@class,'select-trigger')]"), "杭州市" ))
这段代码里有几个我想强调的点。第一,第 2 步用visibility_of_element_located而不是presence_of_element_located,因为下拉面板的 DOM 通常是常驻的,用 display 控制显隐,presence 会立刻返回,然后你点了个隐藏的 li。第二,第 3 步的滚动很关键,长列表下拉里目标项可能在可视区之外,不滚动直接点会点到别的项。scrollIntoView({block:'center'})里的 center 参数是为了避免滚到边缘后被固定头部遮挡。第三,第 5 步的校验才是真正的"断言",前面的点击只是动作。
5.3 选中之后怎么确认真的选上了
我见过很多用例止步于"点击选项",没有任何校验。这样写出来的用例只能发现"点不动"这类问题,发现不了"点了但值没存进去"这类真正的业务 bug。
校验要分两层。第一层是 UI 层,触发器上的文本变了、选中项有了高亮 class:
selected = panel.find_element(By.XPATH, ".//li[contains(@class,'is-selected')]") assert selected.text.strip() == "杭州市"第二层是数据层,如果表单是提交型的,提交后查接口返回或者列表页的展示值。第二层更重要,因为它校验的是端到端的数据一致性,而不是组件的自我表演。我一般会把这两层拆成两个用例,UI 层的跑得快、反馈快,数据层的放在主流程里。
还有一种情况是"多选下拉",点完一个选项面板不关闭,可以连续点。这时候校验就要遍历所有带选中 class 的项,用集合比较而不是字符串比较。这个细节看着小,但多选场景下的顺序是不确定的,用列表比较会变成随机失败。
5.4 虚拟滚动与远程加载下拉的应对
当选项超过几百个时,前端往往会做虚拟滚动——只渲染可视区域内的十几个 li,滚动时动态替换。这时候find_elements拿到的永远只有那十几个,你想找的那一项可能压根不在 DOM 里。
处理思路有两种。一种是用搜索框:大多数虚拟滚动下拉会带一个可输入的搜索框,输入关键词让列表过滤到只剩几条,再点击。这种方式最稳,因为它绕开了滚动逻辑。
另一种是自己模拟滚动。思路是反复执行"滚到底部 - 等新数据 - 找目标",直到找到或者超过重试上限:
def scroll_until_found(panel, text, max_rounds=20): for _ in range(max_rounds): for li in panel.find_elements(By.XPATH, ".//li"): if li.text.strip() == text: return li driver.execute_script( "arguments[0].scrollTop = arguments[0].scrollHeight", panel ) time.sleep(0.3) # 等一帧渲染 raise TimeoutError(f"滚动 {max_rounds} 轮仍未找到选项: {text}")这种写法有点笨,但它可靠。我更倾向在后端 API 层做这类数据的校验,UI 层只验证"能搜到并选中"这一条路径,把大量数据的遍历交给接口测试。
6. 把文本定位封装成团队能用的东西
6.1 一个 by_text 工厂函数
零散地写 XPath 字符串,三个月后没人记得为什么要加那个normalize-space()。我现在的做法是统一走一个工厂函数,把常见需求都封进去:
from selenium.webdriver.common.by import By def xpath_literal(s: str) -> str: """安全地把任意字符串转成 XPath 字面量,兼容单双引号共存。""" if '"' not in s: return f'"{s}"' if "'" not in s: return f"'{s}'" parts = s.split('"') return "concat(" + ', \'"\', '.join(f'"{p}"' for p in parts) + ")" def by_text(text, tag="*", exact=True, container=None): lit = xpath_literal(text.strip()) pred = f"normalize-space()={lit}" if exact else f"contains(normalize-space(), {lit})" prefix = f"{container}//" if container else "//" return (By.XPATH, f"{prefix}{tag}[{pred}]")xpath_literal那个函数值得单独解释一下。XPath 1.0 的字面量不能同时包含单引号和双引号,所以当待匹配文本是他说"你好"这种时,直接拼字符串会语法错误。这个 concat 的写法是个老技巧,能处理任意组合。这个坑我在匹配一段带引号的产品名时踩过一次,报的是InvalidSelectorException,日志里没有提示具体原因,查了很久。
6.2 定位元数据与测试逻辑分开存放
"仅存储定位元数据"这个说法我在一些团队的规范文档里见过,我理解它的意思是:页面对象里只放定位表达式和操作方法的骨架,不放测试数据和业务断言。严格这么做有点教条,但方向是对的。
我的分层是这样的:
| 层 | 内容 | 变更频率 |
|---|---|---|
| 定位层 | 元素的 XPath、文本常量、容器锚点 | 低,页面改版时改 |
| 操作层 | 点击、输入、下拉选择的组合动作 | 中,交互流程变化时改 |
| 用例层 | 测试数据、业务断言、流程编排 | 高,需求变化时改 |
好处很直接:文案调整时,你只改定位层的常量字典;流程调整时,你只改操作层和用例层。如果三者混在一坨,任何一处变动都要全文搜索替换,出错概率指数上升。我把这个结构在一个两百多条用例的项目里推过一次,前期的迁移成本大概两天,之后每轮回归的维护时间从平均四小时降到了四十分钟左右。
6.3 显式等待与重试的统一封装
文本定位的失败里,很大一部分是时序问题:元素还没渲染完就去找了。sleep当然能解决,但那是用总时长换取稳定性,代价太大。统一用显式等待:
def find_by_text(driver, text, tag="*", timeout=10): wait = WebDriverWait( driver, timeout, ignored_exceptions=(NoSuchElementException, StaleElementReferenceException), ) return wait.until(EC.element_to_be_clickable(by_text(text, tag=tag)))把StaleElementReferenceException加进ignored_exceptions是个小技巧。SPA 页面里元素会被反复重建,你前一步拿到的元素引用,到下一步就失效了。默认情况下这个异常会直接中断等待,加入忽略列表之后,等待会继续重试直到超时,成功率高很多。但要注意,忽略它会让真正的元素失效问题被掩盖更久,所以超时时间不要设太长,我一般设 10 秒。
7. 环境搭建与最小可运行骨架
7.1 安装 Selenium 与驱动管理
从零开始的话,安装本身很简单,但驱动管理的细节值得说清楚:
python -m pip install --upgrade pip pip install selenium从 Selenium 4.6 开始,Python 绑定内置了 Selenium Manager,会自动检测本地浏览器版本并下载匹配的驱动。这是最省事的方式,我新环境基本都靠它。但如果公司网络限制外网、或者 CI 环境需要离线部署,就得手动指定驱动路径:
from selenium import webdriver from selenium.webdriver.chrome.service import Service service = Service(executable_path="/opt/drivers/chromedriver") options = webdriver.ChromeOptions() options.add_argument("--no-sandbox") driver = webdriver.Chrome(service=service, options=options)--no-sandbox是容器化环境里的常用参数,不加的话在某些 Linux 镜像里会启动失败。另外无头模式下我习惯再加--window-size=1920,1080,因为默认窗口尺寸很小,很多响应式页面会渲染成移动端布局,导致按文本定位时找到的是移动版文案。
7.2 一个能直接跑的最小骨架
把前面讲的东西串起来,这是一个可以立刻复制用的骨架:
import time from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class TextLocatorDriver: def __init__(self, headless=True): opts = Options() if headless: opts.add_argument("--headless=new") opts.add_argument("--window-size=1920,1080") opts.add_argument("--no-sandbox") self.driver = webdriver.Chrome(options=opts) self.wait = WebDriverWait(self.driver, 10) def find_text(self, text, tag="*", exact=True): pred = "normalize-space()" + ("=" if exact else f"=*") # 实际使用下面的写法更稳妥 expr = ( f"//{tag}[normalize-space()='{text}']" if exact else f"//{tag}[contains(normalize-space(), '{text}')]" ) return self.wait.until( EC.element_to_be_clickable((By.XPATH, expr)) ) def click_text(self, text, tag="*"): self.find_text(text, tag).click() def type_into(self, label_text, value): # 找到 label 右侧或下方的输入框 box = self.driver.find_element( By.XPATH, f"//label[normalize-space()='{label_text}']" f"/following::input[1]", ) box.click() box.send_keys(Keys.CONTROL, "a") box.send_keys(Keys.DELETE) box.send_keys(value) def quit(self): self.driver.quit()following::input[1]这个轴表达式做的是"标签之后第一个 input",常见于表单场景,挺好用。但它的前提是 DOM 顺序符合视觉顺序,如果前端用了 grid 布局把 label 和 input 拆到两个容器里,这个表达式就会找错。稳妥做法是先看 DOM,确认是同级相邻还是跨容器。
7.3 启动阶段最常见的三个报错
第一个是驱动版本不匹配,报SessionNotCreatedException,信息里会写"Chrome version must be between X and Y"。升级 selenium 库或让 Selenium Manager 自动处理即可。
第二个是WebDriverException: unknown error: cannot find Chrome binary,说明容器里没装浏览器。这个跟 Selenium 无关,需要在镜像里装浏览器。
第三个是权限相关的Permission denied,一般出现在 Linux 下驱动文件没有可执行权限。chmod +x一下就行。
这三个几乎涵盖了新手启动阶段的全部失败场景。我建议第一次搭建时先用最简单的脚本跑通打开页面的动作,再往上加业务逻辑,别一上来就写完整的页面对象。
8. 故障排查手册:文本定位最常见的六种翻车
8.1 逐条对照的排查表
下面这个表是我自己整理的速查表,遇到文本定位失败时按顺序排查:
| 现象 | 可能原因 | 验证方法 | 处理方式 |
|---|---|---|---|
| 找不到元素,但肉眼可见 | 文本含不可见字符或全角空格 | 打印repr(el.textContent) | 改用包含匹配或清理文本 |
| 找不到元素 | 元素在 iframe 内 | 在控制台确认所属 document | switch_to.frame() |
| 找到但不唯一,报错 | 页面存在多个同文本元素 | find_elements看数量 | 加容器锚点约束 |
| 元素存在但不可点击 | 被遮罩或动画覆盖 | elementFromPoint看坐标元素 | 等遮罩消失或滚动 |
| 点击无反应 | 事件绑在祖先元素 | 控制台查看事件监听 | 点击祖先 |
| 文本读出来是空的 | 元素隐藏或 CSS 影响 | 对比text与textContent | 按需切换读取方式 |
8.2 iframe 与 Shadow DOM 这两个"套娃"场景
iframe 是老问题了,但包装得很隐蔽。有个页面的登录按钮在 iframe 里,你按文本找永远找不到,因为driver的作用域在顶层 document。解决方式是driver.switch_to.frame(...),操作完再driver.switch_to.default_content()切回来。我习惯给切换写一个上下文管理器,避免忘记切回:
from contextlib import contextmanager @contextmanager def in_frame(driver, frame_locator): frame = driver.find_element(*frame_locator) driver.switch_to.frame(frame) try: yield finally: driver.switch_to.default_content()Shadow DOM 更麻烦,因为 XPath 天生无法穿透 shadow root。Web Component 满天飞的页面里,document.querySelector都查不到 shadow 内部的节点,Selenium 的原生定位自然也不行。目前可行的路径是通过execute_script在页面上下文里逐层进入shadowRoot拿引用,再转成 WebElement:
host = driver.find_element(By.CSS_SELECTOR, "my-component") inner = driver.execute_script( "return arguments[0].shadowRoot.querySelector('button');", host ) inner.click()这段代码能跑,但可读性和稳定性都不好。我的建议是:如果项目里 Shadow DOM 用得很多,认真评估一下换成支持穿透 shadow root 的框架,或者在应用侧开放测试专用的属性(比如>actual = wait.until( EC.visibility_of_element_located( (By.XPATH, "//div[normalize-space()='订单已提交']") ) ).text assert actual.strip() == "订单已提交", ( f"提示文案不符,期望 '订单已提交',实际 {actual!r}" )
用wait.until直接等目标文本出现,而不是等元素出现再比较文本,这样等待和断言合二为一,既稳定又简洁。这个写法是我目前项目里的默认模式。
我自己在这套东西上最大的体会是:文本定位的难点从来不在"怎么写 XPath",而在"怎么让这段表达式在一百次改版之后还活着"。所以真正花时间的部分,是选锚点、定常量、写封装、看日志。前面第 6 节那套分层结构,我建议你哪怕只做一半——先把文本常量抽到一个文件里——收益就已经很明显了。另外提醒一句,每次新写一条按文本定位的表达式,顺手在浏览器控制台里用$x()验证一遍再贴进代码,这个习惯能省掉大量"跑一遍看报错"的往返时间。