简介:这是一套基于Python Pytest与Page Object模式封装的自定义UI自动化测试框架,适合测试人员与开发者在Web或桌面应用项目中快速落地界面回归测试。包内包含2000个文件,以py测试脚本为主,辅以c/h底层扩展、json/xml配置、少量js与文档文件,压缩后约68.95MB,结构上覆盖测试用例、封装方法、数据驱动与报告集成等模块。目前已有171人学习使用。通过该包可复用封装好的页面对象与断言逻辑,理解pytest夹具、参数化、异常处理及覆盖率统计的配合方式,并能在此基础上按项目需要扩展Selenium或PyAutoGUI操作,节省从零搭建UI自动化框架的时间,适合具备一定Python基础、希望提升测试效率的团队或个人参考。 去年我把手头的UI自动化整个推翻重写了一遍,核心就干了一件事:把散落在各个用例里的定位、点击、输入、等待全部收拢,封装成统一的一套组建。这个决定让脚本维护成本降了不止一半,也让新同学从“看不懂测试代码”变成了“半天就能上手写用例”。今天想把这套封装思路完整拆一遍,从为什么封装、基类怎么设计、Page Object怎么做,到稳定性踩坑和AI辅助提效,都会聊到。
这篇内容主要面向已经写过一些UI自动化、但还没系统整理过框架的测试开发同学;如果你刚接触Web或App自动化,也能从中拿到一套可以直接落地的封装路径。项目的实际背景是Web端业务系统先跑通,后来同一套基座扩展到了Android和iOS,所以我下面会兼顾Selenium、Appium以及多端复用的方案。
1. 封装前,先想清楚这三件事
1.1 你究竟要稳定还是要速度?
很多团队做UI自动化,一上来就追求“把全流程都自动化”,结果就是每天被一堆随机失败打脸。我自己的经验是:自动化解决的是回归验证的确定性,不是覆盖所有功能的速度。封装做得好不好,第一判断标准不是“写了多少条用例”,而是“这个用例跑挂了,你能不能在三分钟内知道它为什么挂”。
所以封装之前,先给自动化定一个边界:核心主流程、高频回归、跨端一致性验证这些值得做;UI频繁改版、一次性的临时活动页面,不值得花大力气写进框架。封装的价值不是把代码变多,而是把重复劳动变成一次性的约定。后面基类、Page Object、数据驱动,全都是围绕“稳定优先”来设计的。
1.2 框架分层:一个不被烂代码拖垮的结构
如果所有脚本都直接写driver.find_element,短时间跑得通,项目一大人就废了。我最终采用的框架结构可以简化为五层:
project/ ├── cases/ # 测试用例层,只写数据、动作、断言 ├── page_objects/ # Page Object 层,一个页面一个类 ├── components/ # 公共业务组件,如登录框、弹窗、Toast ├── base/ # 基类封装,如 BasePage、DriverFactory ├── utils/ # 公共工具:日志、截图、读取数据 └── config/ # 环境配置、元素定位器这个分层最核心的原则是“从下往上依赖”。用例层不直接接触Selenium/Appium原生的find_element,只调PageObject的方法;PageObject不关心测试数据怎么来,只负责页面操作;基类不关心业务,只解决driver、等待、异常处理这些通用问题。这样做的好处是:接手的人不需要看所有代码,改一个弹窗组件不影响其他用例,换浏览器、换设备也只需动DriverFactory。
1.3 选型:Selenium、Appium与pytest的组合为什么够用
我选的组合很简单:Web端Selenium 4,App端Appium 2,底层统一用pytest做执行框架,配合allure出报告。有人会问为什么不用Cypress或者Playwright,原因是项目当时既有Web又有App,Selenium和Appium能共享同一套Page Object设计思量;而且团队里已有的基础设施、定位习惯、驱动管理方式都更成熟。
Appium的底层是WebDriver协议,和Selenium同源,所以BasePage里大部分方法能直接复用。唯一要注意的是App和Web在操作语义上的差异,比如Web有iframe,App有native页面和WebView切换,这些差异不能靠正常继承硬吞,需要在基类里留出扩展点。pytest的fixture机制对driver初始化和回收特别友好,conftest里统一做driver管理,比在用例里写setup/teardown干净得多。
2. 把重复劳动封装进基类
2.1 基类BasePage的核心职责
BasePage是整个封装的地基。它不解决业务问题,只解决三件事:统一找元素、统一等待、统一异常处理。我见过很多人把find_element封装成一行return self.driver.find_element(*locator),这种封装等于没封装,因为调用方还是得自己处理找不到元素、等待不够、日志不清晰这些问题。
我的BasePage大致长这样:
class BasePage: def __init__(self, driver): self.driver = driver self.timeout = 10 def find_element(self, locator, timeout=None): timeout = timeout or self.timeout try: element = WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) ) self._log(f"元素定位成功: {locator}") return element except Exception as e: self.screenshot("find_element_error") raise AssertionError(f"定位元素失败 {locator}: {e}") def click(self, locator, timeout=None): element = self.find_element(locator, timeout) try: element.click() self._log(f"点击元素: {locator}") except Exception as e: self.screenshot("click_error") raise AssertionError(f"点击失败 {locator}: {e}") def input_text(self, locator, text, timeout=None): element = self.find_element(locator, timeout) element.clear() element.send_keys(text) self._log(f"输入文本: {locator} -> {text}")这版封装里有个容易被忽略的细节:每步操作前统一走显式等待,而不是只依赖隐式等待。元素找不到时立刻截图,断言消息里包含locator和异常信息,这比直接抛出NoSuchElementException要直观得多。实际跑脚本时,一张失败截图加一行日志,能省掉不少排查时间。
2.2 元素定位与操作层的封装细节
我不建议把定位表达式散落在用例或PageObject的每个方法里,而是集中管理。定位器用元组统一保存,例如("id", "login_btn"),这样BasePage里接收locator不需要关心是ById还是ByXPath,也方便后续统一改定位策略。
操作层有几个容易踩的坑值得展开说一下。第一,click前不一定要等待“可点击”,要等待“可见且可用”,因为按钮在动画过程中会存在看得见但点不中的状态;第二,input前调用clear不一定清干净所有场景,有的框架会触发输入事件,这时需要ctrl+a再删除;第三,Web端偶尔会遇到页面被遮罩层挡住点击,App端会停在半屏弹层,解决思路是先判断元素是否可点击,不可点击就先处理遮罩。
我通常会在BasePage里再补两个基础方法:is_element_visible和swipe_to_element,分别用来快速判断状态和处理App长列表。这些看起来琐碎,但正是这些琐碎细节决定了框架是“能跑”还是“稳”。
2.3 多端支持:Web和App怎么共用一套逻辑
多端复用的关键是“不要让PageObject直接创建driver”,driver从外面传进来。这样同一个LoginPage既能接收ChromeDriver,也能接收AppiumDriver。我服务的项目里,Android和iOS大都沿用这一套,但Android用uiautomator2驱动,iOS用XCUITest驱动,差异由DriverFactory屏蔽掉了。
DriverFactory的基本思路是:
class DriverFactory: @staticmethod def get_driver(platform, config): if platform == "web": return create_web_driver(config) elif platform == "android": return create_android_driver(config) elif platform == "ios": return create_ios_driver(config)WebDriverManager在这里很关键,它可以根据本机浏览器版本自动下载匹配的驱动,省去手动配置浏览器驱动版本的痛苦。App端则需要维护capabilities配置,包括deviceName、platformVersion、appPackage、appActivity,这些统一放在config里,不要散落在各用例中。
3. 页面对象与业务组件的封装实践
3.1 Page Object模式,封装的不是代码而是抽象
Page Object好像是老生常谈,但很多人做成了“把定位器从用例搬到类里”,方法体还是click、input等裸调用。这其实只完成了一半。PageObject真正的价值是:把页面当作一个对象,暴露给用例的是“用户能做的动作”,而不是“元素怎么操作”。
比如登录页,一个好的PageObject应该是这样:
class LoginPage(BasePage): username_input = ("id", "username") password_input = ("id", "password") login_button = ("id", "login_btn") error_tip = ("xpath", "//div[@class='error']") def login(self, username, password): self.input_text(self.username_input, username) self.input_text(self.password_input, password) self.click(self.login_button) def get_error_tip(self): return self.get_text(self.error_tip)用例层调用时,完全不需要关心元素定位是id还是xpath,也不需要知道输入框要先清空再输入。这样的抽象,换UI的时候只需改PageObject类,用例基本不动。这才是“封装”两个字的意义。
3.2 组件化封装:登录、列表、弹窗这些公共模块
项目里总会有一些跨页面复用的东西,最典型的是弹窗、Toast、下拉刷新、搜索栏。如果每个页面都写一套,改文案、改按钮位置时你会发现全工程都在飘红。组件化封装就是把这些公共模块从PageObject里抽出来,让不同页面共用同一个操作逻辑。
我以弹窗为例:很多业务页面的弹窗结构都类似,只有标题和确认按钮。封装后可以做一个CommonDialog组件,提供confirm()、cancel()、get_title()等方法。然后你在页面里可以通过组合方式使用它:
class SomePage(BasePage): dialog = CommonDialog(self.driver)这样写的好处非常直接:弹窗的关闭策略、等待策略、遮罩判断只维护一份。Android和iOS的弹窗样式不同,但都可以在CommonDialog内部按平台做分支。封装组件不是在代码层面炫技,而是把容易变化的地方盯住,收敛到一个文件里。
3.3 数据驱动与用例层的“薄化”
用例层越薄,后期维护越省心。我的习惯是:把测试数据放到外部文件,用例只负责“取数据、调动作、断结果”。比如登录用例,用pytest.mark.parametrize配合yaml数据,能写得很干净:
class TestLogin: @pytest.mark.parametrize("case", load_yaml("cases/login.yaml")) def test_login(self, case, driver): login_page = LoginPage(driver) login_page.login(case["username"], case["password"]) if case.get("expect_error"): assert case["expect_error"] in login_page.get_error_tip() else: assert HomePage(driver).is_loaded()数据文件里只需要写username、password、expect_error这些字段。新增一条用例数据,不需要碰代码,而且这些yaml还可以复用给接口自动化做数据准备。顺便说一句,这里的数据驱动也是某种程度的“接口封装”,它把UI操作和测试数据彻底隔离开,数据变化不会引起代码变化。
4. 稳定性与执行效率:封装最容易踩的坑
4.1 显式等待、隐式等待与轮询,别混用
UI自动化最常见的失败就是“元素没找到”和“元素不可点击”。问题往往出在等待策略上。我强烈建议整个框架里只用显式等待,关掉隐式等待,不要让它们混在一起。隐式等待是给driver设置一个全局轮询,但点击前“可见不可点击”这类复杂场景它处理不了;显式等待则可以针对单个条件做精确控制。
| 等待方式 | 适用范围 | 容易踩的坑 |
|---|---|---|
| time.sleep | 临时调试 | 睡太长拖慢执行,睡太短不稳定 |
| 隐式等待 | 全局粗粒度兜底 | 和显式等待混用会放大等待时间 |
| 显式等待 | 元素可见、可点、消失 | 需要选对expected_conditions |
| 轮询+重试 | 网络慢、异步加载 | 必须限制重试次数,避免死循环 |
我常用的封装是先从WebDriverWait和expected_conditions组合,再加上一个简单的重试装饰器。凡是遇到接口返回慢导致页面局部刷新的场景,不要盲目调大timeout,而是要定位到“真正要找的元素在哪个条件后会稳定出现”,然后针对它写等待。
4.2 驱动版本、浏览器环境与远程执行
浏览器驱动版本不匹配,是所有Web自动化新手的噩梦。手动下载驱动再放到PATH里,换台电脑就崩,根本谈不上团队协作。现在我会用WebDriverManager,几行代码就能自动匹配Chrome版本。同理,Appium也需要明确Android和iOS的不同驱动依赖,Android的uiautomator2、iOS的XCUITest,建议在安装文档里写清楚,避免每个人环境不一样导致结果不一致。
当用例量上来以后,单机执行会越来越慢。我的建议是在封装里提前预留远程执行开关:本地调试用本机driver,CI或集中跑的时候走Selenium Grid或Appium Server的remote地址。这样从单机扩展到并行不会有很大的改造量。
4.3 用例失败与截图日志:封装里最容易被忽略的部分
很多框架能跑,但失败时只抛一个NoSuchElementException,连元素长什么样都不知道。我的BasePage里,每步操作都写日志,并在异常时自动截图。pytest的钩子函数会进一步把截图和日志附到allure报告上,这样失败分析基本不用看控制台。
@pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item): outcome = yield report = outcome.get_result() if report.when == "call" and report.failed: driver = item.funcargs.get("driver") if driver: driver.save_screenshot(f"reports/fail_{item.name}.png")这里的一个经验心得是:截图命名最好包含用例名和时间戳,否则跑一遍用例失败多张图,根本分不清谁是谁。日志也不能只记“定位成功”或“点击成功”,最好带上页面当前URL或当前Activity,这样多端并行时能快速定位是哪个环境出的问题。
下面整理一个我在实际维护中反复遇到的排查速查表:
| 问题 | 常见原因 | 处理建议 |
|---|---|---|
| 元素定位不到 | 页面没加载完 / iframe未切换 | 显式等待 + 切换iframe后再查找 |
| 点击时报被遮挡 | 弹窗、广告遮住元素 | 封装清理遮罩逻辑,或先关闭弹窗 |
| Android执行比iOS慢 | 网络/动画/设备性能差异 | 针对不同平台设置合理等待时间 |
| 本地跑过,CI跑不过 | 浏览器参数、屏幕分辨率不同 | 远程执行时统一capabilities和视口尺寸 |
| 用例间数据互相影响 | 没有独立测试账号 | 数据驱动时每次生成新数据或做清理 |
5. 给自己一点提效空间:AI辅助与录制工具的价值
5.1 录制生成脚本只能当草稿,不能当成品
现在有不少工具支持录制操作并自动生成UI自动化脚本,确实能快速摸清系统的元素路径。但我试过之后发现,录制出来的脚本有个共同问题:定位器太死板,等待方式几乎没有,遇到一点动态变化就挂。录制工具最大的价值是帮我们批量摘取页面元素信息,省去人工从DOM树里找定位器的过程,而不是直接生成可长期维护的用例。
我现在的做法是:用录制工具跑一遍主流程,导出脚本后把定位器整理到PageObject里,然后把裸的click/input替换成BasePage封装方法,补上显式等待和日志。这个过程看似多了一步,实际上等于把录制的“草稿”重构成“真框架”,比从零开始写要快很多。
5.2 用AI补用例、改定位器,能省多少事
近一年AI写UI自动化的提效越来越明显。我最常用的场景是:让它根据业务描述生成一套测试用例模板,或者给它一段重复性很强的PageObject,让它补全类似页面的代码。AI对“封装继承多态”这类结构理解得比一般人想象中好,它可以帮你快速搭出统一的类结构,省去敲重复代码的时间。
但我也要提醒一点:AI生成的定位器和业务断言不一定准确,尤其对复杂业务状态的理解有偏差。它更适合做“初稿生成、批量修改、重构辅助”,不能完全替代人的判断。每次让AI写代码,我都会审视三件事:等待方式是否合理、失败信息是否清楚、用例之间有没有隐藏的状态依赖。
我个人这几年最大的体会是,封装到最后,真正值钱的不是那几层代码,而是你对被测系统的理解和稳定性的把控。框架可以换,但“把变化收敛到一处、把频繁操作抽象成通用方法、把失败信息做到一目了然”这套思路,放在Web端、App端还是接口测试里都通用。如果你准备重构自己的UI自动化,不妨先从基类和PageObject入手,把最脏最乱的定位和等待先收拢起来,剩下的会顺畅很多。
本文还有配套的精品资源,点击获取