ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Locator 设计指南:从 find_element 到自动化测试框架的定位封装

2026/9/23 8:55:42 拓冰建站 浏览量
Locator 设计指南:从 find_element 到自动化测试框架的定位封装 1. 从 find_element 到 Locator一次定位思路的转变如果你写过一段时间的自动化测试脚本大概率经历过这样的场景页面改了一个元素的 id结果几十个用例集体飘红然后你挨个文件去搜find_element_by_id(old_id)改到手酸。这种痛点的根源不在于定位方式本身而在于我们把“怎么找”和“找什么”耦合在了一起。Locator 类要解决的核心问题就是把定位策略从执行逻辑里抽出来变成一种可描述、可复用、可延迟求值的数据。我在日常学习笔记里把 Locator 单独拎出来做一篇是因为它看起来简单实际上是从“写脚本”到“做框架”的一道分水岭。很多人学自动化测试元素定位这一关过了就急着上框架、上 PO 模式但对 Locator 这一层的理解是模糊的。结果就是框架搭起来了定位器还是散落在各个页面对象里改一个元素依然要动好几处。这篇内容适合两类人看一类是刚学完 Selenium 或 Appium 基础 API能跑通find_element但还没想清楚怎么组织定位逻辑的另一类是在维护自动化项目被元素定位的维护成本折磨过想找一个更干净的写法的。我会从 Locator 的本质讲起把它的设计动机、核心结构、和传统写法的对比、实际落地时的坑以及和 XPath、CSS Selector 这些定位语法的配合方式都过一遍。需要先说明一点不同测试框架里 Locator 的具体实现差异很大。Selenium 4 之后 Python 端引入了By和元组形式的 locatorPlaywright 有自己的一套page.locator()链式 APIAppium 则延续了 MobileBy 的体系。我这里讲的是一种通用的设计思路具体 API 以你用的框架文档为准但背后的逻辑是相通的。2. Locator 到底封装了什么定位策略与定位值的分离2.1 传统 find_element 写法的结构性问题先看一段典型的传统写法from selenium import webdriver from selenium.webdriver.common.by import By driver webdriver.Chrome() driver.find_element(By.ID, login-btn).click() driver.find_element(By.XPATH, //input[nameusername]).send_keys(test) driver.find_element(By.CSS_SELECTOR, .submit-btn).click()这段代码能跑但问题在于定位策略By.ID、By.XPATH和定位值login-btn、//input[nameusername]是作为两个独立参数直接传给find_element的。这意味着每次调用都是一次性的你没法把这个“定位描述”存下来、传出去、或者延迟执行。更麻烦的是当你想把定位信息集中管理时只能这样写# 一种常见的土办法 LOGIN_BTN (By.ID, login-btn) USERNAME_INPUT (By.XPATH, //input[nameusername]) SUBMIT_BTN (By.CSS_SELECTOR, .submit-btn) driver.find_element(*LOGIN_BTN).click()这其实就是 Locator 的雏形了——用元组把策略和值打包在一起。但元组的问题是它没有语义你拿到一个(By.ID, login-btn)得靠约定知道第一个元素是策略、第二个是值。而且元组没有行为你不能在它上面挂方法比如“等待这个元素出现”“获取这个元素的文本”之类的操作。2.2 Locator 类的核心结构一个设计良好的 Locator 类通常包含这么几个部分定位策略用哪种方式找比如 id、xpath、css、accessibility id、class name 等。定位值具体的表达式或字符串。描述信息给这个定位器起一个人能看懂的名字方便日志和报错时定位问题。求值方法真正去页面上找元素的动作通常接收一个 driver 或 page 对象作为参数。用 Python 伪代码表示大概是这样class Locator: def __init__(self, strategy, value, descriptionNone): self.strategy strategy self.value value self.description description or f{strategy}{value} def find(self, driver): return driver.find_element(self.strategy, self.value) def find_all(self, driver): return driver.find_elements(self.strategy, self.value) def __repr__(self): return fLocator {self.description}这个类本身没什么神奇的但它带来的变化是结构性的。定位信息从“调用时的参数”变成了“一等公民对象”你可以把它存在列表里、字典里、配置文件里可以在它上面做装饰、做包装、做缓存。2.3 为什么延迟求值很重要Locator 的另一个关键特性是延迟求值。传统的find_element是立即执行的——你调用它的那一刻它就去 DOM 里找找不到就抛异常。而 Locator 对象本身只是一个描述真正去找元素的动作发生在你调用find()的时候。这个区别在页面还没加载完、或者元素是动态渲染的场景下特别重要。你可以先构造好 Locator然后在合适的时机再去求值。配合显式等待就能写出这样的逻辑login_btn Locator(By.ID, login-btn, 登录按钮) # ... 页面跳转、加载 ... WebDriverWait(driver, 10).until( lambda d: login_btn.find(d).is_displayed() ) login_btn.find(driver).click()如果换成传统写法你得在until里面重新写一遍定位表达式或者把元组解包进去可读性和复用性都差一截。提示延迟求值不等于自动等待。Locator 本身不会帮你处理元素还没出现的情况等待逻辑还是得靠 WebDriverWait 或框架自带的等待机制。别把这两个概念混在一起。3. 不同框架里 Locator 的形态差异与选型考量3.1 Selenium 的 By 元组与自定义 LocatorSelenium 官方在 Python 端并没有提供一个叫Locator的类它用的是By常量加元组的组合。By.ID、By.XPATH这些本质上就是字符串常量元组(By.ID, value)就是官方认可的定位描述格式。这种设计的好处是简单、无依赖任何接受(by, value)的 API 都能直接用。坏处是缺乏语义和扩展性。所以很多团队会在 Selenium 之上自己封装一层 Locator 类把描述信息、等待逻辑、日志记录都塞进去。我见过一种比较实用的封装方式是在 Locator 里内置重试class Locator: def __init__(self, strategy, value, descriptionNone, timeout10): self.strategy strategy self.value value self.description description or f{strategy}{value} self.timeout timeout def find(self, driver): try: return WebDriverWait(driver, self.timeout).until( EC.presence_of_element_located((self.strategy, self.value)) ) except TimeoutException: raise ElementNotFoundError( f找不到元素: {self.description}, f策略{self.strategy}, 值{self.value} )这样每个 Locator 自带超时和友好的报错信息排查问题时能直接看出是哪个元素没找到而不是面对一个光秃秃的NoSuchElementException。3.2 Playwright 的 locator() 链式 APIPlaywright 走的是另一条路。它的page.locator()返回的是一个 Locator 对象而且支持链式调用page.locator(css.login-form).locator(button.submit).click() page.locator(text登录).first.click() page.get_by_role(button, name提交).click()Playwright 的 Locator 是“惰性”的它不会在你调用locator()的时候就去查 DOM而是在真正执行动作click、fill、text_content时才去解析。而且它内置了自动等待和可操作性检查元素不可见、被遮挡、还在动画中它都会等。这种设计的思路是Locator 不仅描述“找什么”还隐含了“什么时候可以操作”。对于写惯了 Selenium 显式等待的人来说一开始可能会觉得“不踏实”但用久了会发现它把很多样板代码省掉了。3.3 Appium 场景下的 Locator 特殊性移动端的元素定位比 Web 端更复杂因为多了一套原生定位体系。Appium 支持 id、xpath、class name、accessibility id、android uiautomator、ios predicate string、ios class chain 等多种策略。这些策略的表达式语法差异很大如果散落在测试代码里维护起来非常痛苦。在 Appium 项目里Locator 类的价值更明显。你可以把不同平台的定位器统一管理class LoginPageLocators: USERNAME Locator(MobileBy.ACCESSIBILITY_ID, username_field, 用户名输入框) PASSWORD Locator(MobileBy.ACCESSIBILITY_ID, password_field, 密码输入框) LOGIN_BTN Locator(MobileBy.ID, com.example.app:id/login, 登录按钮) # Android 和 iOS 用不同策略 staticmethod def get_login_btn(platform): if platform android: return Locator(MobileBy.ID, com.example.app:id/login, Android登录按钮) else: return Locator(MobileBy.ACCESSIBILITY_ID, loginButton, iOS登录按钮)这种写法在跨平台测试里几乎是标配。否则你会在代码里到处看到if platform android的分支判断非常难看。3.4 选型时的实际考量框架Locator 形态是否内置等待扩展性适用场景SeleniumBy 元组 / 自定义类否需配合 WebDriverWait高可自由封装Web 端需要精细控制等待逻辑Playwright内置 Locator 对象是自动等待中链式 API 已够用Web 端追求开发效率AppiumMobileBy 元组 / 自定义类否需配合等待高需处理多平台移动端跨平台定位管理Cypress内置 cy.get() 链是自动重试中Web 端前端团队友好选型的时候不要只看 API 好不好看要考虑团队的技术栈、项目的维护周期、以及定位逻辑的复杂度。如果只是写几个一次性脚本用什么都行如果要维护一个几百条用例的回归套件Locator 这一层的设计就值得花时间。4. 把 Locator 用起来从页面对象到配置文件4.1 页面对象模式里的 Locator 组织页面对象模式Page Object是自动化测试里最常用的组织方式而 Locator 是页面对象的核心组成部分。一个典型的页面对象长这样class LoginPage: def __init__(self, driver): self.driver driver self.username Locator(By.ID, username, 用户名输入框) self.password Locator(By.ID, password, 密码输入框) self.submit Locator(By.CSS_SELECTOR, button[typesubmit], 提交按钮) self.error_msg Locator(By.CLASS_NAME, error-message, 错误提示) def login(self, user, pwd): self.username.find(self.driver).send_keys(user) self.password.find(self.driver).send_keys(pwd) self.submit.find(self.driver).click() def get_error(self): return self.error_msg.find(self.driver).text这样写的好处是定位信息集中在页面对象的属性里测试用例只调用login()和get_error()这样的业务方法不直接接触定位表达式。当页面改版时只需要改页面对象里的 Locator 定义用例代码不用动。但这里有个细节要注意Locator 对象在__init__里创建但求值发生在方法调用时。这意味着如果页面对象实例化的时候页面还没加载完也没关系因为 Locator 只是描述不会立即去找元素。4.2 把定位信息外置到配置文件当页面对象多起来之后另一种做法是把 Locator 的定义抽到 YAML 或 JSON 文件里login_page: username: strategy: id value: username description: 用户名输入框 password: strategy: id value: password description: 密码输入框 submit: strategy: css value: button[typesubmit] description: 提交按钮然后写一个加载器把配置读成 Locator 对象import yaml def load_locators(path): with open(path, encodingutf-8) as f: data yaml.safe_load(f) locators {} for page_name, elements in data.items(): locators[page_name] { name: Locator( strategycfg[strategy], valuecfg[value], descriptioncfg.get(description) ) for name, cfg in elements.items() } return locators这种方式的优势是定位信息和代码彻底分离非开发人员也能参与维护。但代价是多了一层加载逻辑而且 IDE 的跳转和补全支持会变弱。我的经验是如果团队里有专门的测试开发维护定位库外置配置值得做如果就是几个开发自己写自己维护放在页面对象里更直接。4.3 动态 Locator 与参数化定位有些元素的定位值不是固定的比如列表里的第 N 行、带动态 id 的弹窗。这时候可以用参数化的方式构造 Locatorclass TableLocators: staticmethod def row_by_index(index): return Locator( By.XPATH, f//table[iddata]/tbody/tr[{index}], f表格第{index}行 ) staticmethod def button_by_text(text): return Locator( By.XPATH, f//button[normalize-space(text()){text}], f文本为{text}的按钮 )用的时候TableLocators.row_by_index(3).find(driver).click() TableLocators.button_by_text(删除).find(driver).click()这种写法把动态拼接的逻辑收在 Locator 工厂方法里调用方不需要关心 XPath 怎么拼只需要传业务参数。而且描述信息里带了参数值报错时能直接看出是哪一行、哪个按钮。注意参数化定位时一定要对传入的值做转义处理尤其是文本里包含引号的情况。XPath 里单引号和双引号的处理规则不一样拼接不当会导致语法错误。一个稳妥的做法是统一用双引号包裹然后把文本里的双引号替换掉或者用 XPath 的concat()函数处理。5. 定位语法怎么选XPath、CSS 与原生定位的取舍5.1 XPath 的强项与代价XPath 是功能最强的定位语法没有之一。它能向上找父节点、能按文本内容匹配、能处理复杂的层级关系。比如“找到包含‘提交’文字的按钮的父级表单”这种需求CSS 基本做不到XPath 一行就搞定Locator(By.XPATH, //button[contains(text(),提交)]/ancestor::form)但 XPath 的代价也很明显。首先是性能在复杂的 DOM 树上XPath 的解析速度通常比 CSS 慢。其次是可读性写复杂了之后维护成本高。再就是浏览器兼容性虽然主流浏览器都支持 XPath但某些移动端 WebView 对 XPath 的支持并不完整。我的经验是能用 id 就用 id能用 CSS 就用 CSSXPath 留给那些确实需要文本匹配或复杂轴关系的场景。不要因为 XPath 万能就什么都用它。5.2 CSS Selector 的性价比CSS Selector 在 Web 端是性价比最高的定位方式。语法简洁、性能好、浏览器原生支持。常见的场景基本都能覆盖Locator(By.CSS_SELECTOR, #login-btn) # id Locator(By.CSS_SELECTOR, .submit-btn) # class Locator(By.CSS_SELECTOR, input[nameusername]) # 属性 Locator(By.CSS_SELECTOR, form button.primary) # 子元素 Locator(By.CSS_SELECTOR, ul li:first-child) # 伪类CSS 的短板是不支持按文本内容匹配也不支持向上遍历。遇到这两种需求要么用 XPath要么换一种思路——比如给元素加一个专门的测试属性。5.3 测试专用属性的实践最稳的定位方式其实是让开发在元素上加一个专门给测试用的属性比如>button>Locator(By.CSS_SELECTOR, [data-testidlogin-submit], 登录提交按钮)这种方式的优势是不受样式类名变动的影响不受文本内容改动的影响语义清晰性能也好。缺点是需要开发配合。如果团队能推行这个规范自动化测试的稳定性会有质的提升。在移动端类似的做法是给控件加 accessibility id 或 resource-id原理是一样的。5.4 定位策略的优先级建议综合下来我通常建议的优先级是测试专用属性data-testid、accessibility id——最稳但需要开发配合。id——如果 id 是稳定的、非动态生成的。name 或其它稳定属性——次优选择。CSS Selector——Web 端通用方案。XPath——留给文本匹配和复杂关系。class name——尽量少用样式类名太容易变。这个优先级不是绝对的实际项目里要根据页面结构灵活调整。但核心原则是定位表达式应该描述元素的本质特征而不是它的偶然特征。id 是本质class 是偶然测试属性是本质位置索引是偶然。6. 踩过的坑Locator 使用中的典型问题与排查6.1 元素找到了但点不了这是最常见的问题之一。Locator 的find()返回了元素对象但调用click()的时候报ElementNotInteractableException或者点击没反应。原因通常是元素被遮挡、还在动画中、或者不在可视区域。排查链路是这样的先确认find()确实找到了元素——打印element.is_displayed()和element.is_enabled()。如果 displayed 是 False说明元素不可见可能是 CSS 的display:none或visibility:hidden。如果 displayed 是 True 但点不了检查是否有遮罩层。可以用element.location和element.size算出元素中心点然后看那个位置上实际是哪个元素。如果是滚动问题先scrollIntoView()再点击。在 Locator 封装里可以加一个click_safely方法def click_safely(self, driver): element self.find(driver) driver.execute_script( arguments[0].scrollIntoView({block:center});, element ) WebDriverWait(driver, 5).until( EC.element_to_be_clickable((self.strategy, self.value)) ) element.click()6.2 动态 id 导致的定位失效很多前端框架生成的 id 是带随机数或时间戳的比如idbutton-1234-abc。这种 id 每次刷新页面都变用它做定位必然失败。遇到这种情况要么改用其它稳定属性要么用 XPath 的部分匹配Locator(By.XPATH, //button[starts-with(id, button-)], 动态id按钮)但更好的做法是推动开发加测试属性。部分匹配虽然能work但不够精确页面上如果有多个同前缀的元素可能会匹配到错误的那个。6.3 多元素匹配时的选择问题find_element在匹配到多个元素时返回第一个find_elements返回列表。但“第一个”是 DOM 顺序不一定是你想要的那个。比如页面上有两个“删除”按钮一个在表格行内一个在工具栏用//button[text()删除]可能匹配到工具栏那个。排查方法是先把所有匹配的元素打印出来elements Locator(By.XPATH, //button[text()删除]).find_all(driver) for i, el in enumerate(elements): print(i, el.location, el.size, el.get_attribute(class))看清楚有几个、分别在哪然后再收窄定位条件。比如加上父级限定Locator(By.XPATH, //tr[td[text()张三]]//button[text()删除])6.4 等待时机不对导致的偶发失败自动化测试最头疼的就是偶发失败——本地跑十次都过CI 上跑三次挂一次。大部分偶发失败都跟等待时机有关。Locator 本身不解决等待问题但它的延迟求值特性让等待逻辑更容易写对。关键是要区分三种等待元素存在presenceDOM 里有这个元素但不一定可见。元素可见visibility元素在页面上显示出来了。元素可点击clickable元素可见且可交互。很多人的写法是presence_of_element_located之后就直接 click这在元素有淡入动画的场景下就会偶发失败。正确的做法是根据操作类型选择等待条件要点击就等 clickable要取文本就等 visibility。# 不推荐 WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, submit)) ).click() # 推荐 WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.ID, submit)) ).click()6.5 报错信息不友好导致排查困难原生的NoSuchElementException只告诉你“找不到元素”不告诉你是哪个元素、用什么策略找的、找了多久。在几百条用例的套件里这种报错基本等于没报。这就是 Locator 类要带description字段的原因。在find()方法里捕获异常并重新抛出带上下文的错误class ElementNotFoundError(Exception): pass def find(self, driver): try: return WebDriverWait(driver, self.timeout).until( EC.presence_of_element_located((self.strategy, self.value)) ) except TimeoutException: raise ElementNotFoundError( f元素未找到: {self.description} | f策略: {self.strategy} | 值: {self.value} | f超时: {self.timeout}s | 当前URL: {driver.current_url} )这样报错信息里带了描述、策略、值、超时时间和当前页面 URL排查效率会高很多。如果再加上截图基本可以做到看一眼报错就知道问题在哪。7. 从 Locator 出发的进阶思路7.1 Locator 与自愈定位自愈定位Self-healing Locator是这两年比较热的一个方向。思路是当一个 Locator 找不到元素时不直接失败而是尝试用备选策略去找。比如主定位是 id找不到就试 CSS再找不到就试 XPath或者用 AI 模型根据元素特征去匹配。实现上可以在 Locator 里维护一个策略列表class SelfHealingLocator: def __init__(self, strategies, descriptionNone): self.strategies strategies # [(By.ID, login), (By.CSS, .login-btn), ...] self.description description def find(self, driver): for strategy, value in self.strategies: try: return WebDriverWait(driver, 3).until( EC.presence_of_element_located((strategy, value)) ) except TimeoutException: continue raise ElementNotFoundError(f所有策略均失败: {self.description})这种做法的争议在于它可能掩盖真正的问题。元素定位失效往往意味着页面结构变了自愈只是让测试继续跑但可能跑的是错误的元素。所以自愈定位更适合用在探索性测试或监控场景回归测试里还是要谨慎。7.2 Locator 的日志与可观测性在 CI 环境里跑自动化测试出问题的时候你往往不在现场。这时候 Locator 的日志就很重要。可以在find()里记录每次定位的耗时、结果、以及页面快照import time import logging logger logging.getLogger(__name__) def find(self, driver): start time.time() try: element WebDriverWait(driver, self.timeout).until( EC.presence_of_element_located((self.strategy, self.value)) ) elapsed time.time() - start logger.debug(f定位成功: {self.description} 耗时 {elapsed:.2f}s) return element except TimeoutException: elapsed time.time() - start logger.error(f定位失败: {self.description} 耗时 {elapsed:.2f}s) raise这些日志在排查偶发失败时特别有用。如果某个 Locator 的定位耗时经常在 8-9 秒接近超时阈值说明页面加载慢或者定位表达式效率低可以提前优化。7.3 和 AI 辅助定位的结合现在有一些工具尝试用 AI 来生成或修复 Locator。思路是给模型看页面 DOM 和元素截图让它推荐最稳定的定位表达式。实际用下来AI 在“从多个候选定位里挑一个”这件事上表现还不错但完全依赖它生成定位还不太靠谱。比较务实的做法是用 AI 做定位表达式的静态检查。比如把项目里所有的 Locator 收集起来让模型评估哪些容易失效比如用了绝对路径、用了索引、用了动态 class然后人工复核。这比让 AI 直接写定位要可靠得多。7.4 Locator 设计的边界最后说一个容易被忽略的点Locator 不是越封装越好。我见过一些项目Locator 类里塞了几十种方法什么find_with_retry、find_with_screenshot、find_with_highlight、find_with_scroll最后这个类变成了一个巨无霸谁都不敢改。合理的边界是Locator 只负责“描述定位”和“求值”这两件事。等待策略、重试逻辑、截图、高亮这些属于“操作增强”应该放在更上层的封装里比如一个ElementActions类或者页面对象的基类里。Locator 保持简单才能保持可测试和可复用。我在实际项目里的做法是Locator 就是一个不可变的数据类带一个find(driver)方法。所有增强逻辑通过装饰器或组合的方式加在外面。这样 Locator 本身很容易单测增强逻辑也可以按需组合。dataclass(frozenTrue) class Locator: strategy: str value: str description: str def find(self, driver): return driver.find_element(self.strategy, self.value) def find_all(self, driver): return driver.find_elements(self.strategy, self.value)frozenTrue保证 Locator 不可变可以安全地在多线程或并行测试里共享。description给个默认值不传也能用。这样一个小而美的类用起来没什么负担但能把定位逻辑的组织方式理清楚。如果你正在学自动化测试我的建议是不要急着上大框架先把 Locator 这一层用自己的方式实现一遍。哪怕就是几十行代码写完之后你对“定位”这件事的理解会不一样。后面再学 Page Object、学数据驱动、学并行执行都会顺很多。