ARTICLE DETAIL

资讯详情

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

WebUI自动化测试框架设计:POM三层分离与Selenium4实战

WebUI自动化测试框架设计:POM三层分离与Selenium4实战 简介这是一套面向Web自动化测试工程师与Python测试开发初学者的POM模式实战框架源码解决传统UI测试脚本耦合度高、维护成本大、环境切换繁琐等痛点适用于电商、后台管理系统等典型Web应用的回归与冒烟测试场景。资源共65个文件压缩包大小2.28MB涵盖17个核心Python模块含base_page、conftest、page对象及test用例、28个JSON配置文件支撑多环境数据驱动与参数化、4个JavaScript辅助脚本、2个INI配置、2个CSS/HTML报告模板以及Dockerfile实现容器化部署支持。已有531人学习下载结构清晰分层page目录封装页面元素与操作data目录管理测试数据base提供基础类与工具report与log目录分别归档Allure报告与执行日志配合pytest.ini与parametrizejsonallure技术栈开箱即用完成数据-逻辑-页面三层解耦。 先说点实在的。前几年我刚负责 WebUI 自动化的时候需求方提了个再正常不过的小要求“以后你手里这块系统的回归测试能不能不要让手工点点点点到半夜”于是我从网上拷贝了一堆脚本把页面元素、业务逻辑、测试用例全塞在一个文件里刚开始跑得飞快可等模块一多只要前端 layout 一改我就要全局搜xpath然后人肉替换。那段时间最怕的不是业务代码升级而是半夜收到“红了一片”的 CI 报告。后来我把框架整个推倒重写用了 Python Pytest Selenium4按 POM 模式做了三层分离才算真正把 WebUI 自动化从“一堆能跑的脚本”变成了“一套能长期维护的框架”。这篇就是这套框架的设计思路和核心源码拆解适合两类人看一是准备搭 WebUI 自动化脚手架、但不想从零踩一遍坑的测试开发二是写了不少用例、却被维护成本搞到崩溃的老手。我会把为什么这么设计、每一层到底放什么、关键代码怎么写、以及实际操作中的隐性坑都摊开说清楚最后附一份问题速查表照着改就能用。1. 整体设计思路从 POM 到三层分离1.1 为什么非用 POM 不可POM全称 Page Object Model翻译过来叫页面对象模型。核心思想很简单把每个页面抽象成一个类类里放着这个页面独有的元素定位和页面操作。测试用例只管“在这个页面上做什么业务”至于这个按钮是idlogin_btn还是xpath//div[classlogin-button]那是 Page Object 内部的事。我用一个生活化的类比解释POM 就像物业分工。你家楼下有快递柜、垃圾投放点、停车场你不需要知道垃圾车几点来收、快递柜谁在维护你只需要知道“扔垃圾要去东门岗亭右侧取快递要去三号楼旁”。页面如果改了布局等于停车场挪了个位置那只需要改物业手册你作为业主照常开车过去就行。测试用例就是业主Page Object 就是物业手册。改一遍手册所有业主都不用受影响。POM 的价值在项目初期完全无感因为写脚本 5 分钟跑一遍也 5 分钟。但一旦页面元素调整、业务流程变化、跨模块复用时没做 POM 的脚本会让你改到怀疑人生。我踩过的最大坑是登录按钮的id被前端从login_btn改成了login_submit当时登录逻辑出现在 30 多个用例里其中一个还是直接写死的定位串结果修完其他 29 个漏了那一个跑到半夜才发现。POM 就是为这种事兜底的。1.2 三层分离到底分的是哪三层网上很多文章提 POM但真正落地时大家会发现一个尴尬如果只是照搬 POM用例层会变得越来越臃肿。比如下单流程它包含“登录、选择商品、加入购物车、确认订单、支付、查看结果”六个页面动作如果用例直接调六个 Page Object那和以前写脚本有什么区别还是又臭又长。所以我把框架拆成了三层每层职责非常单一页面对象层Page Objects最底层定位元素、封装页面动作。一个页面对应一个类比如LoginPage、CartPage。这一层不关心业务顺序它只回答“这个页面能做什么”。业务流程层Services/Business Layer中间层把多个页面动作按业务顺序编排成可复用的步骤。比如LoginService内部调LoginPage输入用户名、输入密码、点登录、等待首页元素出现。用例层只需要调login_service.login(user, pwd)。测试用例层Test Cases最上层只做三件事准备测试数据、调用业务层方法、断言最终结果。它不出现任何元素定位不关心页面动作细节。这两者的边界我一度也分不清后来采用了一个简单标准如果业务需求文档换了措辞你需要动哪一层只动 service 或只动 testcase如果前端页面改了结构你需要动哪一层只动 pages。当你发现改页面位置时需要连带改 20 个用例说明分层没做好。1.3 Selenium4 在框架里的位置选 Selenium4 不单是因为它是最新版本而是它在底层做了几个关键升级对框架设计影响很大。首先是 W3C WebDriver 协议变成标准实现不再像早期那样走 JSON Wire Protocol 的兼容路径这意味着不同浏览器的 driver 行为和 Selenium 版本的匹配问题少了很多。其次Selenium4 提供了原生相对定位器比如above()、below()、to_left_of()、to_right_of()、near()在某些元素没有稳定id、只有相对位置关系的场景下非常有用。另一个细节是find_element的写法更简洁了。在 Selenium3 中find_element_by_id(xxx)这种写法虽然还能用但已经被标记为过时。Selenium4 推荐统一用driver.find_element(By.ID, xxx)。这种统一在框架里的意义是我封装BasePage的底层定位方法时可以把 8 种定位方式做成一个通用接口代码量少而且不容易出错。不过说实话Selenium4 最大的框架层面的价值不是某个新功能而是对WebDriverWait和ExpectedConditions的稳定性提升。后面 3.3 我会具体讲等待策略怎么设计这里先记住一点框架里再也不能出现裸time.sleep()不能出现裸find_element不带等待。这两条是稳定性的底线Selenium4 在协议层面也给了更好的支撑。2. 目录结构与基础能力搭建2.1 目录设计一眼看清每个目录的职责框架的目录设计我踩过很多次坑最初的版本把所有工具类全放在utils.py里后来文件 3000 行想找日志配置要去滚半天滚动条。后来我规定一个文件不超过 300 行一个目录只装一种类型的模块结构如下webui_framework/ ├── config/ │ ├── __init__.py │ ├── settings.yaml # 全局配置URL、浏览器、超时时间 │ └── data_loader.py # 读取 yaml提供全局配置对象 ├── core/ │ ├── __init__.py │ ├── base_page.py # 页面对象基类所有 page 继承 │ ├── browser_engine.py # 浏览器驱动创建管理 │ ├── logger.py # 日志封装 │ └── screenshot.py # 失败截图工具 ├── pages/ │ ├── __init__.py │ ├── login_page.py │ ├── home_page.py │ └── cart_page.py ├── services/ │ ├── __init__.py │ ├── login_service.py │ └── order_service.py ├── testcases/ │ ├── __init__.py │ ├── conftest.py # 用例层 fixture │ ├── test_login.py │ └── test_order.py ├── testdata/ │ ├── login_data.yaml │ └── order_data.yaml ├── reports/ │ ├── logs/ # 运行日志 │ └── screenshots/ # 失败截图 ├── pytest.ini # pytest 配置 ├── requirements.txt └── run.py # 一键执行入口为什么config用 yaml 而不是直接写在代码里因为“改配置”和“改代码”对很多团队来说是两条不同流程。测试环境搭好之后测试开发只需要调整settings.yaml里的url、browser_type、timeout就能切换环境不用重新发布代码。这个细节在小型团队可能无所谓但一旦接入 CI你会发现配置外置是刚需。有朋友会问core和pages是不是重复了不重复。core里放的是与具体业务无关的底层封装比如 BasePage、浏览器引擎、日志工具pages里放的是针对系统具体页面的类。如果你把业务页面的代码塞到core那后面换一个系统core就没法直接复用了。我一般会把core当“半成品框架”把pages、services、testcases当“当前项目的血肉”。2.2 配置管理把变的东西从代码里拿出去settings.yaml的内容参考如下browser: browser_type: chrome # chrome / firefox / edge headless: false # 无头模式CI 里设 true implicit_wait: 10 page_load_timeout: 30 window_size: 1920,1080 base_url: https://your-test-env.example.com user: username: demo_user password: demo_password timeout: normal: 10 # 通用元素等待时间 short: 3 # 短等待 long: 30 # 页面跳转、支付等长流程等待时间这个文件最大的价值不是“把配置集中了”而是把需要变化的维度提前列出来了。浏览器类型、无头模式、隐式等待时间、环境地址、超时时间、测试账号这六项是我经历过的所有项目里几乎必变的东西。你如果不到十次都意识不到等你每个测试类里出现五六个带默认参数的初始化方法就会明白为什么不用配置文件。data_loader.py负责把 yaml 读成 Python 对象我只留两个核心函数import yaml from pathlib import Path BASE_PATH Path(__file__).resolve().parent.parent def load_settings() - dict: with open(BASE_PATH / config / settings.yaml, r, encodingutf-8) as f: return yaml.safe_load(f) config load_settings()这里有个经验不要把配置读取做成单例模式不要加缓存。因为 pytest 是进程内多次收集用例配置理论上只需加载一次但为了写起来方便直接用全局变量让所有模块from config.data_loader import config引用即可。真正的单例反而会在多线程、多进程场景下引入不必要的复杂度而且 pytest 收集阶段本来就会重复 import性能影响可以忽略。2.3 conftest 与 fixturedriver 的生产与销毁pytest 的 fixture 机制是这套框架的“依赖注入”核心。用 fixture 管理 driver 的好处有三个一是 driver 的生命周期可以被 pytest 统一控制而不是在setup和teardown里手写try/finally二是用例只需要在参数里声明driverpytest 会自动把创建好的实例传进来三是 scope 可以按需配置例如默认每个用例一个隔离的浏览器跑批时也能改成 session 级共享。testcases/conftest.py里的核心 fixture 我这样组织import pytest from core.browser_engine import create_driver, quit_driver from config.data_loader import config pytest.fixture(scopefunction) def driver(): driver_instance create_driver() driver_instance.get(config[base_url]) yield driver_instance quit_driver(driver_instance) pytest.fixture(scopefunction) def login_page(driver): from pages.login_page import LoginPage return LoginPage(driver) pytest.fixture(scopefunction) def login_service(driver, login_page): from services.login_service import LoginService return LoginService(driver, login_page)注意这里我用scopefunction意思是每个用例都起一个新浏览器跑一个用例关一个。这样做的代价是慢但好处是用例之间完全隔离不会因为上一个用例没有清理干净导致下一个失败。等到框架稳定、用例数量变大以后你可以改成 session 级复用浏览器但那有个前提用例本身要足够原子不能依赖浏览器历史状态。我的建议是早期先 function稳定以后再根据实际情况优化不要一上来就追求最快的跑批速度。2.4 pytest.ini从命令行配置到全量跑通pytest 配置我单独放在pytest.ini内容如下[pytest] minversion 6.0 testpaths testcases python_files test_*.py python_classes Test* python_functions test_* addopts -s -vtestpaths指定了只用搜索testcases目录避免 pytest 把core或pages目录中符合规则命名的文件误识别为用例。python_classes和python_functions保证测试类、测试方法的命名规则统一。addopts里的-s是允许 print 输出排错的时候特别有用-v是打印用例明细方便看哪条挂了。跑批时我用.gitlab-ci.yml或.jenkinsfile里执行python -m pytest这里有个很多人忽略的点一定用python -m pytest而不是直接pytest。差别在于前者会把当前 Python 环境路径注入sys.path避免有些包明明装好了却ModuleNotFoundError。这种问题在新环境部署时最坑你以为 requirements 没装完最后发现是环境变量问题白白排查半小时。3. 页面层核心实现BasePage 与 PageObject3.1 BasePage 基类把 Selenium 的琐碎细节吞掉页面层的价值在于“稳定封装细节”这个封装大部分沉淀在base_page.py。没有基类的时候每个 Page Object 里都在写重复的WebDriverWait(...).until(...)出现一个定位问题时十个页面定位写法各不一致。有了基类所有页面只需要调用继承来的方法不用关心实现细节。from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By from config.data_loader import config from core.logger import logger class BasePage: def __init__(self, driver: WebDriver): self.driver driver self.wait WebDriverWait(driver, timeoutconfig[timeout][normal]) def find_element(self, by: str, value: str, timeout: int | None None): wait self.wait if timeout is None else WebDriverWait(self.driver, timeout) return wait.until(EC.presence_of_element_located((by, value))) def find_clickable_element(self, by: str, value: str, timeout: int | None None): wait self.wait if timeout is None else WebDriverWait(self.driver, timeout) return wait.until(EC.element_to_be_clickable((by, value))) def click(self, by: str, value: str): element self.find_clickable_element(by, value) element.click() def input_text(self, by: str, value: str, text: str): element self.find_element(by, value) element.clear() element.send_keys(text)find_element用的等待条件是presence_of_element_located意思是元素已出现在 DOM 中但要等元素可点击时用element_to_be_clickable。很多脚本稳定性差就是因为只检查了存在没检查可交互。这一点我在 3.3 会用一个真实案例展开讲。3.2 定位策略一个稳定的元素定位到底怎么设计元素定位是整个自动化测试里最容易被低估的问题。很多人觉得写定位 xpath 有什么难的抄浏览器右键 copy 出来就完事。但抄出来的 xpath 往往是绝对路径比如/html/body/div[2]/div/div[1]/form/div[2]/input。只要页面结构添加一个 div所有坐标全部失效。我的定位优先级排序如下id。唯一性最好页面结构变化一般不影响。name。表单类元素常用但有时不唯一。>from core.base_page import BasePage from selenium.webdriver.common.by import By class LoginPage(BasePage): # 元素定位 username_input (By.ID, username) password_input (By.ID, password) login_button (By.CSS_SELECTOR, button[typesubmit]) error_message (By.CSS_SELECTOR, .alert-error) def enter_username(self, username: str): self.input_text(*self.username_input, username) def enter_password(self, password: str): self.input_text(*self.password_input, password) def click_login(self): self.click(*self.login_button) def get_error_message(self) - str: element self.find_element(*self.error_message) return element.text注意我用了元组self.username_input (By.ID, username)这样self.input_text(*self.username_input, username)可以直接把定位方式展开。这个写法看起来只是语法糖但在项目里极大减少了参数传错位置的概率。这个方法里我特意没有写“登录成功后等待首页元素出现”这种业务断言这类逻辑放 service 层更合适。Page Object 只负责描述页面能力不应隐含业务预期。如果你在 Page Object 里写了业务断言当业务流程变更时你会在很多页面类里同时找断言逻辑维护成本又回来了。这也呼应了前面三层分离的边界问题。3.4 Selenium4 相对定位器在页面层的应用Selenium4 提供的相对定位在动态表格、富文本页面里非常有用。举个例子在一个只能靠文本定位的列表中我找不到“删除”按钮的稳定 id但我找到了“订单号: 10086”这个文本锚点并且知道删除按钮在这个锚点右侧。代码可以这样写from selenium.webdriver.common.by import By from selenium.webdriver.support.relative_locator import locate_with delete_btn self.driver.find_element( locate_with(By.TAG_NAME, button).to_right_of(self.driver.find_element(By.XPATH, //td[contains(text(),10086)])) )这个能力不是银弹但它帮助我解决了很多“元素没有好属性”的场景。相对定位器本质上是基于现有元素的坐标和位置关系来查找所以在元素加载完成前使用会失败因此我依然把它包在等待条件之后。这段代码有一个容易踩的坑locate_with默认不会等待元素出现所以要在WebDriverWait里配合使用from selenium.webdriver.support.ui import WebDriverWait wait WebDriverWait(self.driver, config[timeout][normal]) target self.driver.find_element(By.XPATH, //td[contains(text(),10086)]) button wait.until(lambda d: d.find_element(locate_with(By.TAG_NAME, button).to_right_of(target)))等是基础相对定位是锦上添花。在页面层我统一封装这些逻辑业务层完全不知道底层用了相对定位这就是分层的好处。4. 业务层与用例层分离的价值体现在哪4.1 service 层怎么把页面动作组织成业务步骤业务层在很多人眼里是“多余的中间层”但实际项目里它承担了非常关键的任务把零散的页面动作组合成完整的业务流程同时把和用例无关的校验细节吞掉。以登录业务为例from pages.login_page import LoginPage from core.base_page import BasePage from selenium.webdriver.common.by import By class LoginService: def __init__(self, driver, login_page: LoginPage): self.driver driver self.login_page login_page def login(self, username: str, password: str) - BasePage: self.login_page.enter_username(username) self.login_page.enter_password(password) self.login_page.click_login() # 登录成功后期望跳转首页这里等首页的标志元素出现 from pages.home_page import HomePage home_page HomePage(self.driver) home_page.wait_for_page_load() return home_page def login_with_wrong_password(self, username: str, password: str) - str: self.login_page.enter_username(username) self.login_page.enter_password(password) self.login_page.click_login() return self.login_page.get_error_message()两个方法分别是“正常登录”和“错误密码登录”返回类型不一样一个是成功后的首页对象一个是错误提示文本。这其实就是业务层的职责站在业务角度用例层不需要知道登录需要输入什么元素、点击什么按钮。它只需要说“我要登录”。有一个常见误区是 service 层过度封装把断言也放进去。我认为断言必须留在用例层也就是 testcase 里。原因是同样的业务步骤可能有不同的断言预期。“登录成功”和“登录失败”虽然是同一个业务动作但预期完全相反把它们都塞进 service 会导致 service 方法堆积膨胀。service 负责“做动作”testcase 负责“验结果”这是最清晰的分工。4.2 用例层用 pytest 组织测试场景testcase 层是所有业务场景的最终落地。它应该是最薄的一层也是最能体现“业务文档即测试场景”的一层。以登录场景为例import pytest from services.login_service import LoginService from pages.login_page import LoginPage class TestLogin: def test_login_success(self, login_service: LoginService, login_page: LoginPage): home_page login_service.login(demo_user, demo_password) assert home_page.is_logged_in() True def test_login_with_wrong_password(self, login_service: LoginService): msg login_service.login_with_wrong_password(demo_user, wrong_password) assert 用户名或密码错误 in msg这里的断言是“业务预期”is_logged_in()是 HomePage 里封装好的页面状态判断方法例如等某个用户头像元素出现。用例本身不接触任何find_element不接触任何页面的元素定位。好处是当测试人员想新增一条用例时他可以只看业务层提供了哪些方法快速用业务语言组装出新用例而不需要去理解前端结构。在实际项目里这条规则救过我很多次。有一次前端把首页的“欢迎语”从span换成了div我只改了HomePage.is_logged_in()里的定位方式20 多个登录相关的用例全都不用动。这就是分层带来的维护红利。4.3 数据驱动用 fixture 和参数化减少重复代码测试用例一旦多起来“复制粘贴改数据”的冲动就会出现。pytest 的pytest.mark.parametrize和 yaml 数据文件配合可以减少大量重复代码。先在testdata/login_data.yaml定义数据- username: demo_user password: demo_password expected: success - username: demo_user password: wrong_password expected: 用户名或密码错误然后用参数化读取import pytest import yaml from pathlib import Path def load_login_data(): data_path Path(__file__).resolve().parent.parent / testdata / login_data.yaml with open(data_path, r, encodingutf-8) as f: return yaml.safe_load(f) class TestLogin: pytest.mark.parametrize(case, load_login_data()) def test_login_parametrized(self, login_service, case): if case[expected] success: home_page login_service.login(case[username], case[password]) assert home_page.is_logged_in() True else: msg login_service.login_with_wrong_password(case[username], case[password]) assert case[expected] in msg这一步的价值不是省几行代码而是让测试数据与执行逻辑分离。产品、测试在维护用例时可以只改 yaml不用去动 Python 代码。这一点在团队协作里非常实用因为不是每个写用例的同学都擅长 Python。5. 执行、报告与失败处理5.1 失败截图自动保存的实现自动化测试跑挂后没有截图等于白跑。你只知道某个元素找不到但你看不到当时的页面状态排查起来全靠猜。我实现了一个 pytest hook在用例失败时自动截图并把截图路径打到日志里实现如下# conftest.py 中新增 hook import pytest from core.screenshot import save_screenshot from datetime import datetime pytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: driver item.funcargs.get(driver, None) if driver: timestamp datetime.now().strftime(%Y%m%d_%H%M%S) filename f{item.name}_{timestamp}.png save_screenshot(driver, filename)pytest_runtest_makereport是 pytest 提供的钩子它在每个测试阶段setup/call/teardown结束时被调用。用hookwrapperTrue包裹可以得到最终的报告对象。如果调用阶段失败就从item.funcargs中拿到 driver然后保存截图。有一个细节值得提item.funcargs里未必有driver因为 fixture 的作用域可能不是 function 级或者用例可能不使用 driver。所以在运行时一定要判断driver is not None否则在 CI 裸跑时容易抛异常导致原始失败信息被掩盖。5.2 日志收集排查问题像看监控而不是碰运气我见过很多测试团队的“日志”就是一堆print()。第一次跑的时候你觉得挺好因为输出能看到一接入 pytest-html 或 CI那些 print 要么找不到要么混在一起分不清是哪条用例。后来我把日志统一封装到core/logger.pyimport logging from logging.handlers import RotatingFileHandler from pathlib import Path LOG_DIR Path(__file__).resolve().parent.parent / reports / logs LOG_DIR.mkdir(parentsTrue, exist_okTrue) logger logging.getLogger(webui_framework) logger.setLevel(logging.INFO) handler RotatingFileHandler(LOG_DIR / run.log, maxBytes10 * 1024 * 1024, backupCount5, encodingutf-8) formatter logging.Formatter([%(asctime)s] [%(levelname)s] [%(name)s] %(message)s) handler.setFormatter(formatter) logger.addHandler(handler)用RotatingFileHandler而不是普通 FileHandler原因是跑批时间长日志文件容易膨胀轮转策略可以自动切割不用手动清理。每个 Page Object 和 Service 在关键操作时都打一条日志例如logger.info(LoginPage input username: demo_user)排错的时候跟踪非常清晰。这里我特别想说一句日志不是越多越好。把敏感的密码打印到日志里是安全隐患把无关紧要的点击动作全部打印出来会造成日志噪音。我的习惯是记录“关键业务流转”和“出现异常时的上下文”而不是记录每个操作。日志是给人看的不是给机器看的。5.3 报告集成与失败重跑测试报告我一般选择pytest-html轻量、配置简单、CI 里也容易展示。安装后只需在pytest.ini里的addopts加一行addopts -s -v --htmlreports/result.html --self-contained-html--self-contained-html很关键它会把 CSS、JS 全部内嵌到单个 HTML 文件里否则 CI 上如果把 html 和相关资源一起打包很麻烦而只需要交付一个文件会安逸很多。失败重跑我推荐pytest-rerunfailures因为它和 pytest 的集成很自然使用方法就是加参数python -m pytest --reruns 2 --reruns-delay 1--reruns 2表示失败用例自动重试 2 次--reruns-delay 1表示第一次失败后等待 1 秒再重跑。重试对 WebUI 自动化是必须的因为很多失败是网络抖动、元素加载慢等造成的瞬时问题重试后在多数情况下能跑过。但注意重试不能掩盖真实 bug。如果一个用例连续 3 次都挂那大概率是代码逻辑问题而不是偶然。所以我在 CI 里的策略是普通用例能重试 1 次核心主流程用例不重试防止“看似过了实际没测”的假象。6. 常见问题与排查技巧实录6.1 元素定位不到或不稳定第一反应不要加 sleep很多新手碰到元素定位不到第一反应是在前面加一个time.sleep(5)跑起来发现好了就当作“解决”了。这是最坑的解决方式。原因很简单sleep是固定等待网络快的时候没必要等网络慢的时候又不够等结果就是用例时而通过、时而失败毫无稳定性。正确做法是优先使用显式等待等待条件要精确到“元素可点击”“元素可见”“元素出现在 DOM 中”中的一种。以点击按钮为例# 不推荐 time.sleep(3) driver.find_element(By.ID, submit).click() # 推荐 WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.ID, submit)) ).click()这个变化看似简单但对执行效率的影响很大。当用例达到上千条时每条用例省 2 秒 sleep全量回归就能省半小时。速度和稳定性不是矛盾而是同一件事的两面。6.2 全局 driver 与用例隔离怎么平衡我在 2.3 说过早期用 function 级 driver用例之间完全隔离。等用例数变多、跑批时间变长以后很多人会尝试把 driver 改成 session 级但随之而来的是用例之间的状态污染。我见过最典型的问题用例 A 执行到一半因为某个异常直接退出没有清理 session 的 cookie用例 B 启动时发现还在 A 的登录态导致断言失败。出现这类问题的根源在于业务用例没有做到“自己构建自己的前置状态”。有些用例天然依赖登录态那就应该在用例内部通过 service 登录而不是假设前一条用例执行后保留了登录态。我的方案是默认 function 级只有在 CI 快速冒烟场景下单独加一个smokemarker用--scopesession指定专用浏览器复用。核心回归脚本不允许修改全局 driver 的作用域。6.3 定位字符串全部改成一坨 XPath 怎么办有人说“我们没有前端配合加># 不稳定 /html/body/div[2]/div/div[3]/div[1]/form/div[2]/button # 更稳定 //button[contains(class, login) and contains(text(),登 录)]现实中有些文字和按钮之间存在空格、换行等不可见字符用contains(text(),登)可能会因为空格匹配失败。更稳妥的是使用.//button[normalize-space()登录]它会忽略首尾和中间多余空白。这个细节很少有人提但真的能救你一命。6.4 常见问题速查表我把平时遇到的高频问题统一整理成了一个速查表方便大家照着排查现象最常见原因排查思路根治方案元素定位不到页面未加载完成/动态渲染先看截图确认元素是否在页面上再检查 iframe/新窗口显式等待元素出现切换 iframe/窗口元素存在但点击无效元素被遮挡/不可点击使用 JS 滚动到可视区域等待element_to_be_clickable后再点击跑批偶发失败网络抖动/环境并发查看失败日志时序配合pytest-rerunfailures有限重试不掩盖真实 bug一个页面改动导致大量用例失败元素定位直接写在用例里搜索用例中的find_element所有定位收口到 Page Object登录态互相影响用例间共享了 driver/cookie检查 fixture 作用域使用 function 级 driver 或者用例自行构造前置状态数据跑乱用例共用了测试数据排查数据是否被修改数据隔离每个用例一套独立测试数据这个表看起来简单但每一条背后都是实际线上跑批时踩到的坑。排查问题最快的路径是先看截图、再看日志、再定位代码。不要一上来就猜是哪一行错了。最后再分享一个我自己的小习惯这套框架跑稳定之后我养成了一个习惯每次跑完一批用例不会只看“绿了几个、红几个”而是专门翻一遍失败的截图和日志哪怕最后重试成功了。因为“这次重试通过了”不代表代码没问题很可能只是网络抖动掩盖了某个弱定位。我会把这类失败记录下来定期回头优化定位。另外框架搭好后我已经三次升级 Selenium 版本从 4.0 到 4.x 的某个小版本没有一次因为升级导致用例大面积崩。这也侧面验证了一个点好的框架不只是“能跑通”更是“敢升级”。如果你现在维护的脚本每次升级依赖库都提心吊胆那大概率不是依赖库的问题而是你的封装该重新整理了。这套框架目前在我这边的落地效果是用例数从一百多涨到近千回归时间从原来的手动两天缩到 CI 一小时出头关键是周维成本降了下来。如果你正准备搭或者正在重构自己的 WebUI 自动化框架可以直接按这个思路动手。其中每个模块我都写过完整版本遇到具体细节问题欢迎在评论区交流我看到会回复。本文还有配套的精品资源点击获取
返回列表