简介:这是一套面向测试开发工程师与自动化测试初学者的UI自动化封装框架,基于Python与Pytest构建,采用Page Object模式简化分层设计,覆盖Web与桌面端常用操作,并将数据驱动、断言增强、失败重试、测试报告生成及CI/CD集成等能力整合为易调用的接口。资源共2000个文件,压缩包68.95MB,主体为1870个py测试脚本与封装模块,另含少量c/h扩展实现、txt说明文档、json/xml配置文件及markdown笔记,便于二次复用或定制,适合作为企业内部测试平台或个人学习参考。目前已有171人学习下载。读者可获得完整可运行的自动化项目骨架、分层封装思路、pytest插件搭配方案,以及异常处理与并发执行等落地细节;目录结构清晰,能快速定位基类封装、页面对象、用例集与报告输出模块,有助减少自定义框架的重复造轮子成本。
1. 自己封装组建的UI自动化:为什么我劝你别再堆脚本
UI自动化这件事,脚本刚写完那周是绿的,等被测系统迭代两个版本,就开始全屏飘红。你花一下午排查,发现不过是登录按钮的样式类从btn-primary改成了btn-brand,定位器集体失效。我见过太多项目死在维护阶段——不是用例没写好,而是把最不该重复的逻辑散落到了几十个脚本里。自己封装组建UI自动化框架,不是为了炫技,而是要把「会变的东西」收拢到少数几个文件里:驱动初始化、元素定位策略、等待时机、失败留证,全部统一出口。这份资源面向的正是两类人:刚上手想少走弯路的,以及被脚本维护拖垮、想重建体系的。接下来我从骨架开始,一步步把这套框架拆给你看。
2. 框架分层与工程骨架:为什么先拆目录比先写脚本更重要
自封装UI自动化最容易翻车的点不在技术,而在工程结构。我见过某跨平台系统的自动化仓库,跑了两年后没人敢动:元素id散落在几十个功能脚本里,改一个按钮的定位符要全局搜索加逐个试跑。封装的第一步不是写代码,而是画边界。
2.1 分层原则:config、common、pages、testcase各管哪一段
自封装UI自动化框架的骨架通常分成四层加一个报告目录,职责边界画清楚,后面所有脚本都是往这个框子里填东西。下面是我在模拟项目X里验证过的划分方式。
| 层 | 职责 | 放什么 | 禁止放什么 |
|---|---|---|---|
| config | 可变参数唯一出口 | 环境地址、浏览器类型、超时时间、账号数据 | 元素定位符(属于页面对象层) |
| common | 工具与基类 | 驱动工厂、日志封装、BasePage | 具体业务动作 |
| pages | 页面要素与操作 | 元素定位符、页面动作方法 | 断言、流程编排 |
| testcase | 用例与数据编排 | 测试步骤、断言、参数化数据 | 元素定位、驱动初始化 |
| reports | 过程产物 | 日志文件、失败截图、报告html | 源码文件 |
这里最容易被忽略的是单向依赖规则:testcase可以调pages,pages可以调common,common可以调config,反向路径一律不允许。一旦出现pages里去读配置文件、testcase里直接写find_element,分层就名存实亡。实际维护中你会遇到「改一个超时时间要动五个文件」的情况,那不是参数多,是依赖方向错了。
config层要单独拎出来说。环境地址、浏览器类型、隐式等待秒数、账号密码这类东西,本质都是「换环境就要改」的变量。把它们写进yaml或ini文件,而不是硬编码在脚本里,是为了让同一个用例集能在测试环境、预发环境来回跑。我一般会在config里放一个env.ini,里面按[test]、[staging]分节,运行用例时通过--env参数指定加载哪一节。这样预发环境的地址变了,改的只有配置文件,用例代码一行不用动。
2.2 可执行步骤:先搭骨架再写实现
拿到这份资源后,第一步不是去看驱动怎么封装,而是先把目录结构立起来。空目录不亏,后面每一块代码都是往对应位置填。骨架如下:
auto_ui/ ├── config/ │ ├── __init__.py │ ├── env.ini │ └── settings.py ├── common/ │ ├── __init__.py │ ├── logger.py │ ├── driver_factory.py │ └── base_page.py ├── pages/ │ ├── __init__.py │ └── login_page.py ├── testcase/ │ ├── __init__.py │ └── test_login.py ├── reports/ │ ├── logs/ │ └── screenshots/ └── pytest.ini每个目录新建后在__init__.py里留一行注释,说明「这个包只允许被谁引用」。这个动作看起来刻意,但三个月后你回来看,会发现它挡住了不少随手乱import。config/settings.py的作用是把ini文件解析成Python对象,业务代码里不直接读文件,而是from config.settings import ENV_CONFIG,换配置源时只改这一处。
pytest.ini是这个骨架的黏合剂,我用它替代了conftest.py里大量重复的config初始化:
[pytest] testpaths = testcase addopts = -v -s --tb=short markers = p0: 核心冒烟用例 p1: 主流程回归用例testpaths显式指定用例目录,避免pytest跑去扫描pages和common里的辅助类;addopts里的--tb=short是我踩坑后加上的——默认的长回溯会把定位失败信息刷到一屏之外,short模式只保留最近几层调用,失败原因一眼能看见。markers提前声明p0/p1两级标签,配合-m p0可以只跑冒烟集。
3. 驱动层的封装:初始化复用与三个翻车点
所有用例都依赖driver实例。如果每个脚本都自己写webdriver.Chrome(options=...),就等于把可变点铺满了整个仓库。驱动这一层必须收口,收口之后你改浏览器版本、切headless、换远程节点,都只需要动一个文件。
3.1 驱动工厂:把浏览器选择、启动参数、等待策略放在同一个出口
我一般用一个简单的函数做驱动工厂,不刻意上类,函数签名够用了。核心思路是:浏览器类型和启动参数全部来自config,函数内部只做组装和异常兜底。
# common/driver_factory.py from selenium import webdriver from config.settings import ENV_CONFIG def create_driver(browser: str = None, headless: bool = None): """创建WebDriver实例,浏览器类型与headless模式支持运行时覆盖""" browser = browser or ENV_CONFIG["browser"] headless = headless if headless is not None else ENV_CONFIG["headless"] if browser == "chrome": options = webdriver.ChromeOptions() if headless: options.add_argument("--headless=new") options.add_argument("--no-sandbox") options.add_argument("--disable-gpu") options.add_argument("--window-size=1920,1080") driver = webdriver.Chrome(options=options) elif browser == "firefox": driver = webdriver.Firefox() else: raise ValueError(f"不支持的浏览器类型: {browser}") driver.implicitly_wait(ENV_CONFIG["timeout"]) driver.maximize_window() return driver参数说明:browser和headless都支持显式覆盖,这样在调试时可以直接跑create_driver(headless=True),不用改配置文件。--headless=new是Chrome新版的推荐写法,老写法--headless在新版本上会报兼容警告。--no-sandbox和--disable-gpu是容器环境里必加的,不加会在无桌面环境下启动失败或渲染异常。
真正要注意的是隐式等待和显式等待的叠加问题。implicitly_wait(5)对每次find_element都生效,而显式等待只管当前这一次查找。两者同时存在时,最坏等待时间是两者之和:隐式5秒加显式10秒,一个元素消失时你要干等15秒才能报失败。所以在BasePage里做显式等待之前,我会先把这个逻辑想清楚——要么统一用显式等待,要么保留一个2到3秒的隐式等待兜底,不混用大数值。
3.2 fixture与浏览器生命周期:什么时候开、什么时候关
驱动创建好之后,还要解决「用例怎么拿driver、用完怎么还」的问题。pytest的fixture是标准答案,不是每个用例写try-finally去手动quit。
# conftest.py import pytest from common.driver_factory import create_driver @pytest.fixture(scope="function") def driver(): driver = create_driver() yield driver driver.quit()我推荐scope="function"起步,也就是每个用例都开一个全新浏览器。这种模式最大的优点是隔离:用例A把登录态搞坏了,不影响用例B。缺点是慢,一个用例多花3到5秒启动时间。等用例集大到上百条之后,可以再升级到scope="session"配合会话清理,但那样会引入一个很隐蔽的坑:某个用例异常退出后,浏览器留存脏状态,后面所有用例都跟着失败。新手阶段、用例量在50条以内,function级别是最省心的。
在Web和App混合的项目里,驱动工厂的入参换成Appium的capabilities即可,工厂逻辑本身不用动。这就是把驱动收口的红利——你在页面层写的那些方法,跨端可以直接复用,区别只在driver的创建方式。
4. 页面对象与用例编排:把元素、动作、数据从用例里剥出来
装好驱动、跑通一个「打开浏览器-输入账号-点登录」的用例并不难,难在当被测系统有20个页面时,你的定位符、动作方法、测试数据如果不分层,仓库会迅速退化成脚本垃圾场。页面对象模式(Page Object)就是用来解决这个问题的。
4.1 BasePage:把元素定位、动作、等待三种语义分开收口
基础实现不复杂,关键是把「等待」和「动作」绑定起来:不是先等元素存在再单独执行点击,而是每个动作自带合适的等待条件。以下是这份资源里BasePage的核心部分:
# common/base_page.py from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class BasePage: def __init__(self, driver): self.driver = driver self.timeout = 5 def find_element(self, locator, timeout=None): timeout = timeout or self.timeout return WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator), message=f"元素定位超时: {locator}" ) def click(self, locator, timeout=None): timeout = timeout or self.timeout el = WebDriverWait(self.driver, timeout).until( EC.element_to_be_clickable(locator), message=f"元素不可点击: {locator}" ) el.click() return self def input_text(self, locator, text, timeout=None): timeout = timeout or self.timeout el = WebDriverWait(self.driver, timeout).until( EC.visibility_of_element_located(locator), message=f"输入框不可见: {locator}" ) el.clear() el.send_keys(text) return self这里三个等待条件是有讲究的,不能统一用一个presence_of_element_located。点击操作要求元素在DOM里且可被点击,如果用一个只验证存在的等待,按钮还在渲染中就被点击了,偶发点击无效;输入框要可见才能接收键盘事件,不可见时send_keys会把事件丢进空气。return self的链式风格让页面动作写起来像说话:login_page.input(...).click(...).wait_until(...),省掉大量临时变量。
timeout参数的设计是留给慢接口页面的后门。默认5秒够覆盖大多数场景,碰到报表加载、数据量大的列表页,调用时显式传timeout=15即可,不用改基类。
4.2 页面类与用例层:一个登录用例跑通全链路
页面类的要点是把定位符定义为类属性,集中在文件顶部。任何元素变了,打开这个文件只改一行,而不是去全仓库搜,让定位符租期达到最长:
# pages/login_page.py from common.base_page import BasePage from selenium.webdriver.common.by import By class LoginPage(BasePage): user_input = (By.CSS_SELECTOR, "input[data-testid='username']") pwd_input = (By.CSS_SELECTOR, "input[data-testid='password']") login_btn = (By.CSS_SELECTOR, "button[data-testid='login']") def login(self, username, password): self.input_text(self.user_input, username) self.input_text(self.pwd_input, password) self.click(self.login_btn) return self定位符格式统一用(By.CSS_SELECTOR, "input[data-testid='username']")二元组,直接喂给BasePage的等待条件,不需要再拆包。这个资源里我刻意没有用xpath绝对路径——/html/body/div[3]/form/input[1]这类写法是定位符翻车的头号来源,前面加一个节点,整个路径就断掉。># testcase/test_login.py import pytest from pages.login_page import LoginPage @pytest.mark.p0 def test_login_success(driver): homepage = LoginPage(driver).open().login("admin", "test123") assert homepage.is_user_logged_in() is True @pytest.mark.parametrize("username,password", [ ("admin", "wrong_password"), ("nonexist_user", "test123"), ]) def test_login_failed_with_bad_credentials(driver, username, password): login_page = LoginPage(driver).open() login_page.login(username, password) assert login_page.get_error_message() is not None
parametrize这份资源里是重点但也是易错点。数据驱动适合登录、搜索这类「同一套动作换不同输入」的场景;但如果是「五个用例动作顺序完全不同」,就老老实实拆成五个函数,别硬塞到参数化里。参数化用例失败定位时报表信息会比较模糊,你看到的是一条用例带多个参数组,得展开才看得见哪一组炸了,所以给每组参数加id:pytest.mark.parametrize(..., ids=["wrong_pwd", "no_user"]),失败信息会清晰得多。
5. UI自动化的坑:定位策略失效、测试顺序依赖与丢日志
框架搭完跑绿不难,难在跑稳定。我整理了封装过程中踩过的五个高频现场,每一条都是「现象 → 原因 → 解决」的完整链路。这不是理论推演,是实打实翻车翻出来的经验。
5.1 定位器昨天还能点,今天就连不上了
现象:前一天跑得好好的用例,第二天一上来就在某个按钮上超时失败,日志显示element_to_be_clickable等待超时。手动打开页面,元素明明就在那里。
原因:这个元素的定位符用的是样式类或DOM结构中的某个不稳固属性,比如button.btn.btn-primary。前端上次发版改了样式类名,或者加了层嵌套,类名还在但样式优先级变了,Selenium找不到完全匹配的selector。
解决:把定位符换成># conftest.py 追加 import time from pathlib import Path import pytest @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") if driver: shot_dir = Path("reports/screenshots") shot_dir.mkdir(parents=True, exist_ok=True) name = f"{item.name}_{time.strftime('%Y%m%d_%H%M%S')}.png" driver.save_screenshot(str(shot_dir / name))
这个钩子的关键点在于判断report.when == "call"。整个用例有setup、call、teardown三个阶段,只对call阶段失败截图,避免把fixture的启动错误误判为用例失败、截一张空白图。item.funcargs.get("driver")拿到的是当前用例的driver实例,如果你把fixture叫了别的名字,这里要改成对应的名字。
截图之外,我还会在日志里记录失败时的页面上下文——当前URL、页面标题、错误消息摘要。截图是给你看画面,上下文是给定位问题用的。
我自己在维护框架时立了一条规矩:凡是新增一个页面类,必须同时确认failure hook已经生效,不生效不允许提交用例。从那以后每次接手新框架,第一件事就是检查失败留证挂没挂上,没挂就先补,不补不跑。否则你跑得再勤,遇到玄学失败还是两眼一抹黑。希望帮到你。
本文还有配套的精品资源,点击获取