最近我们在整理一批自动化回归脚本,表面上看失败原因五花八门:有的点不到按钮,有的输不进表单,有的断言时直接抛 NoSuchElementException。但把日志和失败录像对在一起看了一轮后,线索很快收敛到了同一个问题:不是业务流程变复杂了,而是元素定位从一开始就没站稳。
这类问题很容易被误判。刚开始我也以为是等待时间不够,或者前端渲染偶发变慢,于是先去补显式等待、加重试、调超时。结果脚本确实“活久了一点”,但不稳定的问题并没有消失。后来我们回头看 locator 的写法,才发现很多失败根本不是“等不到”,而是“找错了”。
所以这篇文章我不打算从 API 手册角度去讲 Selenium 有哪些定位方式,而是把我们在项目里反复踩过的坑和最终形成的选择顺序梳理出来:元素定位不是语法题,而是稳定性设计题。
先别急着写 XPath,我们先问两个问题
在工程里选 locator,我通常只看两个维度:
唯一性,也就是这个表达式最终是不是只会命中一个目标元素。
稳定性,也就是页面小改版、样式调整、组件重构之后,它还能不能继续命中。
很多脚本一开始能跑,并不代表定位写对了。它可能只是“恰好今天还没坏”。尤其是组件化前端里,DOM 结构、类名、包裹层级都在变,靠视觉感觉写出来的路径往往经不起两轮迭代。
我们后来把常用定位方式按这个标准重新排了一遍优先级,结论并不复杂:
| 定位方式 | 代码示例 | 我们的实际判断 |
|---|---|---|
| ID | By.ID, "id_value" |
首选。唯一性通常最好,受页面结构变化影响最小。 |
| Name | By.NAME, "name_value" |
常见于表单字段,够直接,但要确认页面里不是重复 name。 |
| Class Name | By.CLASS_NAME, "class_name" |
能用,但谨慎。样式类经常跟着 UI 重构走。 |
| Tag Name | By.TAG_NAME, "button" |
适合拿一组元素,不适合直接赌唯一目标。 |
| Link Text | By.LINK_TEXT, "退出登录" |
对纯文本链接很实用,但文案改动会带来波动。 |
| Partial Link Text | By.PARTIAL_LINK_TEXT, "退出" |
适合兜底,不适合高精度场景。 |
| CSS Selector | By.CSS_SELECTOR, "css_syntax" |
项目里用得最多。表达力够强,性能也更稳。 |
| XPath | By.XPATH, "xpath_syntax" |
万能,但越万能越容易被滥用。 |
不是说 XPath 就比 ID 更强,也不是 CSS 就天然比别的写法“专业”。真正决定脚本寿命的,不是你用了什么语法,而是你有没有抓住页面里那个不容易变的锚点。
有 ID 就别绕路,别把简单问题写复杂
我们接手过一批老脚本,明明目标元素已经带了稳定 ID,结果代码里还是层层嵌套去找父节点、子节点,最后拼出一段很长的 XPath。这样写的直接后果不是“更精准”,而是把一个原本很稳的锚点,变成了一个依赖页面结构的脆弱链条。
例如登录框这种场景,如果页面已经给了:
<input id="username" name="username" type="text" />
那我们就直接写:
username_input = driver.find_element(By.ID, "username")
username_input.send_keys("admin")
而不是这样:
username_input = driver.find_element(
By.XPATH,
"//div[@class='login-form']/div[1]/input"
)
username_input.send_keys("admin")
这两段代码今天都可能能跑,但后者把定位绑死在 DOM 层级上了。只要前端多包一层容器、调整表单布局,脚本立刻失效。问题不在 Selenium,而在我们自己主动选择了更脆的依赖。
没有 ID 时,我们更偏向 CSS Selector
在真实项目里,“全站都设计了稳定 ID”基本是理想状态。更多时候我们面对的是 React、Vue 渲染出来的组件树,元素没有 ID,类名还带 hash,甚至会随着构建策略变化。这种场景下,我们通常优先写 CSS Selector,而不是第一时间冲去堆 XPath。
原因很简单:CSS Selector 足够快,语法也更贴近前端本身的选择逻辑。尤其是在基于属性、层级和部分匹配做定位时,可读性通常更好。
例如搜索框这种输入元素,如果 name 稳定:
search_input = driver.find_element(By.CSS_SELECTOR, "__SENTINEL_METABIND_$[ input ]")
search_input.send_keys("Selenium")
如果没有明确 name,但 placeholder 文案相对稳定:
search_input = driver.find_element(
By.CSS_SELECTOR,
"__SENTINEL_METABIND_$[ input ]"
)
search_input.send_keys("Selenium")
如果我们需要走一层父子关系,也可以这样写:
second_link = driver.find_element(
By.CSS_SELECTOR,
"div#kw a:nth-child(2)"
)
这里的关键不是“CSS 比 XPath 高级”,而是 CSS 在很多场景下足够表达需求,同时不会把定位写得太重。团队里一旦默认从 CSS 开始,很多 locator 会自然收敛到比较克制的写法。
我们一开始吃过这个亏。前端同学把类名从 .search-input 换成了组件库生成的哈希类,视觉没变,功能没变,自动化全挂。后来我们统一约定:样式类只能作为辅助线索,不能默认当成长期稳定锚点。
XPath 不是不能用,而是要用在它真正擅长的地方
XPath 在项目里当然有价值,尤其是当你需要按文本查找、做层级回溯、利用兄弟节点或父子轴关系时,它的表达力很难被 CSS 完整替代。
但 XPath 最容易出问题的地方也在这里:一旦表达力太强,大家就会顺手把它写成“万能抓取器”,最后把页面结构、节点顺序、容器层级全都硬编码进去。脚本能跑,但生命周期很短。
我们后来给团队定了一个经验规则:XPath 优先解决“语义关系”问题,不要拿它去描述整个页面地形。
比如按钮文本比较稳定时:
submit_btn = driver.find_element(
By.XPATH,
"//__SENTINEL_METABIND_$[ button ]"
)
submit_btn.click()
比如表单标签和输入框存在固定的邻接关系时:
username_input = driver.find_element(
By.XPATH,
"//label[text()='用户名']/following-sibling::input"
)
username_input.send_keys("admin")
比如我们需要从输入框反查它的容器,做局部断言或局部操作时:
container = driver.find_element(
By.XPATH,
"//__SENTINEL_METABIND_$[ input ]/parent::div"
)
这些 XPath 都在利用“稳定语义”定位,而不是在赌页面有几层 div、第几个节点是谁。这个区别非常关键。
我们走过一次弯路:以为是等待问题,其实是定位策略问题
有一段时间,我们购物车页面的删除操作失败率很高。日志看上去很像经典异步问题:按钮有时找得到,有时找不到;加长等待后成功率会回升一点,但始终不稳定。
一开始我们的 locator 是按列表结构硬取第 N 个按钮:
delete_btn = driver.find_element(
By.XPATH,
"//div[@class='cart-list']/div[3]//button"
)
这个写法在测试环境里勉强成立,因为测试数据比较固定,第三个商品大多数时候就是目标商品。但到了真实回归环境,商品排序会变,推荐位会插入,促销标签也会改 DOM 结构。脚本失败并不是因为页面“慢”,而是因为“第三个”这件事根本不稳定。
后来我们把思路反过来:先找到稳定的商品标题,再围绕标题找对应按钮,问题一下子变得简单了很多。
Selenium 4 的相对定位,适合拿来处理“布局稳定、属性不稳”的区域
Selenium 4 引入 Relative Locators 之后,我们在一些列表、卡片、配置面板类场景里确实省了不少事。尤其是那些元素本身没有稳定 ID,但界面布局长期固定的页面,相对定位比硬写深层 XPath 更接近人类看页面的方式。
例如购物车里删除按钮都在商品标题右边,我们会这样写:
from selenium.webdriver.common.by import By
from selenium.webdriver.support.relative_locator import locate_with
product_name = driver.find_element(By.LINK_TEXT, "Python 自动化实战")
delete_btn = driver.find_element(
locate_with(By.TAG_NAME, "button").to_right_of(product_name)
)
delete_btn.click()
这段代码真正有价值的地方,不是语法看起来更“新”,而是它把定位表达从“DOM 第几层第几个按钮”改成了“标题右侧那个按钮”。当页面布局语言本身是稳定的,这种写法通常更贴近真实业务意图。
常用的几个方向关系包括:
-
.above() -
.below() -
.to_left_of() -
.to_right_of() -
.near()
不过这里也要提醒一句,相对定位不是银弹。它适合布局关系足够清晰、参考物足够稳定的界面。如果页面本身响应式变化很大,或者同一区域内元素密度太高,单纯依赖“左边右边附近”也可能误命中。
我们不会默认把 Relative Locators 当成第一选择。它更适合在“目标元素缺少可靠属性,但参考元素很稳定”的时候补位,而不是全面替代 CSS 或 XPath。
真正影响脚本寿命的,是这套选择顺序
到后面我们不再讨论“哪种语法最强”,而是直接在代码评审里看 locator 有没有遵守一套固定顺序。大致是下面这样:
第一优先级:稳定 ID 或测试专用属性
如果前端愿意配合,最好直接给自动化留 id、data-testid、data-test 这类属性。
这一步的收益比后面所有技巧加起来都更大。
login_btn = driver.find_element(By.CSS_SELECTOR, "[data-testid='login-button']")
login_btn.click()
这类属性不会为了样式重构而变,也不会因为文案改动而失效。我们后来越来越倾向于把 locator 稳定性前置到前后端协作里,而不是让测试在页面上线后自己“猜”。
第二优先级:CSS 属性定位
没有专用属性时,优先看 name、type、placeholder、role、aria-* 这些相对稳定的信息。
email_input = driver.find_element(
By.CSS_SELECTOR,
"__SENTINEL_METABIND_$[ input ][name='email']"
)
第三优先级:基于文本或语义关系的 XPath
当元素本身属性不稳,但周边文案、标签、上下文关系稳定时,再上 XPath。
password_input = driver.find_element(
By.XPATH,
"//label[text()='密码']/following-sibling::input"
)
第四优先级:相对定位
适合那些视觉布局规律非常强,但 DOM 属性没有明显抓手的区域。
最后兜底:列表扫描与过滤
有些复杂组件里,单个 locator 很难一步命中。这个时候我们宁愿先拿一组元素,再在代码里做二次过滤,也不愿意写一条不可维护的超长 XPath。
buttons = driver.find_elements(By.TAG_NAME, "button")
target = next(
btn for btn in buttons
if btn.text.strip() == "删除"
)
target.click()
这个办法不是最精致,但在少数动态场景下,比“赌一条复杂路径永远不变”更务实。
我们现在基本不允许再出现“死亡路径”
所谓“死亡路径”,就是那种从 /html/body/... 开始一路写到目标元素的绝对路径。它的问题不是难看,而是它默认页面结构永远不变。这在现代前端里几乎不成立。
例如下面这种写法:
driver.find_element(
By.XPATH,
"/html/body/div[1]/div[2]/div[3]/form/div[1]/input"
)
只要前端多加一层弹窗容器、插入一个提示条、调整一个布局节点,这条路径就断。我们线上见过太多“昨天还好好的,今天突然全红”的 case,根因最后都是这种路径。
所以在代码评审时,只要看到绝对路径,我们通常就直接打回,不讨论别的。因为它不是“也许可用”,而是“迟早要坏”。
自动化脚本不是一次性爬虫。它要扛住多人协作、前端迭代、测试数据变化和页面局部改版。能跑通只是开始,能活下来才算写对。
一个更完整的例子:把“输入关键词并提交搜索”写成稳定闭环
下面这段代码基本代表了我们现在比较认可的写法:先用稳定属性抓输入框,再通过按钮文本或专用属性完成提交,整个链路尽量避免依赖 DOM 位置。
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10)
search_input = wait.until(
EC.presence_of_element_located((By.CSS_SELECTOR, "__SENTINEL_METABIND_$[ input ]"))
)
search_input.clear()
search_input.send_keys("Selenium locator")
search_button = wait.until(
EC.element_to_be_clickable((By.XPATH, "//__SENTINEL_METABIND_$[ button ]"))
)
search_button.click()
result_stats = wait.until(
EC.presence_of_element_located((By.CSS_SELECTOR, ".result-stats"))
)
print(result_stats.text)
这个例子里其实有三个层面的考虑:
第一,输入框和按钮的 locator 分别选择了更合适的抓手,而不是统一迷信某一种语法。
第二,定位和等待绑定在一起,避免元素刚渲染出来就立刻交互。
第三,最终不是只做点击,而是补上结果区域断言,让这段代码形成完整的 input → operation → output 闭环。
这也是我们后来写自动化时越来越看重的一点:定位不是独立动作,它是整个交互链条的入口。入口选错,后面所有等待、点击、断言都会跟着失真。
最后沉淀下来的几条经验
回头看这件事,其实没有什么玄学。我们只是花了一些时间,终于把“元素定位”从随手写代码的动作,变成了一项有选择顺序的工程决策。
目前我们内部基本遵循这几条:
-
有稳定 ID 或测试属性就直接用。
-
没有 ID,优先用 CSS 抓稳定属性。
-
需要按文本或结构语义查找时再用 XPath。
-
布局稳定但属性不稳时,可以考虑 Relative Locators。
-
尽量围绕稳定锚点定位,不要围绕 DOM 层级定位。
-
绝对路径默认视为不可维护代码。
这套方法帮我们把一大批“偶发失败”的脚本重新拉回了可维护状态,但它也不是终点。到现在为止,我们仍然有两个遗留问题没有彻底解决。
一个是前端组件库升级后,某些语义属性会发生漂移,导致原本依赖 aria-* 或文本的 locator 需要回收重写。另一个是响应式布局在不同分辨率下会改变元素相对位置,这会削弱 Relative Locators 的稳定性。
所以真正更稳的方向,还是让自动化参与到前端约定里:该加 data-testid 的地方尽早加,该保留语义标识的地方不要随意删。只靠测试侧“猜页面”,始终不是最省成本的方案。
接续阅读: Web自动化-等待机制详解