ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PO+Selenium+unittest:可维护的Web自动化测试框架实战

PO+Selenium+unittest:可维护的Web自动化测试框架实战 1. 为什么这套组合值得投入PO Selenium Unittest 的选型逻辑做过几年 Web 自动化的人大概都有过这个阶段一开始图快脚本里全是driver.find_element(By.ID, username).send_keys(admin)一个文件写完登录、下单、查询全流程。跑通的那一刻挺爽但等到页面改了一个按钮的 id或者需要把同一套流程跑在三套环境上维护成本就会像滚雪球一样压过来。POPage Object页面对象模式 Selenium unittest 这套组合本质上就是用来治这个病的。它不是什么新潮技术栈但在中小型团队、以回归验证为主、需要长期维护的项目里它的性价比依然很难被替代。这篇文章我会把整套项目的搭建过程、每一步为什么这么设计、踩过的坑和排查技巧完整拆出来适合刚接触自动化测试、想从能跑进阶到能维护的同学也适合已经在写脚本但被维护成本折磨的同行。1.1 PO 模式真正解决的三个痛点很多人对 PO 的理解停留在每个页面写一个类这其实只说对了一半。PO 的核心是把页面长什么样和要做什么操作分开让元素定位信息和业务操作逻辑各归其位。第一个痛点是定位信息的散落。在没有 PO 的项目里一个登录按钮的定位可能在 20 个用例里重复出现。产品经理心情好换了个 class 名你得全文搜索改 20 处改漏一处就是一片飘红。PO 把定位信息收敛到页面类的一个属性上页面变了只改一处用例层完全无感。第二个痛点是业务语义缺失。element.click()这种写法三个月后自己都看不懂点的是什么。而login_page.click_submit()一眼就知道在提交登录表单。用例应该读起来像业务说明书而不是一堆底层 API 的堆砌。第三个痛点是复用与扩展。同一个登录动作可能被下单流程、退款流程、报表流程各调用一次。PO 把它封装成一个方法后任何新流程都能直接复用新人接手时也不用重新理解一遍页面结构。注意PO 不是一个页面必须一个类。弹窗、抽屉、公共导航栏这类跨页面复用的组件完全可以单独抽成一个 Component 类被多个页面类组合使用。死守一页一类反而会导致大量重复代码。1.2 为什么很多团队依然选 unittest 而不是 pytestpytest 生态确实更好插件丰富、断言优雅、fixture 强大。但 unittest 有两个无法忽视的现实优势。一是标准库自带不需要额外引入第三方依赖。在有些公司的内网环境里装个第三方包要走审批流程而 unittest 直接import就能用这对落地速度的影响比想象中大。二是与既有 CI 和测试平台的兼容性。很多公司的测试管理平台、Jenkins 流水线、报告解析脚本默认就是按 unittest 的输出格式和用例组织方式来解析的换成 pytest 反而要改一堆周边设施。当然unittest 的短板也很明确断言不够优雅没有裸assert得记assertEqual、assertTrue一大堆、参数化不如 pytest 灵活得靠 ddt 补、fixture 能力弱。我的做法是主体用 unittest参数化用 ddt报告用 HTMLTestRunner 或 BeautifulReport用最小的依赖代价补齐短板。如果团队已经在用 pytest 且没有兼容包袱那直接用 pytest 也没问题PO 的架构思路是完全通用的。1.3 三层架构的分工边界整套项目我划分为三层边界必须清晰不然很容易写成四不像。层级职责典型文件禁止出现的内容基础层 Base驱动封装、等待封装、日志、截图、配置读取base_page.py、driver_factory.py任何具体业务语义页面对象层 Pages元素定位、页面级操作、页面内断言login_page.py、order_page.py跨页面的业务流程用例层 Tests组织业务流程、调用页面方法、最终断言test_login.py任何find_element调用这张表里的第三列禁止出现的内容是我踩过坑后加的硬规矩。最早我在用例层里直接写了定位结果页面一改用例层和页面层一起改PO 的意义直接归零。后来在代码评审里把这条当红线卡住维护成本立刻降下来。判断标准很简单如果用例文件里出现了By.或者find_element这个用例就不合格。三层之外还有两个横向支撑模块config环境地址、账号、超时时间等和utils日志、截图、随机数据生成、数据库校验工具。它们不属于任何一层被各层按需调用这样既避免了循环依赖也方便单独测试工具方法本身。再说说业务流程的位置。像登录 → 搜索商品 → 加购物车 → 下单 → 支付这种跨多页面的长流程我倾向于放在用例层或者单独抽一个business包做成流程类。放用例层的好处是流程一目了然缺点是多个用例要复用同一段流程时会有重复。用流程类的好处是复用性好缺点是多了层跳转调试时不够直观。小项目我建议直接放用例层等真的出现第三个用例复用同一流程时再抽出来不要为了架构而架构。2. 项目骨架搭建从目录设计到驱动管理架构思路理清了接下来是落地。搭骨架这件事很多人觉得随便建几个文件夹就行但目录结构一旦定型后面所有代码都要往里面塞改起来牵一发动全身。我习惯在动手写第一行代码前先把目录结构和各文件的职责写在纸上确认一遍这个习惯至少帮我省过三次大规模重构。2.1 目录结构怎么划分才不返工下面是我在多个项目里迭代出来的结构中小型项目可以直接抄auto_test_project/ ├── config/ │ ├── config.yaml # 环境地址、超时、账号等配置 │ └── config_reader.py # 配置读取封装 ├── common/ │ ├── base_page.py # 页面基类等待、查找、点击、输入 │ ├── driver_factory.py # 浏览器驱动初始化与销毁 │ ├── logger.py # 日志封装 │ └── screenshot.py # 失败截图工具 ├── pages/ │ ├── login_page.py │ ├── home_page.py │ ── order_page.py ├── testcases/ │ ├── test_login.py │ ├── test_order.py │ └── conftest_data/ # 数据驱动用的 yaml / excel ├── testdata/ │ ├── login_data.yaml │ └── order_data.yaml ├── reports/ # 测试报告输出 ├── logs/ # 日志输出 ├── screenshots/ # 截图输出 ├── run.py # 统一入口 ── requirements.txt几个关键决策解释一下。第一config单独成目录而不是塞在 common 里是因为配置是横切关注点所有层都要读单独放语义更清晰。第二testdata和testcases分开是因为数据要能被非技术人员编辑YAML 比 Python 文件友好太多测试同学改个账号不用碰代码。第三reports、logs、screenshots全部走独立目录并在.gitignore里忽略避免仓库被产物文件撑爆。提示config.yaml里绝对不要写生产环境的真实账号密码。我一般只在配置里放测试环境账号敏感信息通过环境变量注入config_reader.py读取时优先取环境变量取不到再用默认值。这个习惯在代码审计时能省很多解释成本。2.2 配置管理与浏览器驱动初始化配置读取我封装了一个简单的单例避免每次读文件# config/config_reader.py import os import yaml class ConfigReader: _instance None _config None def __new__(cls, pathNone): if cls._instance is None: cls._instance super().__new__(cls) base_dir os.path.dirname(os.path.dirname(os.path.abspath(__file__))) default_path os.path.join(base_dir, config, config.yaml) with open(path or default_path, r, encodingutf-8) as f: cls._config yaml.safe_load(f) return cls._instance def get(self, key, defaultNone): return self._config.get(key, default)驱动初始化放在driver_factory.py里统一管理这地方有个必须讲清楚的坑不要在模块顶层创建 driver。我见过有项目在base_page.py顶部写了driver webdriver.Chrome()结果所有用例共用一个浏览器实例用例之间互相污染上一个用例遗留的登录态直接让下一个用例失败。正确做法是用工厂方法按需创建# common/driver_factory.py from selenium import webdriver from selenium.webdriver.chrome.options import Options from common.config_reader import ConfigReader def create_driver(): cfg ConfigReader() browser cfg.get(browser, chrome) headless cfg.get(headless, False) if browser chrome: options Options() if headless: options.add_argument(--headlessnew) options.add_argument(--window-size1920,1080) options.add_argument(--disable-gpu) driver webdriver.Chrome(optionsoptions) else: raise ValueError(f暂不支持的浏览器类型: {browser}) driver.implicitly_wait(0) driver.maximize_window() return driver注意这里implicitly_wait(0)也就是显式关闭隐式等待。隐式等待和显式等待混用是自动化测试里最经典的一个坑两者叠加时实际等待时间是不可预测的有时会远超你设定的超时值导致用例莫名其妙变慢。我的策略是全局只用显式等待隐式等待一律设为 0。关于 selenium 安装pip install selenium就够了4.x 版本开始 Selenium Manager 会自动处理驱动下载不再需要手动下载 chromedriver 放进 PATH。如果公司内网无法访问外网可以用pip config set global.index-url配置内部镜像源或者提前把驱动放到指定目录通过Service(executable_path...)显式指定。判断驱动的浏览器主版本是否匹配用chrome://version/看主版本号和驱动主版本一致即可小版本不必强求对齐。2.3 BasePage 基础层封装BasePage 是整个项目的发动机所有页面类都继承它。它要做的事情是把定位、等待、点击、输入、取值这些高频操作统一封装并加日志和截图钩子。# common/base_page.py import os import time from datetime import datetime from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException from common.config_reader import ConfigReader from common.logger import get_logger logger get_logger(__name__) class BasePage: def __init__(self, driver): self.driver driver self.timeout ConfigReader().get(timeout, 10) def find(self, locator, timeoutNone): 等待元素可见后返回locator 形如 (By.ID, kw) timeout timeout or self.timeout try: return WebDriverWait(self.driver, timeout).until( EC.visibility_of_element_located(locator) ) except TimeoutException: self.save_screenshot(find_timeout) logger.error(f元素定位超时: {locator}) raise def click(self, locator, timeoutNone): el self.find(locator, timeout) self.driver.execute_script( arguments[0].scrollIntoView({block:center});, el ) el.click() logger.info(f点击元素: {locator}) def input_text(self, locator, text, timeoutNone): el self.find(locator, timeout) el.clear() el.send_keys(text) logger.info(f输入文本到 {locator}: {text}) def get_text(self, locator, timeoutNone): return self.find(locator, timeout).text def save_screenshot(self, tag): cfg ConfigReader() base_dir cfg.get(screenshot_dir, screenshots) os.makedirs(base_dir, exist_okTrue) ts datetime.now().strftime(%Y%m%d_%H%M%S) path os.path.join(base_dir, f{tag}_{ts}.png) self.driver.save_screenshot(path) logger.info(f截图已保存: {path}) return path这里有几个细节值得单独说。scrollIntoView那段是为了解决元素存在但被固定头部遮挡导致点击失败的问题这个坑在带吸顶导航的页面上特别常见报错信息还是ElementClickInterceptedException不熟悉的人很难第一时间想到是被遮挡。截图钩子挂在find的超时分支上保证任何定位失败都能留下现场照片排查时不用靠猜。还有一个经验不要用time.sleep()。我在项目里有个不成文的规定代码评审看到time.sleep就要问一句为什么。绝大多数情况下它能被WebDriverWait替代而sleep的问题是它要么短了不够用要么长了拖慢整个套件几十个用例累积下来就是几分钟的浪费。3. 页面对象层实操元素定位与交互细节骨架搭好接下来是最见功力的页面对象层。这一层写得好不好直接决定了项目三个月后还能不能维护。我见过太多项目在这一层堆了上百行定位语句最后还是退化成了一锅粥。3.1 元素定位元数据的集中管理定位策略上优先级我是这么排的定位方式推荐度适用场景风险点ID高唯一标识的元素前端换框架时可能丢失name高表单元素多表单页面可能重名CSS Selector高大部分场景过度依赖层级易失效># pages/login_page.py from selenium.webdriver.common.by import By from common.base_page import BasePage class LoginPage(BasePage): # 定位元数据集中在此仅用于描述元素在哪不含操作逻辑 URL_PATH /login USERNAME_INPUT (By.CSS_SELECTOR, [data-testusername]) PASSWORD_INPUT (By.CSS_SELECTOR, [data-testpassword]) SUBMIT_BTN (By.CSS_SELECTOR, [data-testlogin-submit]) ERROR_TIP (By.CSS_SELECTOR, .error-tip) def open(self, base_url): self.driver.get(base_url.rstrip(/) self.URL_PATH) return self def login(self, username, password): self.input_text(self.USERNAME_INPUT, username) self.input_text(self.PASSWORD_INPUT, password) self.click(self.SUBMIT_BTN) return self def get_error_message(self): return self.get_text(self.ERROR_TIP)这个写法有个隐藏好处整个页面的元素地图一眼看全。新人接手时先看这段常量列表就知道这个页面有哪些可操作元素比翻遍整个文件找定位语句高效得多。这种把定位元数据与操作逻辑分离的做法最近在一些团队里被叫做页面元素枚举或仅存储定位元数据本质就是同一件事。3.2 非原生下拉框divulli的定位与选择Select类只能处理原生select标签。但现在前端框架做出来的下拉框绝大多数是divulli组合直接对它们用Select会抛UnexpectedTagNameException。这是新手最常卡住的地方之一。麻烦点在于这类下拉框的选项列表通常是点击后才渲染到 DOM 里甚至挂在body下用绝对定位展示跟触发按钮在 DOM 树上根本不是父子关系。所以定位选项时不能从下拉框容器往下找。我封装了一个通用方法处理这类组件def select_custom_dropdown(self, trigger_locator, option_text, list_locator): trigger_locator: 下拉框触发按钮的定位 option_text: 要选择的选项文本 list_locator: 选项容器定位一般在 body 下 self.click(trigger_locator) # 等待选项列表出现 self.find(list_locator) option_locator ( By.XPATH, f//li[normalize-space(text()){option_text}] ) self.click(option_locator) logger.info(f已选择下拉项: {option_text})这里normalize-space(text())很关键。前端渲染的文本经常带前后空格或换行符直接text()北京匹配不上加上normalize-space自动去空白后就稳了。另外一个常见坑是选项文本里有动态内容比如北京123 条这时候就得改用contains()option_locator (By.XPATH, f//li[contains(text(), {option_text})])注意用contains时要小心前缀相同的情况。比如同时存在北京和北京市contains(text(), 北京)会匹配到两个元素click就会报元素不唯一的错。这种情况下建议在 XPath 里追加索引或者用更精确的匹配条件。还有一类更刁钻的场景下拉选项是虚拟滚动渲染的也就是只渲染可视区域的几十个选项滚动时才动态加载。这时候要先用 JS 滚动到目标位置或者用搜索框先过滤缩小范围再点。这类页面我不太建议硬做全量选项遍历性价比太低聚焦业务主流程的几个关键选项就够。3.3 显式等待的封装与超时策略等待策略直接决定用例的稳定性。我的原则是每一个会改变页面状态的操作之后都必须有一个明确的等待条件而不是等一个固定时间。常用的等待条件归纳一下场景推荐条件说明等待元素出现visibility_of_element_located元素可见最常用等待元素存在presence_of_element_located元素在 DOM 但可能不可见等待元素可点击element_to_be_clickable按钮类元素首选等待元素消失invisibility_of_element_located等 loading 遮罩消失等待文本变化text_to_be_present_in_element等异步结果回填等待 URL 变化url_contains等页面跳转完成超时时间我分了三档常规操作 10 秒页面跳转和登录 20 秒涉及第三方接口的查询类操作 30 秒。统一配置在config.yaml里不同的等待调用可以传参覆盖。这样做的好处是环境慢的时候改一个配置就行不用满代码找魔法数字。另外element_to_be_clickable我一般用在按钮上visibility_of_element_located用在读取文本的元素上。用错的影响是按钮可能可见但不可点比如被透明遮罩盖住这时候用可见性判断会提前返回紧接着的 click 就会失败。3.4 页面对象类的完整写法把上面几点综合起来一个生产可用的页面对象大概是这个样子# pages/order_page.py from selenium.webdriver.common.by import By from common.base_page import BasePage class OrderPage(BasePage): SEARCH_INPUT (By.CSS_SELECTOR, [data-testsearch-input]) SEARCH_BTN (By.CSS_SELECTOR, [data-testsearch-btn]) RESULT_LIST (By.CSS_SELECTOR, .result-list li) SUBMIT_ORDER_BTN (By.CSS_SELECTOR, [data-testsubmit-order]) CONFIRM_DIALOG (By.CSS_SELECTOR, .confirm-dialog) CONFIRM_OK (By.CSS_SELECTOR, .confirm-dialog .ok-btn) SUCCESS_TIP (By.CSS_SELECTOR, .toast-success) def search(self, keyword): self.input_text(self.SEARCH_INPUT, keyword) self.click(self.SEARCH_BTN) self.find(self.RESULT_LIST) return self def get_result_count(self): return len(self.driver.find_elements(*self.RESULT_LIST)) def submit_order(self): self.click(self.SUBMIT_ORDER_BTN) self.find(self.CONFIRM_DIALOG) self.click(self.CONFIRM_OK) return self def get_success_tip(self): return self.get_text(self.SUCCESS_TIP)注意get_result_count用了find_elements而不是find因为列表数量可能是 0用find等待会直接超时。这种允许为空的场景是find_elements的典型用法写的时候要能区分开。4. 测试用例层unittest 组织、数据驱动与断言到了用例层业务流程图终于能清晰呈现了。这一层的目标不是炫技而是让任何一个懂业务的人读一遍就能确认流程对不对。4.1 用例分层与 setUp/tearDown 的正确用法unittest 提供了setUpClass、setUp、tearDown、tearDownClass四个钩子。用错层级会导致资源浪费或状态污染。我的划分标准是驱动和登录态放在setUpClass用例级数据准备放在setUp数据清理放在tearDown驱动销毁放在tearDownClass。# testcases/test_order.py import unittest from common.driver_factory import create_driver from common.config_reader import ConfigReader from pages.login_page import LoginPage from pages.order_page import OrderPage class TestOrder(unittest.TestCase): classmethod def setUpClass(cls): cfg ConfigReader() cls.driver create_driver() cls.base_url cfg.get(base_url) cls.order_page OrderPage(cls.driver) # 一次登录整个类复用 LoginPage(cls.driver).open(cls.base_url).login( cfg.get(user), cfg.get(password) ) classmethod def tearDownClass(cls): cls.driver.quit() def setUp(self): self.order_page.driver.get(self.base_url /order) def test_search_and_submit_order(self): self.order_page.search(测试商品) self.assertGreater(self.order_page.get_result_count(), 0) self.order_page.submit_order() self.assertIn(成功, self.order_page.get_success_tip())为什么登录放setUpClass而不是每个用例都登一次因为登录是重操作涉及接口请求和页面跳转每个用例登一次会让整个套件时间翻好几倍。但这么做的代价是用例之间会共享登录态如果某个用例会清空会话或者切换账号就会互相影响。遇到这种情况我会把会污染状态的用例单独拆一个测试类让它自己管自己的登录。提示setUp里的driver.get是每次用例都执行的用get而不是点击导航是因为get更快且不依赖导航菜单的稳定性。但要注意这会丢失前一个用例的前端状态如果用例之间有前后依赖就不能这么写。4.2 数据驱动与用例参数化同一套流程要用不同数据跑多遍时ddt 是 unittest 生态里最顺手的方案import unittest from ddt import ddt, data, file_data, unpack from pages.login_page import LoginPage ddt class TestLoginDataDriven(unittest.TestCase): classmethod def setUpClass(cls): cls.driver create_driver() cls.page LoginPage(cls.driver) file_data(../testdata/login_data.yaml) def test_login(self, username, password, expect): self.page.open(http://test.example.com).login(username, password) if expect success: self.assertIn(/home, self.driver.current_url) else: self.assertTrue(self.page.get_error_message())对应的 YAML 数据- [admin, correct_pwd, success] - [admin, wrong_pwd, fail] - [, any_pwd, fail] - [locked_user, any_pwd, fail]用 YAML 而不是 Excel 的理由很实际YAML 是纯文本能进 Git 做版本管理能看 diff合并冲突时也好处理。Excel 是二进制版本管理基本失效多人协作时还容易互相覆盖。数据驱动最容易踩的坑是数据里的中文乱码。读取文件时务必显式指定encodingutf-8Windows 环境下默认编码可能是 GBK读的时候不指定就会报UnicodeDecodeError。还有一个坑file_data默认的路径是相对于当前测试文件所在目录如果 YAML 放在项目根的testdata目录路径里要带../。我一般会在测试文件顶部定义一个常量DATA_DIR用os.path.join拼接绝对路径彻底躲开相对路径的坑。4.3 失败截图与日志埋点用例失败时最想要的是现场信息。我的做法是在tearDown里判断用例结果失败就自动截图def tearDown(self): if any(error for _, error in self._outcome.errors if error): self.driver.save_screenshot(fscreenshots/fail_{self._testMethodName}.png)不同版本的 unittest 里获取用例结果的 API 略有差异稳定一点的做法是重写run方法或者自定义TestResult类。我自己习惯写一个MyTestResult(unittest.TextTestResult)在addFailure和addError里统一处理截图这样所有用例都能自动生效不用每个类都写一遍。日志这边我用标准logging模块配置成按天切分文件同时输出到控制台。关键操作点击、输入、跳转、断言失败都打一条 INFO 级别的日志。排查线上环境偶发失败时日志加截图的组合基本能定位 90% 的问题。5. 测试报告与执行效率优化用例能跑通只是第一步能跑得快、出了问题能一眼看清才算真正可用。5.1 报告生成与失败信息可读性HTMLTestRunner 是比较经典的选择虽然老但够用。如果希望报告好看一点BeautifulReport 会更现代支持截图直接嵌进报告里这对定位失败用例帮助极大。封装统一入口run.pyimport unittest, time from common.driver_factory import create_driver from testcases.test_login import TestLogin from testcases.test_order import TestOrder if __name__ __main__: suite unittest.TestSuite() loader unittest.TestLoader() suite.addTests(loader.loadTestsFromTestCase(TestLogin)) suite.addTests(loader.loadTestsFromTestCase(TestOrder)) ts time.strftime(%Y%m%d_%H%M%S) with open(freports/report_{ts}.html, wb) as f: runner unittest.TextTestRunner(streamf, verbosity2) runner.run(suite)用loadTestsFromTestCase而不是手写suite.addTest(TestLogin(test_xxx))是因为后者每加一个用例都要改代码前者新增用例自动纳入维护成本为零。整个testcases目录批量加载可以用discoversuite unittest.defaultTestLoader.discover(testcases, patterntest_*.py)这条命令的前提是所有用例文件名都以test_开头、类名以Test开头、方法名以test_开头这三个约定一旦破坏用例就会静默不执行排查起来很耗时。我一般会在跑完后核对一下用例总数发现数量不对就检查命名。5.2 执行效率与并发取舍用例数上来之后串行执行会越来越慢。可选方案有两条路一是用多进程拆分测试类二是用 Selenium Grid 分布式执行。中小项目我更倾向第一种简单直接把互相独立的测试类分到不同进程每个进程跑自己的一份 driver。要注意的是共享资源必须隔离——如果多个进程用同一个测试账号登录服务端可能会因为并发登录把前一个会话踢掉导致大量莫名其妙失败。解决办法是给每个进程分配不同账号或者用不同租户的数据。另一条提速路径是减少不必要的页面加载。比如某些用例只需要验证接口返回的数据是否正确渲染可以直接调接口拿数据只在需要验证 UI 交互时开浏览器。UI 自动化只做 UI 该做的事纯数据校验交给接口层这个分工能让套件时间砍掉一大半。5.3 稳定性治理的几条实操心法自动化测试最大的敌人不是写不出来而是偶发失败。一旦出现重跑就过团队对这套东西的信任度会迅速崩掉。我踩过不少总结几条实用的绝对不用固定等待。所有等待都要有明确条件宁可多写一个等待条件也不要放一个sleep。测试数据要自己造不要依赖存量数据。依赖环境里已有的数据一旦被人删掉或改动用例立刻失败。要么在setUp里创建要么用固定的独立测试租户。每个用例的终态要可预测。有副作用下单、删除、提交的用例要么加清理逻辑要么用专门的测试环境。善用重试机制但要谨慎。我一般只对网络抖动类的失败加一次重试业务逻辑失败绝不重试否则会把真实 bug 掩盖掉。失败信息要自解释。断言消息里带上实际值、期望值和上下文比如self.assertEqual(count, 3, f搜索结果数量不符实际 {count})比光秃秃的assertEqual好用太多。6. 常见问题排查速查表与高频面试点这一节把我这些年遇到的高频问题整理成速查表方便对着症状找原因。6.1 定位类问题排查症状常见原因解决思路NoSuchElementException元素还没渲染出来换成显式等待检查是否在 iframe 内元素找到了但点不动被遮挡、不可见、禁用状态先滚动到可见区域检查遮罩层StaleElementReferenceException页面刷新后元素引用失效重新定位不要缓存元素对象定位到多个元素报错XPath 或选择器不够精确加索引或改用更精确的属性ElementClickInterceptedException有浮层或动画遮挡等待浮层消失或用 JS 点击兜底iframe 内元素找不到没切换框架上下文switch_to.frame()后再定位StaleElementReferenceException这个坑值得单独说。很多人图省事在页面类里把元素对象存成属性比如self.btn self.find(...)页面一跳转再点这个self.btn就报错。正确做法是只存定位元组每次操作用定位元组重新查找。这也是为什么我在 BasePage 里所有方法都接收 locator 而不是 element。关于 iframe还有个容易忽略的点切进去之后一定要记得切回来不然后续所有定位都会失败。我一般用上下文管理器包一层或者用switch_to.default_content()显式复位。6.2 环境与驱动类问题排查症状常见原因解决思路浏览器一闪而过脚本执行完就退出没保持检查是否在tearDownClass里 quit 了启动报驱动版本不匹配驱动与浏览器主版本不一致Selenium 4 用 Selenium Manager 自动管理无头模式下元素定位失败无头模式窗口尺寸太小显式设置--window-size1920,1080截图是黑屏或空白无头模式渲染时序问题截图前加短等待或用全页截图方案中文路径报错编码问题路径统一用英文文件读写指定 utf-8无头模式那个窗口尺寸问题特别典型。默认无头窗口大概是 800x600很多响应式页面在这个宽度下会切换成移动端布局元素位置和结构全变了定位自然失败。加一句--window-size1920,1080就能解决但这问题不看经验很难想到。6.3 高频面试问答要点如果是准备自动化测试相关面试这几个问题出现频率极高我把自己理解的回答要点也放上。问PO 模式和普通脚本的区别是什么核心区别在于定位信息与操作逻辑分离。普通脚本里定位和操作混在一起页面一变就要全量改PO 把定位收敛到页面类页面变只改一处用例层承载业务语义可读性和可维护性都更高。问显式等待和隐式等待能混用吗不建议。两者混用时实际等待时间不可预测可能出现远超预期的等待。统一用显式等待隐式等待设为 0。问怎么处理非原生下拉框原生select用 Select 类divulli 结构要点击触发按钮后用 XPath 定位 li 元素注意用normalize-space处理文本空白注意选项可能是虚拟滚动动态渲染的。问用例偶发失败怎么排查先看截图和日志确定失败点再判断是等待不足、数据依赖、并发冲突还是真 bug。等待不足最常见其次是数据被其他用例污染。定位清楚后再决定是加重试还是修等待逻辑。问自动化测试用例应该覆盖多少我个人的标准是覆盖核心业务流程和历史上出过 bug 的高风险路径追求全量覆盖性价比极低。UI 自动化跑得慢、维护成本高把稳定的主流程守住就够了边缘场景交给接口层和单元测试。自动化测试这个活技术门槛其实不算高真正的难点在于长期维护中的取舍什么时候该抽象、什么时候该妥协、什么时候该果断把不稳定的用例下线。我见过太多项目死于用例越写越多、跑一次失败一半、最后没人看报告。P0 用例跑得稳、报告看得懂、失败能定位比用例数量堆到几百条有意义得多。
返回列表