ARTICLE DETAIL

资讯详情

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

PO+selenium+unittest自动化测试框架实战:分层、等待与稳定化

PO+selenium+unittest自动化测试框架实战:分层、等待与稳定化 “poseleniumunittest自动化测试项目实战”这个题目我在三家公司、四五个不同类型的项目里真刀真枪落地过从一开始把所有定位写死在用例里到后来把整个框架拆成三层、能扛住上百条用例并发跑中间踩的坑足够写满一个笔记本。PO 是 Page Object 的缩写核心思想就一句话把页面当对象管把定位和操作从用例里剥出去。Selenium 负责驱动浏览器干活unittest 负责把用例组织起来、跑起来、报出结果。这三样东西凑在一起不是最时髦的组合但绝对是中小团队最容易上手、最容易维护、招人最好招的一套方案。这篇文章写给谁看写过几条用例但一到维护就头大的同学想给团队搭一套能长期用的框架但不知道从哪下手的同学还有面试前想把 PO 模式讲清楚的同学。我会把目录怎么分、每层写什么、等待怎么加、非原生下拉框怎么点、报告和截图怎么留现场全流程拆开讲代码基本可以直接抄。1. 先把选型说透这三样东西各自解决什么问题1.1 三个关键词的分工别混在一起谈很多人一上来就问“Selenium 怎么用”其实这个问题问得太笼统。在这个组合里Selenium 只干一件事把浏览器的操作抽象成 API。点击、输入、取文本、切窗口、执行脚本这些动作是它的地盘。它不关心你的业务是登录还是下单也不关心用例怎么组织。unittest 是 Python 标准库自带的单元测试框架它的价值在于提供了用例发现、批量执行、前置后置setUp/tearDown、断言集合、测试套件这几样东西。它本身很朴素没有 pytest 那些华丽插件但胜在零依赖、团队里谁都认识、和 Selenium 配合起来没有兼容性焦虑。PO 则是设计模式层面的东西它不属于任何库。说白了就是约定每个页面写一个类类里放这个页面的元素定位和操作动作用例层只调用这些动作不直接碰find_element。三层各管各的边界清晰出了问题好定位。1.2 为什么我不推荐新手一上来就用 pytest这里得把话说清楚pytest 本身很好参数化、fixture、插件生态都比 unittest 舒服。但我见过太多项目团队里新人刚学会写assert就被一堆 conftest.py、fixture 作用域、插件配置搞晕了最后框架比业务代码还复杂。unittest 的优势在于“显式”。一个测试类继承TestCasesetUp里写前置tearDown里写清理test_开头的方法就是用例没有魔法。新人看一遍就会改。等团队跑顺了、用例上到几百条再考虑迁到 pytest 也不迟因为 PO 层的代码几乎不用动只换执行器。下面这张表是我自己选型时常用的对照供你参考维度unittestpytest依赖标准库自带零安装需 pip 安装学习成本低结构显式中fixture 概念需理解参数化subTest / ddt 扩展原生 parametrize更好用报告插件HTMLTestRunner 系列allure 生态更顺适合场景中小团队、新手为主用例多、追求执行效率结论很直接项目刚起步、团队水平参差unittest 是更稳的起点。1.3 PO 到底解决了什么痛点用反例说话不写 PO 的代码大概长这样def test_login(self): self.driver.find_element(By.ID, username).send_keys(admin) self.driver.find_element(By.ID, password).send_keys(123456) self.driver.find_element(By.CSS_SELECTOR, button.login-btn).click() self.assertIn(首页, self.driver.title)这段代码能跑但问题埋得很深。第一元素定位散落在每条用例里登录框的 ID 一改你得全局搜索替换漏一个就挂一片。第二等待逻辑没地方统一管每条用例各写各的sleep。第三这条“登录”逻辑在注册用例、下单用例里都要用复制粘贴三遍改的时候改三处。PO 之后长这样def test_login(self): LoginPage(self.driver).login(admin, 123456) self.assertIn(首页, self.driver.title)定位、等待、异常处理全部收进LoginPage用例只描述业务动作。这个转变带来的可维护性提升是量级的不是百分之几十。2. 项目目录结构骨架搭对了后面少走半年弯路2.1 我实际在用的目录结构这套结构经过几个项目验证直接贴出来auto_test/ ├── base/ │ └── base_page.py # 所有页面的父类公共动作 ├── pages/ │ ├── login_page.py # 登录页对象 │ ├── home_page.py # 首页对象 │ └── order_page.py # 订单页对象 ├── testcases/ │ ├── test_login.py │ ── test_order.py ├── data/ │ └── login_data.yaml # 测试数据 ├── config/ │ ── settings.py # 环境地址、超时时间等 ├── utils/ │ ├── driver_factory.py # 浏览器初始化 │ └── logger.py # 日志 ├── report/ # 报告输出 ├── screenshot/ # 失败截图 ├── run.py # 统一入口 ── requirements.txt关键点在于base、pages、testcases这三层的职责不能串。我在评审别人代码时最常看到的问题就是pages里出现了assert或者testcases里出现了find_element。一旦这两条线破了框架就退化成了一堆散装脚本。2.2 BasePage 为什么必须单独抽一层有人会问页面对象直接写不就行了为什么还要 BasePage我的回答是因为等待和异常处理是横切关注点每个页面都要用但每个页面都不该重复写。假设你有 20 个页面对象每个页面 5 个操作一共 100 个操作点。如果没有 BasePage这 100 个点每个都要写一遍“先等元素可点击再点击超时抛异常”。有 BasePage就是父类写一次子类继承。还有一个隐性好处将来你想把等待策略从“元素可点击”改成“元素可见且可点击”或者想加一层失败自动截图只需要改 BasePage 一个文件。这种改一处、全生效的能力是框架和脚本最本质的区别。2.3 元素定位元数据集中放还是分散放热词里提到“selenium 页面元素枚举”“仅存储定位元数据”这其实是在讨论一个真实的取舍。方案 A定位写在页面类顶部用类属性统一声明。class LoginPage(BasePage): username_input (By.ID, username) password_input (By.ID, password) login_button (By.CSS_SELECTOR, button.login-btn)方案 B定位和操作写在方法内部随用随写。我推荐方案 A理由是元素定位本质上就是“这个页面的元数据”把它聚拢在类顶部你能一眼看全这个页面涉及哪些元素改版时对着改就行。而且以后要抽出 YAML 管理定位类属性这种形式迁移成本最低。方案 B 的唯一优势是写起来快但页面一多、迭代一频繁定位就找不全了。一个折中做法是常用且稳定的元素用类属性只在某一个方法里用一次的临时元素允许写在方法里但要在注释里标注原因。3. 环境准备从零到跑通第一条用例3.1 Python 与 Selenium 版本怎么配版本这件事看着小实际是新手最容易卡住的地方。我的建议是 Python 3.8 以上Selenium 4.x。Selenium 4 相比 3 有几个必须知道的变化一是驱动管理内置了不用再手动下 chromedriver二是相对定位器relative locator的 API 变了三是部分find_element_by_*的简写被彻底移除必须写成find_element(By.ID, xxx)。如果你在老项目里看到driver.find_element_by_id(username)这种写法那基本是 Selenium 3 时代的代码迁移时全局改一遍工作量不大但必须改。3.2 驱动管理别再手动下载了Selenium 4.6 之后内置了 Selenium Manager你只要装了浏览器启动时它会自动匹配并下载对应驱动。所以现在的安装步骤简到不能再简pip install selenium以前那套“查浏览器版本、去官网下 chromedriver、解压、放 PATH”的流程现在只在极少数内网环境才需要走。如果你的机器连不了外网那就得手动放驱动这时候把驱动放项目drivers/目录下用Service指定路径from selenium.webdriver.chrome.service import Service service Service(executable_path./drivers/chromedriver) driver webdriver.Chrome(serviceservice)3.3 依赖清单我一般把依赖固定住版本避免同事之间环境不一致selenium4.21.0 PyYAML6.0.1 pytest-html4.1.1注意最后一个是pytest-html它是给 pytest 用的。如果纯用 unittest报告用HTMLTestRunner系列的替代包即可选一个仍在维护的版本。版本锁定的意义在于半夜 CI 挂了你能确定不是某个库偷偷升级导致的。4. 核心实现三层代码怎么写才不臃肿4.1 BasePage把等待和异常处理一次性收口这是整个框架的地基我把实际在用的版本精简后贴出来from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException class BasePage: def __init__(self, driver, timeout10): self.driver driver self.timeout timeout self.wait WebDriverWait(driver, timeout) def find(self, locator): 等待元素出现在 DOM 中并返回 return self.wait.until(EC.presence_of_element_located(locator)) def finds(self, locator): return self.wait.until(EC.presence_of_all_elements_located(locator)) def click(self, locator): el self.wait.until(EC.element_to_be_clickable(locator)) el.click() def input_text(self, locator, text): el self.find(locator) el.clear() el.send_keys(text) def get_text(self, locator): return self.find(locator).text def is_visible(self, locator): try: return self.wait.until(EC.visibility_of_element_located(locator)) except TimeoutException: return False这里有三个设计决定值得展开说。第一click用element_to_be_clickable而不是presence_of_element_located。原因是元素出现在 DOM 里不代表它可点可能被遮罩层挡着可能还在做动画。用可点击条件能挡掉一大类ElementClickInterceptedException。第二input_text里先clear()。看似多余其实在表单回填、二次提交场景里非常关键。不清空直接输入会变成“旧值新值”拼接测试数据就脏了。第三is_visible吞掉了超时异常返回布尔值。这是给那些“元素可能出现也可能不出现”的场景用的比如弹窗、加载提示。但要注意不要用它替换正常的等待只在真正需要判存在性时用。4.2 业务页面对象怎么写才不臃肿页面对象的原则是只暴露业务动作不暴露元素操作细节。看一个登录页的例子from selenium.webdriver.common.by import By from base.base_page import BasePage class LoginPage(BasePage): username_input (By.ID, username) password_input (By.ID, password) login_button (By.CSS_SELECTOR, button.login-btn) error_tip (By.CSS_SELECTOR, .error-msg) def login(self, username, password): self.input_text(self.username_input, username) self.input_text(self.password_input, password) self.click(self.login_button) return self def get_error_msg(self): return self.get_text(self.error_tip)注意login方法返回了self这样可以链式调用比如LoginPage(driver).login(...).get_error_msg()。这个技巧在业务流长的场景里能省不少中间变量。还有一个细节error_tip只在登录失败时出现所以它的读取必须放在点击之后而且调用方要能接受超时。我通常给这类元素单独写一个带短超时的读取方法而不是复用默认 10 秒的等待否则一条失败用例光等待就要耗掉十秒。4.3 用例层unittest 的组织方式用例层只做三件事准备数据、调页面动作、断言结果。import unittest from utils.driver_factory import get_driver from pages.login_page import LoginPage class TestLogin(unittest.TestCase): classmethod def setUpClass(cls): cls.driver get_driver() cls.driver.maximize_window() def setUp(self): self.driver.get(http://example.com/login) def test_login_success(self): page LoginPage(self.driver).login(admin, 123456) self.assertIn(首页, self.driver.title) def test_login_wrong_password(self): page LoginPage(self.driver).login(admin, wrong) self.assertIn(密码错误, page.get_error_msg()) classmethod def tearDownClass(cls): cls.driver.quit()setUpClass和setUp的分工要说清楚浏览器启动开销大所以放setUpClass一个类只启一次每个用例前的状态重置放setUp只做跳转页面这种轻量操作。见过有人在setUp里启动浏览器结果 50 条用例启动了 50 次浏览器执行时间从 3 分钟涨到 20 分钟。4.4 非原生下拉框就是 div ul li 那种热词里专门提到这个说明踩坑的人很多。原生select用Select类就能搞定但前端框架封装的下拉框底层是divulli组合Select直接报错。正确套路是两步先点触发器展开再遍历选项匹配文本点击。from selenium.webdriver.common.by import By from selenium.common.exceptions import NoSuchElementException def select_custom_dropdown(self, trigger_locator, option_text): self.click(trigger_locator) # 等待下拉面板真正展开 options self.finds((By.XPATH, //ul[contains(class,dropdown-menu)]/li)) for opt in options: if opt.text.strip() option_text: opt.click() return True raise NoSuchElementException(f下拉项未找到{option_text})三个坑必须提醒。第一展开有动画finds拿到的可能是动画中间态元素坐标在变点了不生效。稳妥做法是展开后先等选项容器可见再取列表。第二选项文本常有前后空格或换行一定要strip()。第三有些组件是把选项渲染在body末尾的浮层里不在触发器的 DOM 子树内这时候 XPath 不能写相对路径得从全局找。4.5 失败截图与报告现场必须留证自动化最大的痛点不是失败是失败了不知道为啥失败。我的做法是在tearDown里判断用例结果失败就截图。import os from datetime import datetime def tearDown(self): if any(error for _, error in self._outcome.errors if error): name self._testMethodName ts datetime.now().strftime(%Y%m%d_%H%M%S) self.driver.save_screenshot(f./screenshot/{name}_{ts}.png)截图文件名带上用例名和时间戳避免互相覆盖。报告方面unittest 配合 HTML 报告插件能在每条失败用例下挂上截图缩略图排查效率提升非常明显。这一步千万别省它是你半夜看 CI 结果时唯一的线索。5. 稳定性与数据管理让用例从三天两头红变成稳定绿5.1 等待策略三种写法的取舍强制等待time.sleep(3)最省事也最坑因为它不管你实际要等多久网络快的时候白等慢的时候还是不够。隐式等待implicitly_wait是全局的但和显式等待混用会产生奇怪的时序问题官方文档也不建议混用。显式等待WebDriverWait才是正解。我踩过的一个具体坑项目里有人设了implicitly_wait(10)同时 BasePage 里用WebDriverWait(driver, 5)。结果某个元素实际 8 秒才出现按理显式等待 5 秒该失败但隐式等待把查找过程拖长了程序卡了将近 8 秒才报错日志时间对不上排查了半天。结论是全项目只用显式等待删掉所有隐式等待。5.2 测试数据与配置分离把账号密码、环境地址写死在用例里是另一个高频问题。测试环境切到预发环境你得改几十个文件。我的做法是建一个 YAMLbase_url: http://example.com timeout: 10 accounts: admin: username: admin password: 123456读取用一个简单封装import yaml def load_yaml(path): with open(path, r, encodingutf-8) as f: return yaml.safe_load(f)数据分离还有个隐性收益以后要做数据驱动ddt或者subTest直接读 YAML 里的列表就行用例代码不用大改。注意密码这类敏感信息不要明文提交到代码仓库本地用.env或者仓库密钥管理YAML 里只放占位符。5.3 用例之间的依赖能不依赖就不依赖有的同学图省事让“下单”用例依赖“登录”用例的执行结果靠执行顺序串联。这套路短期能跑长期一定崩因为 unittest 默认按方法名排序执行你改个方法名顺序就变了。正确做法是每条用例自己完成完整前置。登录用接口调一次拿 token或者干脆在setUp里走一遍 UI 登录。虽然慢一点但用例之间互不影响可以单独跑、可以并发跑、失败原因也好定位。还有一种情况是测试数据要清理。比如注册用例造了一个用户不清理会污染下一次执行。我的习惯是在tearDown里删数据而且删除逻辑要幂等就是数据不存在也不报错。6. 常见问题排查实录6.1 一张速查表报错信息常见原因解决方向NoSuchElementException定位写错、页面未加载完、iframe 内元素检查定位、加显式等待、switch_to.frameElementClickInterceptedException元素被遮罩层挡住、动画未结束等可点击条件、必要时滚动到可视区ElementNotInteractableException元素不可见或不可交互等可见条件、确认没被隐藏StaleElementReferenceException页面刷新或 DOM 重建后引用失效重新查找元素不要缓存 element 对象TimeoutException等待时间不够或条件写错调大超时、检查 expected_conditions驱动版本不匹配浏览器自动升级了Selenium Manager 自动处理或手动指定驱动StaleElementReferenceException是最容易被忽视的一个。它出现的场景是你把元素对象存成变量然后页面局部刷新了这个变量指向的 DOM 节点已经没了。解决办法就一句话不要跨操作缓存 element 对象每次用每次重新查。6.2 几个只有踩过才知道的细节说几个文档里不会写、但实战中特别有用的点。第一用例执行顺序问题。unittest 默认按test_后面的方法名字母序执行不是按你写代码的顺序。如果你确实需要固定顺序比如冒烟用例先跑要么在方法名前加编号test_01_xxx要么用TestSuite手动 addTest 编排。后者更清晰我推荐后者。第二多窗口切换。点了链接开新窗口后必须先driver.switch_to.window(driver.window_handles[-1])用完再切回来不然后续操作还在旧窗口上定位全失败。我习惯在 BasePage 里封装一个上下文管理器用完自动切回。第三浏览器启动参数。无头模式做 CI 必备但要注意无头模式下某些渲染行为和有头不一样截图可能是空白。建议无头模式加上窗口尺寸参数options webdriver.ChromeOptions() options.add_argument(--headlessnew) options.add_argument(--window-size1920,1080) options.add_argument(--no-sandbox)第四调试时别急着关浏览器。写新用例时把tearDown里的driver.quit()暂时注释掉让浏览器留在现场你能直观看到卡在哪一步。等用例稳定了再恢复。第五页面对象不要写成“万能类”。见过一个CommonPage塞了几十个方法谁都能调最后没人搞得清哪些是稳定的、哪些是临时的。原则是一个页面一个类公共动作才上提 BasePage跨页面的业务流可以单独写一个flows/层别硬塞进页面类。第六日志要打但别只打“开始执行”。至少要打当前用例名、当前操作的元素、耗时。出问题时日志能告诉你到底卡在哪个动作上这比看一堆 traceback 高效得多。我在实际使用中发现这套框架最值钱的不是代码本身而是“分层”这个约束。它逼着你把“页面长什么样”和“业务流程是什么”分开想写用例的时候脑子里只装业务改页面的时候只动一个文件。项目跑顺之后新来的人照着一份现有页面对象抄半小时就能写出一条规范的用例这才是框架真正的价值。最后再分享一个小技巧每周花十分钟看一眼失败用例的截图目录如果某些截图反复出现说明那块页面本身不稳定去和开发确认一下比你在用例里加等待划算得多。
返回列表