ARTICLE DETAIL

资讯详情

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

Selenium+unittest+PO模式:Web自动化测试分层与定位实战

Selenium+unittest+PO模式:Web自动化测试分层与定位实战 1. 项目开工前先把一把梭的写法戒掉如果你写过那种从打开浏览器到断言结果全塞在一个函数里的脚本大概都经历过同样的场景第一版跑得飞快第二版加两个用例还行等到页面改版第三版定位全废改到凌晨两点还在找哪个 By.ID 写错了。POPage Object、selenium、unittest 这套组合之所以被反复提起不是因为它看起来更专业而是因为它把变化点和稳定点隔离开了。页面 UI 是会变的业务逻辑和验证规则相对稳定。把两者混在一起写等于每次 UI 一动你所有的断言逻辑都要跟着重写。这篇内容面向的是已经能写单条 selenium 脚本、但项目一复杂就失控的人。我会完整走一遍从目录设计、页面对象封装、元素定位元数据管理、unittest 用例组织一直到稳定性优化和踩坑记录的全过程。所有代码都基于 Selenium 4.x可以直接复制到项目里跑。你不需要先学什么设计模式只要知道一个类对应一个页面一个方法对应一个用户动作这个基本原则剩下的都是细节。先说一个我自己的判断标准如果一个用例脚本超过 80 行还没进入断言那基本就是分层没做对。这不是玄学是因为超过这个长度你已经在脚本里重复做了找元素这件事太多次。PO 的价值就在于把这些重复动作收敛到一处让用例脚本只描述我做了什么操作期望看到什么结果读起来像自然语言。1.1 线性脚本崩溃的三个典型征兆我总结过自己早期项目的失败模式有三种征兆基本可以判定必须重构定位表达式散落各处同一个登录按钮的定位在 5 个文件里各写了一遍页面改版后要全局搜索替换还容易漏。步骤复用靠复制粘贴登录这一步在每个用例前面都粘贴一次一旦加了验证码几十个用例全部改。断言和操作混在一起driver.find_element(...).click()后面紧跟着assert中间没有任何抽象层导致页面是否加载完成这种判断到处都要重写。1.2 PO 模式到底隔离了什么很多人把 PO 理解成把定位写到一个类里这只是表面。它真正隔离的是三层职责层次职责变化频率页面对象层描述页面有哪些元素、能做什么动作UI 改版时变用例层描述业务场景和验证点需求变更时变数据/配置层账号、URL、超时、环境环境切换时变这个分层的意义在于UI 改版你只动第一层需求改了你只动第二层换测试环境你只动第三层。三者互不干扰这才是 PO 能扛住长期维护的根本原因。提示不要一开始就追求完美的抽象。我见过太多项目在写第一个页面对象时就设计了五六层继承结果新人看不懂维护成本反而更高。先让它能跑再让它可以被复用最后才考虑优雅。2. 目录结构落地页面、用例、数据各归其位这一节讲具体怎么搭骨架。目录结构没有唯一正确答案但有明显的好和坏。好的结构是新人拿到代码五分钟内能找到登录页面的定位写在哪这条用例的数据从哪来。坏的结构是页面对象和用例混在一个文件夹里命名还靠test1.py、test2.py。我用了很多年的一套结构如下你可以直接套用auto_test/ ├── config/ │ ├── settings.py # URL、超时、浏览器类型 │ └── env.yaml # 多环境地址 ├── pages/ │ ├── base_page.py # 基类所有页面继承它 │ ├── login_page.py │ └── order_page.py ├── testcases/ │ ├── base_case.py # 用例基类管浏览器生命周期 │ ├── test_login.py │ └── test_order_flow.py ├── data/ │ └── login_data.yaml ├── utils/ │ ├── logger.py │ └── driver_factory.py ├── reports/ ├── screenshots/ └── run.py # 统一入口这套结构里我最看重的是pages/和testcases/严格分开。原因很实在页面对象是被依赖方用例是依赖方单向依赖让改动方向永远清晰。如果你把两者混放很容易出现用例文件里 import 另一个用例文件的页面类这种反向依赖一旦那个用例被删整个项目就报错。2.1 driver_factory浏览器只在一个地方创建浏览器初始化这件事我坚持集中管理。原因有两个一是换浏览器Chrome 换 Edge时只改一处二是无头模式、窗口大小、下载路径这些参数可以统一配置。# utils/driver_factory.py from selenium import webdriver from selenium.webdriver.chrome.options import Options def create_driver(headless: bool False): opt Options() if headless: opt.add_argument(--headlessnew) opt.add_argument(--window-size1920,1080) opt.add_argument(--disable-gpu) opt.add_experimental_option(excludeSwitches, [enable-logging]) driver webdriver.Chrome(optionsopt) driver.implicitly_wait(0) # 关键关掉隐式等待 return driver这里有个很多人踩过的坑implicitly_wait和WebDriverWait混用会导致等待时间不可预期地叠加。比如你设了 10 秒隐式等待又用显式等待 10 秒找同一个不存在的元素实际可能要等 20 秒以上才抛异常。所以我在工厂里直接把隐式等待设为 0所有等待一律用显式等待行为可控。2.2 BasePage 的职责边界别让它变成万金油BasePage 应该只放所有页面都会用到的能力查找元素、点击、输入、等待、切换窗口、截图。不要把具体业务动作比如登录放到这里。判断标准很简单如果这个方法只在一个页面用得上它就不该出现在 BasePage。# pages/base_page.py from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class BasePage: def __init__(self, driver, timeout10): self.driver driver self.timeout timeout self.wait WebDriverWait(driver, timeout, poll_frequency0.3) def find(self, locator): 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 type_text(self, locator, text): el self.find(locator) el.clear() el.send_keys(text) def text_of(self, locator): return self.find(locator).text.strip()注意poll_frequency0.3这个参数。默认是 0.5 秒轮询一次改成 0.3 之后元素一出现就能被抓到整体用例耗时会明显下降。在几十上百条用例的项目里这点优化累积起来相当可观。3. 元素定位元数据化页面类里只描述是什么这一部分是我认为 PO 最有价值的地方也是最容易被写歪的地方。核心原则一句话页面对象里只存定位元数据locator不存 WebElement 对象。原因很直接——WebElement 是某一时刻的页面状态快照页面一刷新它就失效了StaleElementReferenceException。如果你在类初始化时就self.username driver.find_element(...)那这个对象从创建那一刻就开始倒计时。正确的做法是把定位写成类属性常量用的时候才去查找# pages/login_page.py from selenium.webdriver.common.by import By from pages.base_page import BasePage from pages.home_page import HomePage class LoginPage(BasePage): # 仅存储定位元数据不持有元素对象 _USERNAME (By.ID, username) _PASSWORD (By.ID, password) _SUBMIT (By.CSS_SELECTOR, button.submit-btn) _ERROR (By.CSS_SELECTOR, .form-error) def open(self, url): self.driver.get(url) return self def login(self, user, pwd): self.type_text(self._USERNAME, user) self.type_text(self._PASSWORD, pwd) self.click(self._SUBMIT) return HomePage(self.driver) # 返回下一个页面对象 def error_msg(self): return self.text_of(self._ERROR)这里login()返回HomePage也就是页面流转的写法登录成功后返回首页对象用例里直接链式调用。这样做的好处是用例读起来就是一条业务流而不是一堆driver.find_element。3.1 为什么定位表达式要集中在类顶部把定位写成类顶部的常量不只是整齐。它带来三个实际收益改版时只改一处可以通过反射批量检查哪些定位可能失效最重要的是写新用例时你能一眼看出这个页面到底有哪些元素可以操作不用去翻页面源码。我习惯给定位常量加简短注释说明它在页面上的位置_PRICE (By.CSS_SELECTOR, .goods-price) # 商品列表右侧价格 _BUY_BTN (By.XPATH, //li[contains(class,goods)][1]//button) # 第一个商品的购买按钮XPath 我一般只在 CSS 表达不了的时候才用比如包含某段文本或者根据兄弟节点定位。XPath 写复杂了可读性和性能都会下降能用 CSS 就用 CSS。3.2 非原生下拉框这类难缠控件怎么处理热词里提到sikixix自动化测试selenium定位获取下拉框元素不是原生下拉框是divulli组合——这个场景我踩过不止一次。selenium 的Select类只支持selectoption这种原生下拉框遇到前端框架渲染的 div 组合你会直接报UnexpectedTagNameException。处理方式是在 BasePage 或页面对象里封装一个自定义选择方法def select_by_text(self, trigger, option_text): 适用于 divulli 结构的自定义下拉框 self.click(trigger) options self.finds((By.CSS_SELECTOR, .dropdown-list li)) for op in options: if op.text.strip() option_text: op.click() return raise ValueError(f下拉选项未找到: {option_text})这里有三个容易忽略的细节先点击触发器这类下拉框的选项列表在展开前根本不在 DOM 里或display:none。不点开去找一定找不到。点击后要等列表真正渲染建议用EC.visibility_of_all_elements_located而不是presence_of_all_elements_located因为存在不等于可见可点击。选中后确认列表收起某些框架点完选项列表还在会遮挡后续操作。稳妥做法是点完后等列表不可见或者按 ESC 关闭。3.3 显式等待封装别让 sleep 污染你的代码time.sleep()是自动化测试里的慢性毒药。一个 sleep 用上去整个团队的用例跑完时间就会被拉长。更糟的是它治标不治本——页面慢的时候你加 sleep等页面快了这个 sleep 就成了纯粹的浪费。我在 BasePage 里封装了一组等待方法用例里不允许出现裸 sleepdef wait_visible(self, locator, timeoutNone): t timeout or self.timeout return WebDriverWait(self.driver, t).until( EC.visibility_of_element_located(locator)) def wait_invisible(self, locator, timeoutNone): t timeout or self.timeout return WebDriverWait(self.driver, t).until( EC.invisibility_of_element_located(locator)) def wait_url_contains(self, fragment, timeoutNone): t timeout or self.timeout return WebDriverWait(self.driver, t).until( EC.url_contains(fragment))wait_url_contains这个封装特别实用。很多应用的跳转是异步的点完按钮 URL 要等一会才变。等 URL 比等某个元素更稳定因为 URL 是路由层的东西受 UI 渲染速度影响小。4. 用 unittest 组织用例从单条验证到业务串联selenium 负责怎么操作浏览器unittest 负责什么时候跑、跑哪些、跑完怎么断言。这两者分工清楚之后测试代码的结构就自然清晰了。4.1 浏览器生命周期setUpClass 还是 setUp这是个需要按场景选择的决定没有标准答案。我的原则是setUpClasstearDownClass整个测试类共用一个浏览器。适合一个页面内部的多条验证用例速度快但如果某条用例把页面搞乱了比如登录状态变了后面的用例会受影响。setUptearDown每条用例一个全新浏览器。完全隔离但每条用例都要重新启动浏览器慢。适合跨模块、跨状态的流程用例。折中方案是用setUp但复用同一个 driver 实例只做状态重置清 cookie、回到首页# testcases/base_case.py import os, time, functools, unittest from utils.driver_factory import create_driver SCREENSHOT_DIR os.path.join(os.path.dirname(__file__), .., screenshots) def screenshot_on_failure(func): functools.wraps(func) def wrapper(self, *args, **kwargs): try: return func(self, *args, **kwargs) except Exception: os.makedirs(SCREENSHOT_DIR, exist_okTrue) name f{self.__class__.__name__}_{func.__name__}_{int(time.time())}.png self.driver.save_screenshot(os.path.join(SCREENSHOT_DIR, name)) raise return wrapper class BaseCase(unittest.TestCase): driver None classmethod def setUpClass(cls): cls.driver create_driver(headlessFalse) cls.driver.maximize_window() classmethod def tearDownClass(cls): cls.driver.quit() def setUp(self): self.driver.delete_all_cookies()这里用装饰器做失败截图而不是在tearDown里判断异常。原因是 Python 3.11 之后 unittest 内部结构_outcome调整过依赖私有属性判断失败状态很不稳用装饰器包裹测试方法异常一定会经过这里逻辑简单且跨版本可靠。4.2 用例分层页面级验证与业务级串联我把用例分成两类分别放在不同文件里页面级用例只验证单个页面的功能比如登录页的错误提示、必填校验。这类用例短平快数量多。业务级用例串联多个页面比如登录 → 搜索商品 → 加入购物车 → 下单。这类用例少但价值高最能发现集成问题。页面级用例示例# testcases/test_login.py import unittest from pages.login_page import LoginPage from testcases.base_case import BaseCase, screenshot_on_failure from config.settings import BASE_URL class TestLogin(BaseCase): screenshot_on_failure def test_login_empty_password(self): page LoginPage(self.driver).open(BASE_URL) page.login(tester, ) self.assertIn(密码, page.error_msg()) screenshot_on_failure def test_login_success(self): page LoginPage(self.driver).open(BASE_URL) home page.login(tester, Test123) self.assertTrue(home.is_loaded())业务级用例就是把页面对象串起来读起来几乎就是需求文档Screenshot_on_failure def test_order_flow(self): home LoginPage(self.driver).open(BASE_URL).login(tester, Test123) detail home.search(机械键盘).open_first_result() cart detail.add_to_cart() order cart.checkout() self.assertTrue(order.is_submitted())4.3 断言设计别只断言元素存在assertTrue(element.is_displayed())这种断言很弱因为元素可能存在但内容是错的。我一般从三个维度设计断言业务结果订单号是否生成、页面状态是否跳转到正确页面、提示文案错误提示是否匹配。断言类型示例适用场景业务数据断言assertEqual(order.no, expected_no)有明确返回值的流程页面状态断言assertTrue(home.is_loaded())跳转类操作文案断言assertIn(密码, page.error_msg())表单校验元素数量断言assertEqual(len(cart.items()), 3)列表类页面文案断言我建议用assertIn而不是assertEqual因为提示文案常带前后缀或标点用包含匹配更抗变动。5. 跑通只是起点稳定性、报告与数据驱动用例第一次全绿不代表项目成功能连续跑一周都绿才是。这一节讲怎么让测试稳定下来。5.1 数据驱动把测试数据从代码里赶出去我见过太多项目把账号密码硬编码在用例里结果换个环境全军覆没。用 YAML 管理数据是最省事的方案# data/login_data.yaml - case: 空密码 user: tester pwd: expect: 密码 - case: 密码错误 user: tester pwd: wrong expect: 账号或密码错误配合subTest或ddt批量执行import unittest, yaml from ddt import ddt, file_data ddt class TestLoginData(BaseCase): file_data(../data/login_data.yaml) def test_login_cases(self, case, user, pwd, expect): page LoginPage(self.driver).open(BASE_URL) page.login(user, pwd) self.assertIn(expect, page.error_msg(), msgf用例失败: {case})数据驱动的好处是加用例不用写代码只加数据。测试人员改数据文件就行降低了协作门槛。5.2 失败截图 日志 页面源码三件套只截图有时候不够因为控制台报错看不到。我在装饰器里会把截图、当前 URL、页面源码一起存下来def screenshot_on_failure(func): functools.wraps(func) def wrapper(self, *args, **kwargs): try: return func(self, *args, **kwargs) except Exception: ts int(time.time()) prefix f{self.__class__.__name__}_{func.__name__}_{ts} os.makedirs(SCREENSHOT_DIR, exist_okTrue) self.driver.save_screenshot(os.path.join(SCREENSHOT_DIR, prefix .png)) with open(os.path.join(SCREENSHOT_DIR, prefix .html), w, encodingutf-8) as f: f.write(self.driver.page_source) logger.error(用例失败: %s, 当前URL: %s, prefix, self.driver.current_url) raise return wrapper页面源码这个文件在排查定位失效时特别有用你可以直接拿它离线复现定位表达式不用再跑一遍浏览器。5.3 集成到持续流程无头模式下最容易翻车的两个参数放到 CI 环境跑时有两点必须注意窗口大小无头模式下默认窗口是 800x600很多响应式页面会切成移动端布局导致元素定位全错。所以我在create_driver里固定了--window-size1920,1080。页面加载策略如果页面有大量异步资源广告、统计脚本normal策略会等到所有资源加载完容易超时。可以考虑opt.page_load_strategy eager # DOM 就绪即返回不等图片等资源eager模式下driver.get()会更快返回剩下的资源在后续显式等待里处理即可。这个改动通常能让整体执行时间下降 20% 到 40%不过要注意有些依赖图片加载的事件会失效得在页面对象里补等待。6. 那些让我加班到深夜的坑这一节是我最想分享的部分全是文档里不太会写的东西。6.1 定位失效的三种真实原因第一种元素在 iframe 里。现象是明明页面上肉眼能看到元素代码就是找不到。解决办法是先切进去self.wait.until(EC.frame_to_be_available_and_switch_to_it( (By.CSS_SELECTOR, iframe#pay-frame))) # 操作完记得切回来 self.driver.switch_to.default_content()第二种多个匹配结果。用 CSS.btn找到 5 个按钮find_element只返回第一个而你要的是第三个。这种情况我会用finds()拿到列表再按索引取或者把定位写得更精确。别偷懒用find_elements(...)[2]直接在用例里写死索引页面一加元素就错位。第三种动态生成的 class。有些框架会给元素加上css-1a2b3c这类哈希 class下次构建就变了。这种定位没有任何意义必须换成稳定的属性比如>def switch_to_new_window(self): main self.driver.current_window_handle self.wait.until(EC.number_of_windows_to_be(2)) for h in self.driver.window_handles: if h ! main: self.driver.switch_to.window(h) return main raise RuntimeError(未检测到新窗口)用number_of_windows_to_be(2)等待新窗口出现比sleep(2)靠谱得多。切换回来时用返回的 main 句柄比window_handles[0]更安全。原生的window.confirm和alert则要用switch_to.alertalert self.wait.until(EC.alert_is_present()) self.assertIn(确认删除, alert.text) alert.accept()注意 alert 是在当前窗口上下文里的如果你切换过 iframe 或窗口得先回到对的上下文再操作。6.4 测试数据互相污染这是流程用例里最隐蔽的问题。比如下单用例跑完数据没清理下次跑购物车用例时数量对不上。我的做法是在tearDown里做数据回滚或者在用例里用随机数据生成器import random, string def rand_text(prefixauto, n6): return prefix .join(random.choices(string.ascii_lowercase, kn))用随机数据的好处是每条用例都在创建新对象不会撞车。代价是测试库会越来越大所以我会同时配一个定时清理任务。这两者配合起来才算是可持续的方案。提示如果你们团队同时在用 Appium 做移动端自动化这套 PO 分层其实可以复用。把driver_factory换掉BasePage 里的查找、点击、等待逻辑基本一致页面对象的组织方式完全相同。区别主要在定位方式accessibility id 用得更多和一些移动端特有的手势操作上。7. 关于代码评审和接手传承的一点个人做法项目搭好之后最大的风险不是技术而是人。新人接手时如果看不懂为什么这么分层很容易把逻辑塞回用例文件里几个月后结构就烂掉了。我自己的做法是三条约定写进项目的 README第一用例文件里不允许出现By.。所有定位必须写在pages/里。这条规则的好处是可以直接用静态检查工具扫描非常执行得下去。第二所有driver.调用只能在 BasePage 和 driver_factory 里出现。用例和页面对象只用封装好的方法。这样将来换 selenium 版本或者要加操作日志埋点改动范围是收敛的。第三新页面上线时先补页面对象再写用例。顺序反了就会写出带业务逻辑的页面对象后面很难拆分。这几条规则看着有点死板但我在实际带项目的时候发现越简单的规则越容易被遵守。比请保持代码优雅这种话有用一百倍。最后分享一个我踩过的小坑收尾页面对象的定位常量命名尽量跟页面上的语义对齐别用_BTN1、_BTN2这种编号。因为页面改版时你根本记不住_BTN2是哪个按钮只能一个个点开看。用_SUBMIT_LOGIN、_CANCEL_ORDER这样的名字半年后回来改代码你会感谢当时的自己。
返回列表