简介:这是一套面向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与parametrize+json+allure技术栈,开箱即用完成数据-逻辑-页面三层解耦。 先说点实在的。前几年我刚负责 WebUI 自动化的时候,需求方提了个再正常不过的小要求:“以后你手里这块系统的回归测试,能不能不要让手工点点点点到半夜?”于是我从网上拷贝了一堆脚本,把页面元素、业务逻辑、测试用例全塞在一个文件里,刚开始跑得飞快,可等模块一多,只要前端 layout 一改,我就要全局搜xpath然后人肉替换。那段时间最怕的不是业务代码升级,而是半夜收到“红了一片”的 CI 报告。后来我把框架整个推倒重写,用了 Python + Pytest + Selenium4,按 POM 模式做了三层分离,才算真正把 WebUI 自动化从“一堆能跑的脚本”变成了“一套能长期维护的框架”。
这篇就是这套框架的设计思路和核心源码拆解,适合两类人看:一是准备搭 WebUI 自动化脚手架、但不想从零踩一遍坑的测试开发;二是写了不少用例、却被维护成本搞到崩溃的老手。我会把为什么这么设计、每一层到底放什么、关键代码怎么写、以及实际操作中的隐性坑都摊开说清楚,最后附一份问题速查表,照着改就能用。
1. 整体设计思路:从 POM 到三层分离
1.1 为什么非用 POM 不可
POM,全称 Page Object Model,翻译过来叫页面对象模型。核心思想很简单:把每个页面抽象成一个类,类里放着这个页面独有的元素定位和页面操作。测试用例只管“在这个页面上做什么业务”,至于这个按钮是id="login_btn"还是xpath=//div[@class='login-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", encoding="utf-8") as f: return yaml.safe_load(f) config = load_settings()这里有个经验:不要把配置读取做成单例模式,不要加缓存。因为 pytest 是进程内多次收集用例,配置理论上只需加载一次,但为了写起来方便,直接用全局变量让所有模块from config.data_loader import config引用即可。真正的单例反而会在多线程、多进程场景下引入不必要的复杂度,而且 pytest 收集阶段本来就会重复 import,性能影响可以忽略。
2.3 conftest 与 fixture:driver 的生产与销毁
pytest 的 fixture 机制是这套框架的“依赖注入”核心。用 fixture 管理 driver 的好处有三个:一是 driver 的生命周期可以被 pytest 统一控制,而不是在setup和teardown里手写try/finally;二是用例只需要在参数里声明driver,pytest 会自动把创建好的实例传进来;三是 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(scope="function") def driver(): driver_instance = create_driver() driver_instance.get(config["base_url"]) yield driver_instance quit_driver(driver_instance) @pytest.fixture(scope="function") def login_page(driver): from pages.login_page import LoginPage return LoginPage(driver) @pytest.fixture(scope="function") def login_service(driver, login_page): from services.login_service import LoginService return LoginService(driver, login_page)注意这里我用scope="function",意思是每个用例都起一个新浏览器,跑一个用例关一个。这样做的代价是慢,但好处是用例之间完全隔离,不会因为上一个用例没有清理干净导致下一个失败。等到框架稳定、用例数量变大以后,你可以改成 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 与 PageObject
3.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, timeout=config["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[type='submit']") 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", encoding="utf-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(hookwrapper=True) 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)结束时被调用。用hookwrapper=True包裹,可以得到最终的报告对象。如果调用阶段失败,就从item.funcargs中拿到 driver,然后保存截图。有一个细节值得提:
item.funcargs里未必有driver,因为 fixture 的作用域可能不是 function 级,或者用例可能不使用 driver。所以在运行时一定要判断driver is not None,否则在 CI 裸跑时容易抛异常,导致原始失败信息被掩盖。5.2 日志收集:排查问题像看监控而不是碰运气
我见过很多测试团队的“日志”,就是一堆
print()。第一次跑的时候你觉得挺好,因为输出能看到;一接入 pytest-html 或 CI,那些 print 要么找不到,要么混在一起分不清是哪条用例。后来我把日志统一封装到core/logger.py:import logging from logging.handlers import RotatingFileHandler from pathlib import Path LOG_DIR = Path(__file__).resolve().parent.parent / "reports" / "logs" LOG_DIR.mkdir(parents=True, exist_ok=True) logger = logging.getLogger("webui_framework") logger.setLevel(logging.INFO) handler = RotatingFileHandler(LOG_DIR / "run.log", maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-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 --html=reports/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,用--scope=session指定专用浏览器复用。核心回归脚本不允许修改全局 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 自动化框架,可以直接按这个思路动手。其中每个模块我都写过完整版本,遇到具体细节问题欢迎在评论区交流,我看到会回复。
本文还有配套的精品资源,点击获取