1. 为什么这套组合值得投入:PO + Selenium + Unittest 的选型逻辑
做过几年 Web 自动化的人大概都有过这个阶段:一开始图快,脚本里全是driver.find_element(By.ID, "username").send_keys("admin"),一个文件写完登录、下单、查询全流程。跑通的那一刻挺爽,但等到页面改了一个按钮的 id,或者需要把同一套流程跑在三套环境上,维护成本就会像滚雪球一样压过来。PO(Page 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 而不是 pytest
pytest 生态确实更好,插件丰富、断言优雅、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, path=None): 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", encoding="utf-8") as f: cls._config = yaml.safe_load(f) return cls._instance def get(self, key, default=None): 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("--headless=new") options.add_argument("--window-size=1920,1080") options.add_argument("--disable-gpu") driver = webdriver.Chrome(options=options) 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, timeout=None): """等待元素可见后返回,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, timeout=None): 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, timeout=None): el = self.find(locator, timeout) el.clear() el.send_keys(text) logger.info(f"输入文本到 {locator}: {text}") def get_text(self, locator, timeout=None): 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_ok=True) 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-test="username"]') PASSWORD_INPUT = (By.CSS_SELECTOR, '[data-test="password"]') SUBMIT_BTN = (By.CSS_SELECTOR, '[data-test="login-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 非原生下拉框(div+ul+li)的定位与选择
麻烦点在于:这类下拉框的选项列表通常是点击后才渲染到 DOM 里,甚至挂在 我封装了一个通用方法处理这类组件: 这里
还有一类更刁钻的场景:下拉选项是虚拟滚动渲染的,也就是只渲染可视区域的几十个选项,滚动时才动态加载。这时候要先用 JS 滚动到目标位置,或者用搜索框先过滤缩小范围再点。这类页面我不太建议硬做全量选项遍历,性价比太低,聚焦业务主流程的几个关键选项就够。 3.3 显式等待的封装与超时策略等待策略直接决定用例的稳定性。我的原则是:每一个会改变页面状态的操作之后,都必须有一个明确的等待条件,而不是等一个固定时间。 常用的等待条件归纳一下:
超时时间我分了三档:常规操作 10 秒,页面跳转和登录 20 秒,涉及第三方接口的查询类操作 30 秒。统一配置在 另外, 3.4 页面对象类的完整写法把上面几点综合起来,一个生产可用的页面对象大概是这个样子: 注意 4. 测试用例层:unittest 组织、数据驱动与断言到了用例层,业务流程图终于能清晰呈现了。这一层的目标不是炫技,而是让任何一个懂业务的人读一遍就能确认流程对不对。 4.1 用例分层与 setUp/tearDown 的正确用法unittest 提供了 我的划分标准是:驱动和登录态放在 为什么登录放
4.2 数据驱动与用例参数化同一套流程要用不同数据跑多遍时,ddt 是 unittest 生态里最顺手的方案: 对应的 YAML 数据: 用 YAML 而不是 Excel 的理由很实际:YAML 是纯文本,能进 Git 做版本管理,能看 diff,合并冲突时也好处理。Excel 是二进制,版本管理基本失效,多人协作时还容易互相覆盖。 数据驱动最容易踩的坑是数据里的中文乱码。读取文件时务必显式指定 还有一个坑: 4.3 失败截图与日志埋点用例失败时最想要的是现场信息。我的做法是在 不同版本的 unittest 里获取用例结果的 API 略有差异,稳定一点的做法是重写 日志这边,我用标准 5. 测试报告与执行效率优化用例能跑通只是第一步,能跑得快、出了问题能一眼看清,才算真正可用。 5.1 报告生成与失败信息可读性HTMLTestRunner 是比较经典的选择,虽然老但够用。如果希望报告好看一点,BeautifulReport 会更现代,支持截图直接嵌进报告里,这对定位失败用例帮助极大。 封装统一入口 用 这条命令的前提是所有用例文件名都以 5.2 执行效率与并发取舍用例数上来之后,串行执行会越来越慢。可选方案有两条路:一是用多进程拆分测试类,二是用 Selenium Grid 分布式执行。 中小项目我更倾向第一种,简单直接:把互相独立的测试类分到不同进程,每个进程跑自己的一份 driver。要注意的是共享资源必须隔离——如果多个进程用同一个测试账号登录,服务端可能会因为并发登录把前一个会话踢掉,导致大量莫名其妙失败。解决办法是给每个进程分配不同账号,或者用不同租户的数据。 另一条提速路径是减少不必要的页面加载。比如某些用例只需要验证接口返回的数据是否正确渲染,可以直接调接口拿数据,只在需要验证 UI 交互时开浏览器。UI 自动化只做 UI 该做的事,纯数据校验交给接口层,这个分工能让套件时间砍掉一大半。 5.3 稳定性治理的几条实操心法自动化测试最大的敌人不是写不出来,而是偶发失败。一旦出现"重跑就过",团队对这套东西的信任度会迅速崩掉。我踩过不少,总结几条实用的:
6. 常见问题排查速查表与高频面试点这一节把我这些年遇到的高频问题整理成速查表,方便对着症状找原因。 6.1 定位类问题排查
关于 iframe,还有个容易忽略的点:切进去之后一定要记得切回来,不然后续所有定位都会失败。我一般用上下文管理器包一层,或者用 6.2 环境与驱动类问题排查
无头模式那个窗口尺寸问题特别典型。默认无头窗口大概是 800x600,很多响应式页面在这个宽度下会切换成移动端布局,元素位置和结构全变了,定位自然失败。加一句 6.3 高频面试问答要点如果是准备自动化测试相关面试,这几个问题出现频率极高,我把自己理解的回答要点也放上。 问:PO 模式和普通脚本的区别是什么?核心区别在于定位信息与操作逻辑分离。普通脚本里定位和操作混在一起,页面一变就要全量改;PO 把定位收敛到页面类,页面变只改一处,用例层承载业务语义,可读性和可维护性都更高。 问:显式等待和隐式等待能混用吗?不建议。两者混用时实际等待时间不可预测,可能出现远超预期的等待。统一用显式等待,隐式等待设为 0。 问:怎么处理非原生下拉框?原生 问:用例偶发失败怎么排查?先看截图和日志确定失败点,再判断是等待不足、数据依赖、并发冲突还是真 bug。等待不足最常见,其次是数据被其他用例污染。定位清楚后再决定是加重试还是修等待逻辑。 问:自动化测试用例应该覆盖多少?我个人的标准是覆盖核心业务流程和历史上出过 bug 的高风险路径,追求全量覆盖性价比极低。UI 自动化跑得慢、维护成本高,把稳定的主流程守住就够了,边缘场景交给接口层和单元测试。 自动化测试这个活,技术门槛其实不算高,真正的难点在于长期维护中的取舍:什么时候该抽象、什么时候该妥协、什么时候该果断把不稳定的用例下线。我见过太多项目死于"用例越写越多、跑一次失败一半、最后没人看报告"。P0 用例跑得稳、报告看得懂、失败能定位,比用例数量堆到几百条有意义得多。
版权声明:
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设
2026/10/1 5:13:59
SpringBoot+Vue+MySQL毕设项目:从源码到答辩的完整指南每年的毕业季,都能在技术社区看到一堆挂着“SpringBootVueMySQL”标题的完整源码包,附带数据库脚本、论文和部署文档。作为一个被这种项目“折磨”过、也帮不少人看过代码的老学长,我可以负责任地说:选这个技术栈做毕设࿰…
网站建设
2026/10/1 5:13:59
风电最大化消纳:热电联产与储热联合优化控制Matlab实现我去年接了一个调度优化的活,项目标题很长:风电最大化消纳的热电联产机组联合优化控制(Matlab代码实现)。说白了就是一件事——北方冬天供暖期,热电机组为了供热,夜间电出力压不下去,而风电在凌…
网站建设
2026/10/1 5:13:53
GAN增强行人重识别实战:从数据集、指标到训练避坑指南简介:基于GAN生成对抗网络的行人重识别毕业设计资源包,面向深度学习、计算机视觉方向的高年级本科生和研究生,适合正在开展相关课题或课程设计的读者。该课题聚焦跨摄像头下的行人检索任务,利用生成对抗网络增强行人特征的判别性与…
网站建设
2026/10/1 5:13:37
GitHub日榜速报:从榜单数据到有效信息的筛选与判断1. 日榜速报到底在解决什么问题每天早上打开 GitHub 的 Trending 页面,看到一堆新项目冒出来,但真正值得花时间研究的可能不到十分之一。这就是我坚持做日榜速报的起点——不是简单搬运榜单,而是帮自己(顺便帮读者)做一…
网站建设
2026/10/1 5:13:08
Google Hacking完全指南:高效搜索语法与实战技巧1. 开篇:别把搜索引擎当百度用了这些年我见过太多人搜索时只会敲几个关键词,然后翻十来页找答案,效率低到让人着急。实际上,Google 的检索能力远比我们日常用到的部分深得多,一套被圈内称作Google Hacking的语法组合&a…
网站建设
2026/10/1 5:12:16
软考高项备考经验分享:选择题、案例分析、论文三科全攻略我参加软考高项前后折腾了两次,第一次倒在论文上,第二次才踩着线过关。回想起来,三门科目——选择题、案例分析、论文——看起来是分开考,实际是一条完整的链路:选择题是知识储备的地基,案例分析是应用能力… |