资讯详情

资讯详情

基于Python+Selenium+Pytest的高可维护WebUI自动化测试框架搭建

一套能跑的WebUI自动化测试脚本和一套能住十年的自动化测试框架中间隔着的东西比大多数人想象的多得多。做过WebUI自动化的朋友应该都有体会脚本写出来第一周跑得挺欢第三周就开始红一片不是元素定位失效就是等待时间不够再要么就是换个环境跑不起来——最后全都沦为手动维护脚本的时间黑洞。我这次要分享的就是一套从零搭建、以高可维护性为核心目标的WebUI自动化测试框架使用Python Selenium Pytest Page Object模式覆盖了从配置文件管理、浏览器驱动管理、页面对象封装、数据驱动到测试报告生成的完整链路。这套框架适合正在做Web端测试但苦于脚本难维护的QA工程师也适合想系统学习自动化框架设计的测试开发新人文末会附上项目结构、核心代码和踩坑记录照着搭一套完全可行。先把这个框架能解决什么问题说清楚日常写自动化最常遇到的就是代码和页面强耦合、用例之间互相干扰、环境切换要改一堆代码、失败原因看不清。这套框架把页面元素和测试逻辑彻底分开把测试数据和测试代码彻底分开把浏览器操作做成公共能力层最终达到的效果是业务页面改了只需要改一个页面对象文件环境变了只需要改一个配置文件用例要加只需要新增测试函数和数据文件其他什么都不用动。1. 先想清楚怎么才算高可维护性1.1 WebUI自动化测试的常见痛点高可维护性不是一句空洞的口号它是有具体检验标准的。在动手写代码之前我先回溯了团队里以前那些自动化脚本到底死在哪儿结论几乎集中在这么几个典型场景上元素定位和业务逻辑混在一起。一个测试用例里既能看到find_element和send_keys又能看到登录流程和断言这类用例几乎没有任何复用价值页面一改就得全盘排查。硬等待泛滥。到处是sleep(3)、sleep(5)跑一次用例动不动十分钟八成时间都在空等速度慢还不稳定改到1秒就随机失败。测试数据和代码不分离。账号、密码、URL直接写死在代码里换一套测试环境就要全局替换替换过程中漏掉一两个就是疑难杂症。失败信息不可追溯。用例失败就一句ElementNotVisibleException没有上下文截图、没有页面源码、没有当时的浏览器状态定位问题全靠猜。用例之间互相依赖。一个用例登录后不退出下一个用例直接复用会话一旦前一个失败后面全线崩溃排查起来完全是无头公案。这些问题的本质是缺少分层、缺少抽象、缺少标准化的工程手段。写单个脚本是把菜炒熟搭框架是把厨房动线设计好后者显然需要先想清楚再动手。1.2 高可维护性框架的四个设计原则我这次搭建框架之前给自己定了四条硬性标准每一条都是为了直接对冲上面那些痛点分层隔离测试用例层不碰元素定位页面对象层不写业务断言公共封装层只做通用操作。每一层只做一件事改一层不影响其他层。配置外部化环境地址、账号数据、超时时间、浏览器类型全部放到配置文件里不经过代码发布流程就能切换。等待策略统一封装统一的显式等待机制代替散落的硬编码sleep兼顾稳定性和执行速度。可观测性用例失败自动截图、自动记录页面源码、自动输出关键日志任何失败都有据可查。1.3 技术选型为什么是Python Selenium Pytest Page Object技术选型上没有追求新潮选的是被千万个项目验证过的成熟组合。Python的优势在生态和语法简洁写测试用例的工程师不一定都是科班开发出身Python的入门门槛和学习曲线最友好。Selenium是Web自动化事实标准除非你做的是基于Canvas、WebGL这类特殊渲染的页面否则它都是最稳妥的选择。Pytest作为测试框架有几个特性非常契合WebUI自动化场景fixture机制做前置后置和共享会话非常灵活、parametrize天然支持数据驱动、插件生态丰富pytest-xdist可以分布式执行、pytest-html和allure可以做报告。Page Object模式是我认为WebUI自动化框架最核心的设计模式。它的本质很简单把每一个页面封装成一个类页面上有哪几个元素、能做什么操作、怎么判断页面加载完成全部由这个类负责。测试用例只跟页面对象交互不直接跟find_element交互。这样页面结构变了只改页面对应类里的定位器用例逻辑不变。把复杂变化收敛到一个类里这是维护成本最关键的一道闸门。提示如果你们的Web应用是React/Vue这类现代框架搭建的注意Selenium定位时不要过度依赖动态的class和id优先使用稳定的数据属性或文本特征这一点在后面章节会专门讲。2. 框架整体设计与分层结构2.1 核心分层模型六层架构这套框架的物理结构就是逻辑结构。我按职责划分成六层每一层在项目中都有明确的目录对应层级对应目录核心职责测试用例层testcases只负责描述业务场景、调用页面操作、断言结果业务操作层page_objects封装每个页面的元素定位和操作行为公共封装层common封装浏览器驱动、等待、截图、日志等通用能力配置层config管理所有环境参数、全局变量、元素定位策略数据层testdata存放测试输入数据支持不同业务场景的Excel/YAML/JSON报告层reports存放测试报告、截图、失败日志从上往下每一层只能依赖自己的下层不允许跨层调用。比如测试用例层可以用业务操作层的LoginPage但绝对不允许直接去common或者更底层里调find_element。这个约定是框架长期可维护的基石。用一句话概括这套结构测试用例像导演页面对象像演员公共封装像舞台机械配置像剧本标注数据像道具。导演只管表演效果不关心道具怎么准备、灯光怎么调——一旦某个环节出问题就能精准定位到对应的人去修改。2.2 目录结构与项目初始化项目结构我直接贴上这个结构是我多轮迭代后觉得最顺手的一版webui_auto_framework/ ├── config/ │ ├── __init__.py │ ├── settings.py # 全局参数定义 │ └── base_config.yaml # 环境配置不同环境的地址、账号、浏览器 ├── common/ │ ├── __init__.py │ ├── browser_manager.py # 浏览器驱动管理与封装 │ ├── base_page.py # BasePage核心类 │ ├── wait_utils.py # 显式等待统一封装 │ ├── screenshot_utils.py # 截图与日志工具 │ └── logger.py # 日志模块 ├── page_objects/ │ ├── __init__.py │ ├── login_page.py # 登录页面对象 │ └── home_page.py # 首页/主界面页面对象 ├── testdata/ │ ├── __init__.py │ └── login_data.yaml # 登录模块测试数据 ├── testcases/ │ ├── __init__.py │ ├── conftest.py # pytest夹具与钩子 │ └── test_login.py # 登录模块测试用例 ├── reports/ # 测试报告/截图/日志输出目录 ├── .gitignore └── requirements.txt初始化时注意一个容易踩的坑所有包目录都要放__init__.py文件否则跨目录导入会非常痛苦。另外reports目录建议加入.gitignore报告属于生成物不进版本库。2.3 配置管理方案YAML 环境隔离配置管理的核心需求就是换环境不改代码。我用了config/base_config.yaml来维护多环境参数base: project_name: webui_auto_framework timeout: 10 poll_frequency: 0.5 environments: test_env: base_url: https://example-test.com users: normal_user: test_user password: test_pass_123 browser: chrome staging_env: base_url: https://example-staging.com users: normal_user: staging_user password: staging_pass_456 browser: chrome browser_options: headless: false incognito: true window_size: 1920,1080在settings.py里读取这个YAML并对外暴露统一的配置对象import os import yaml class Settings: def __init__(self, envtest_env): with open(os.path.join(os.path.dirname(__file__), base_config.yaml), r, encodingutf-8) as f: self._config yaml.safe_load(f) self._env env property def base_url(self) - str: return self._config[environments][self._env][base_url] property def browser(self) - str: return self._config[environments][self._env][browser] property def timeout(self) - int: return int(self._config[base][timeout]) property def normal_user(self) - tuple: env_conf self._config[environments][self._env] return (env_conf[users][normal_user], env_conf[users][password]) settings Settings()实际执行时通过环境变量选择环境export AUTO_TEST_ENVstaging_env pytest testcases/test_login.pySettings类读到环境变量后动态选择配置这样CI/CD里可以按发布流水线自动切换环境完全不改代码。这套方案我用下来最大的感受是避免了一千个为什么本地过了线上挂的灵异事件——绝大多数是环境数据不一致导致的。3. 核心模块实现从驱动到页面封装的完整链路3.1 浏览器驱动的统一管理器驱动的核心任务有三个初始化浏览器、配置浏览器参数、保证所有用例执行完正常回收资源。common/browser_manager.py from selenium import webdriver from selenium.webdriver.chrome.options import Options from config.settings import settings class BrowserManager: def __init__(self): self.driver None def init_browser(self) - webdriver.Remote: browser_name settings.browser.lower() if browser_name chrome: options Options() if settings._config[browser_options][headless]: options.add_argument(--headlessnew) if settings._config[browser_options][incognito]: options.add_argument(--incognito) options.add_argument(f--window-size{settings._config[browser_options][window_size]}) self.driver webdriver.Chrome(optionsoptions) elif browser_name firefox: self.driver webdriver.Firefox() else: raise ValueError(fUnsupported browser: {browser_name}) self.driver.maximize_window() return self.driver def quit_browser(self): if self.driver: self.driver.quit()有几个细节值得说道说道headless模式在用--headlessnew时对现代网站的兼容性比老版--headless好很多尤其是对某些需要真实视口尺寸的渲染。incognito模式可以避免浏览器缓存和Cookie串用例这也是保证用例独立性的一个小手段。注意不要在每个用例里都重复init_browser和quit_browser。浏览器启动是重操作每个用例都启动退出一套用例跑下来时间会翻好几倍。正确的做法是用Pytest的session级或module级fixture共享一个浏览器会话或者配合pytest-xdist做多进程级并行。共享会话的具体方案在4.3节会详细讲。3.2 BasePage把等待、定位、操作揉成一个稳定接口BasePage是整个框架的心脏。所有页面类都继承它它提供以下能力统一的显式等待定位方法元素可点击、可见、消失三种情况的等待封装好的click、input、get_text等方法JS执行、iframe切换、窗口切换等疑难操作统一的失败截图钩子common/base_page.py from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from common.screenshot_utils import take_screenshot_on_failure from config.settings import settings class BasePage: def __init__(self, driver): self.driver driver self.timeout settings.timeout self.poll_frequency settings._config[base][poll_frequency] def find_element(self, byBy.XPATH, valueNone): 显式等待元素存在并返回 if value is None: # 兼容传入元组定位器 (By.XPATH, //input) by, value by element WebDriverWait( self.driver, self.timeout, self.poll_frequency ).until(EC.presence_of_element_located((by, value))) return element def wait_element_visible(self, by, value): WebDriverWait(self.driver, self.timeout, self.poll_frequency).until( EC.visibility_of_element_located((by, value)) ) return self.find_element(by, value) def click(self, by, value): element self.wait_element_visible(by, value) try: element.click() except Exception: # 某些元素被遮罩挡住时改用JS点击绕过 self.driver.execute_script(arguments[0].click();, element) def input_text(self, by, value, text): element self.wait_element_visible(by, value) element.clear() element.send_keys(text) def get_text(self, by, value): element self.wait_element_visible(by, value) return element.text def wait_element_disappear(self, by, value): WebDriverWait(self.driver, self.timeout, self.poll_frequency).until_not( EC.presence_of_element_located((by, value)) ) take_screenshot_on_failure def get(self, url): self.driver.get(url)关于find_element的签名我用了一个比较偷懒的兼容设计既支持by, value两个参数也支持传一个元组。这样在Page Object里定义定位器时非常方便例如self.LOGIN_BTN (By.XPATH, //button[typesubmit])然后self.click(self.LOGIN_BTN)把元组直接展开传进去。这种写法直接提升页面类的阅读体验。3.3 LoginPage页面对象模式落地示例页面对象模式的核心就是把页面上的表现封装成业务动作。拿登录页面举例page_objects/login_page.py from selenium.webdriver.common.by import By from common.base_page import BasePage class LoginPage(BasePage): # 集中式定位器定义 USERNAME_INPUT (By.ID, username) PASSWORD_INPUT (By.ID, password) LOGIN_BUTTON (By.XPATH, //button[contains(class, login-btn)]) ERROR_TOAST (By.XPATH, //div[contains(class, el-message--error)]) def input_username(self, username: str): self.input_text(self.USERNAME_INPUT, username) def input_password(self, password: str): self.input_text(self.PASSWORD_INPUT, password) def click_login(self): self.click(self.LOGIN_BUTTON) def login(self, username: str, password: str): 页面对象提供完整业务操作 self.input_username(username) self.input_password(password) self.click_login() def get_error_message(self) - str: return self.get_text(self.ERROR_TOAST) def is_login_page_loaded(self) - bool: 通过可见性和可用性判断页面是否加载完成 return self.wait_element_visible(self.LOGIN_BUTTON) is not None写这个类的原则我总结了三条定位器全部集中在页面类顶部。不管是后面维护自动化还是评审代码一眼就能看到这个页面所有可操作的元素。暴露给用例的方法是业务动作不是Selenium操作。例如login()、get_error_message()用例不会看到find_element这个词。一个页面一个类一个类只描述一个页面。不要搞万能页面把所有元素堆一起那是在给维护埋雷。3.4 Pytest夹具机制会话共享、失败自动截图conftest.py是Pytest的魔法文件我在这里定义了浏览器会话的scope控制和失败处理逻辑。testcases/conftest.py import os import pytest from selenium import webdriver from common.browser_manager import BrowserManager from page_objects.login_page import LoginPage from config.settings import settings browser_manager BrowserManager() pytest.fixture(scopesession) def driver(): session级浏览器整轮测试只启动和退出一次 drv browser_manager.init_browser() yield drv browser_manager.quit_browser() pytest.fixture() def login_page(driver): page LoginPage(driver) page.get(settings.base_url) return page 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) if driver is not None: screenshot_dir reports/screenshots os.makedirs(screenshot_dir, exist_okTrue) file_name f{item.name}_{report.nodeid.replace(::, _)}.png driver.save_screenshot(os.path.join(screenshot_dir, file_name))scopesession的意思是整轮测试只启一个浏览器用例跑完不退出下一个用例接着复用。带来的好处是执行速度提升好几倍。代价是每个用例必须自己保证页面状态干净也就是说每个用例的开头都要从访问初始URL开始而不依赖上一个用例结束时的状态。这一点必须配合fixture的独立初始化来保证。失败自动截图的逻辑挂在pytest_runtest_makereport钩子上只要某个用例断言失败或异常就把当时的浏览器截图保存到reports/screenshots。这种失败留痕机制省下大量复现时间。4. 实战落地登录模块从数据到用例的完整拆解4.1 测试场景设计与数据组织登录功能是WebUI自动化最典型的入门项目但想写稳场景和数据组织要做细。我先设计了以下场景用例编号场景描述输入数据预期结果LOGIN_001正确账号密码登录成功test_user / test_pass_123跳转首页显示用户昵称LOGIN_002密码错误test_user / wrong_pass出现错误提示停留在登录页LOGIN_003用户不存在ghost_user / xxxx出现错误提示,LOGIN_004用户名为空 / test_pass_123出现用户名不能为空提示测试数据放testdata/login_data.yamllogin_success: username: test_user password: test_pass_123 expected_text: 欢迎您 login_wrong_password: username: test_user password: wrong_pass_123 expected_error: 密码错误 login_user_not_exist: username: ghost_user_9527 password: xxxx1234 expected_error: 用户不存在 login_empty_username: username: password: test_pass_123 expected_error: 用户名不能为空数据文件做了归一化处理YAML文件的key是语义化的如login_success测试用例读取后做参数化把数据映射到用例的输入和断言。这样新加一个测试场景往往只需要往YAML文件里加一组数据再在用例函数pytest.mark.parametrize参数化列表里加一个key。4.2 用例编写与参数化执行登录模块的用例长这样testcases/test_login.py import pytest import yaml import os from page_objects.home_page import HomePage from config.settings import settings # 读取yaml测试数据 with open(os.path.join(os.path.dirname(__file__), ../testdata/login_data.yaml), r, encodingutf-8) as f: login_data yaml.safe_load(f) pytest.mark.parametrize(data_name, [login_success]) def test_login_success(login_page, data_name): data login_data[data_name] login_page.login(data[username], data[password]) home_page HomePage(login_page.driver) assert home_page.get_welcome_text() data[expected_text] pytest.mark.parametrize(data_name, [login_wrong_password, login_user_not_exist]) def test_login_failure_with_error(login_page, data_name): data login_data[data_name] login_page.login(data[username], data[password]) assert login_page.get_error_message() data[expected_error] pytest.mark.parametrize(data_name, [login_empty_username]) def test_login_empty_username(login_page, data_name): data login_data[data_name] login_page.login(data[username], data[password]) assert login_page.get_error_message() data[expected_error]通过Pytest的参数化一条用例函数对应多条测试数据。执行时Pytest会为每条数据生成独立的测试节点任何一个失败都不会影响其他节点互不干扰。另外注意我在用例里甚至没有出现URL、账号、密码这些硬编码信息完全通过login_page这个fixture和login_data数据来驱动。如果有人要求新增一个验证码错误场景只需要改YAML和新增一个test_login_captcha_error函数最低成本的扩展方式。4.3 用例间的隔离与执行顺序策略WebUI自动化最怕的就是用例顺序强依赖。我给自己定了一个规矩宁可每个用例多花两秒重新加载页面也不要通过前一个用例留下的状态来完成当前用例。所以在用例里login_page这个fixture每次都会执行page.get(settings.base_url)确保用例的初始状态永远是一致的登录页面。这就带来一个副作用如果用例是登录成功 - 进入首页那登录成功后的页面状态如何保持我的选择是登录成功这类场景一旦登录成功后续操作都在同一个用例内完成不跨用例。跨用例共享登录状态这件事虽然能省时间但带来的是用例间高耦合和高失败连锁反应维护成本远大于收益。除非做端到端的购物流程这种连续操作否则用例就该独立。同时执行顺序不要依赖于文件命名或函数定义位置。推荐给核心回归用例打标记pytest.mark.p0 def test_login_success(login_page): ...然后在Pytest配置里指定执行策略P0的用例永远最先跑保证最核心的回归链路第一时间暴露问题P1/P2的用例再用pytest-xdist做并行。4.4 HomePage跨页面流转的处理示例登录完成后自然要验证首页。HomePage也一样继承BasePage但在设计上会多一个断言页面已切换的动作page_objects/home_page.py from selenium.webdriver.common.by import By from common.base_page import BasePage class HomePage(BasePage): WELCOME_TEXT (By.XPATH, //div[contains(class, welcome)]) LOGOUT_BUTTON (By.XPATH, //span[text()退出登录]) def get_welcome_text(self) - str: return self.get_text(self.WELCOME_TEXT) def logout(self): self.click(self.LOGOUT_BUTTON)这个类虽然短但它解决了跨页面流转后的操作问题用例从LoginPage登录完紧接着构造HomePage对象来操作首页。两个页面对象之间通过同一个driver共享浏览器上下文这正是Selenium WebUI自动化的标准做法。一个小技巧get_welcome_text内部可以加一个基于URL的等待确保页面真正跳转完成def is_page_loaded(self) - bool: WebDriverWait(self.driver, self.timeout).until( lambda d: d.current_url.endswith(/home) ) return True这种基于URL片段或页面独有元素的加载判断远比sleep(2)可靠得多。5. 常见问题与排查技巧实录5.1 元素定位不稳定的根治方法WebUI自动化里元素定位不稳定是最常见的灵异事件。我总结的经验可以浓缩为一句话不要跟动态class过不去要跟静态特征做朋友。很多现代前端框架会生成非常长的动态class比如_2mQp8z3X这种每次刷新可能都变。遇到这种情况优先从这几个方向找稳定特征>options.add_argument(--headlessnew) options.add_argument(--disable-gpu) options.add_argument(--no-sandbox) # 容器内需要 options.add_argument(--disable-dev-shm-usage)--disable-dev-shm这个参数在Docker容器里特别重要不设置的话浏览器容易崩溃报错信息非常抽象而且排查半天都不会怀疑到共享内存上。CI里的环境变量也要提前配置好比如AUTO_TEST_ENVstaging_env、LANGC.UTF-8。字符集问题会导致日志乱码或文字断言失败别问我怎么知道的。5.4 测试报告与可持续集成的建议报告我推荐直接使用allure-pytest。除了样式好看它的用例历史记录和缺陷分类功能在回归分析里非常有用。pytest testcases/ -vs --alluredirreports/allure-results allure generate reports/allure-results -o reports/allure-report --clean把报告当成一个每天要看的仪表盘而不只是跑完就扔的产物。我给团队定的标准是每天看三个数字——用例总数、失败数、失败原因Top5。如果连续三天出现同类失败就要考虑是不是页面结构演变或者数据问题而不是反复去修断言。5.5 遇到的坑与排查速查表最后把实操中高频出现的坑整理成速查表建议收藏症状可能原因排查方法元素明明在页面上却定位失败元素在iframe/Shadow DOM中driver.switch_to.frame切换后再定位点击无响应但元素可见元素被遮罩层遮挡改用JS点击或等待遮罩消失用例随机失败有时过有时挂网络延迟导致加载超时把等待策略从固定sleep改为按元素状态等待登录后跳转的URL/页面不稳定前端路由跳转异步等待URL片段或页面独有元素出现跑完一堆失败第一眼全是超时前置接口挂导致页面白屏在登录后加页面打开展示的元素等待或接口健康检查多进程并行数据冲突用例间共享数据给进程分配独立的数据空间或数据造数改用随机前缀最后再分享一点实操体会这套框架从雏形到稳定我反复调整过好几轮。最大的领悟是高可维护性不是写出来的是改出来的。第一篇代码永远有瑕疵重要的是每遇到一次维护痛点就把框架对应的地方补强一次。比如最初我没做统一等待策略后来被超时问题逼着加了WebDriverWait最初截图逻辑散在各用例里后来统一收到conftest钩子里。框架本身不复杂就是一层层地给变化做好隔离。再往后如果要做数据驱动的接口测试、关键业务的逐层监控甚至可以在这套框架的common基础上继续扩展把UI自动化和接口自动化共用一套配置和报告体系。如果你也正被WebUI自动化的维护成本逼得快放弃不妨按这个思路从一个小模块重构起不要贪大求全跑通一条链路后再慢慢铺开。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →