pytest+playwright框架完善:从脚本到可维护的UI自动化架构

发布时间:2026/10/11 22:39:59
pytest+playwright框架完善:从脚本到可维护的UI自动化架构
先说个背景这个系列已经写了两篇前两篇我们把pytest和playwright的基本玩法捋了一遍从元素定位到断言从playwright的安装到与pytest的简单集成算是把能跑起来这件事解决了。这一篇完全不一样定位是结合项目进行框架的完善。说白了前两篇是讲怎么用工具这一篇是讲怎么把工具组织成一个像样的、能经受住真实项目折腾的测试框架。我会以自己最近在做一个后台管理系统的UI自动化项目为例把框架的目录结构、config管理、日志接入、失败重试、数据驱动、Page Object落地这些事一件件拆开讲每一步都会给出实际用过的代码和踩坑记录而不是把那些官方demo里的hello world再搬一遍。如果你是刚接触pytest和playwright前两篇没看的话建议先补一下基础如果你已经能写一些零散的自动化脚本但总觉得脚本越堆越多、越改越痛苦那这篇应该正好对着你的痛点。1. 为什么要在这个阶段谈框架完善1.1 自动化测试的演进路径脚本、框架、平台很多团队做UI自动化的路径是高度相似的。刚起步时写几个脚本验证核心流程能不能自动化这时候大家关心的是元素能不能定位到点击之后页面能不能跳转本质上是在验证playwright这个工具链是否靠谱。脚本写多了以后问题就变了变成改一个页面跳转逻辑我得上百个用例里去找那几处硬编码的URL用例跑到一半失败截图日志都得去CI里翻半天新同事接手看着目录完全不知道从哪里下手。这就是从脚本阶段进入框架阶段的信号。所谓框架不是为了听起来高级而是为了解决几个非常实际的问题用例之间如何共享浏览器实例、如何统一管理配置和日志、如何把页面操作和业务断言解耦、如何让失败时能自动收集足够的现场信息。再往上走是平台化就是把用例管理、执行、报表都做成web服务但那已经是另一个话题了。这一篇聚焦在框架完善目标是把你的自动化项目从能跑提升到稳定、可维护、可扩展。1.2 框架完善的三个核心维度我在完善这个框架时脑子里始终有三把尺子。第一把尺子是稳定性。UI自动化最大的敌人就是不稳定同样的脚本今天跑过明天就挂而且挂在不同的地方。解决稳定性的手段包括合理的等待策略、失败自动重试、准确的日志和截图记录。没有这些脚本跑出来的报错信息根本没法定位问题大家就会自然地说UI自动化不靠谱。第二把尺子是可维护性。这体现在代码组织上。页面对象模型把页面元素定位和操作封装到独立的类里用例层只关注业务场景数据驱动把测试数据从代码里抽离出来一个用例可以喂多组数据公共方法抽到基类里避免到处重复代码。维护性差的框架改动成本会随着用例数量线性上升最后重写比维护更便宜。第三把尺子是可扩展性。新加一个页面不需要动框架本身的代码新加一个浏览器类型改一行配置就能跑需要接入新的报告平台不至于被现有logger耦合死。框架的扩展性决定了它能跟你项目一起活多久。这三把尺子在实际项目中逐步落实。下面是我们在重构中每一步的具体做法。2. 项目驱动的框架重构目录结构与分层思想2.1 从脚本文件堆到分层架构我刚接手这个后台管理系统的自动化项目时它的结构很自由一个test目录下面躺了几十个.py文件每个文件里既有元素定位、又有业务操作、还直接print断言结果。更崩溃的是很多URL和账号密码都散落在各个文件里登录方式改了全量脚本要跟着改。这种结构在用例少于20个的时候还能忍一旦要覆盖完整的回归用例集就完全失控。重构的第一步就是先确定分层思想。这套思路不是我拍脑袋想出来的参考的是行业里成熟的做法核心原则是稳定层依赖不稳定层的反向理解也就是说把最容易变化的部分页面元素、操作步骤、测试数据和相对稳定的部分用例编排、报告、日志分开存放让变化的部分可以独立修改不影响整体框架。在pytestplaywright项目里我常用的分层是四层用例层testcases只负责业务场景的编排和断言业务操作层pages封装页面对象和公共操作数据层data存放测试数据和配置文件基础设施层common或core处理pytest的fixture、配置读取、日志、报告、驱动实例化。这样做的好处非常直接当你需要改登录逻辑你只需要去pages里改login_page.py当你需要加一条测试数据去data目录加一个参数化文件当一个用例挂了你看日志和截图能知道是页面元素变了还是业务规则变了。2.2 核心目录结构说明我最终用的目录结构是这个样子你可以根据自己的项目调整ui_auto_test/ ├── pytest.ini # pytest主配置 ├── requirements.txt ├── config/ │ ├── config.yaml # 全局环境配置 │ └── settings.py # 配置读取封装 ├── core/ │ ├── base_page.py # 页面对象基类 │ ├── browser.py # 浏览器实例管理 │ ├── logger.py # 日志封装 │ └── screenshot.py # 截图公共方法 ├── pages/ │ ├── login_page.py │ ├── home_page.py │ └── user_manage_page.py ├── testcases/ │ ├── conftest.py # 核心fixture定义 │ ├── test_login.py │ └── test_user_manage.py ├── data/ │ ├── login_data.yaml │ └── user_data.json ├── reports/ # allure报告输出 ├── logs/ # 运行日志 └── utils/ └── read_data.py # 数据文件解析方法这个结构里有几个细节值得说一下。第一conftest.py放在了testcases目录而不是项目根目录这样fixture的作用范围就限定在testcases这一层不会影响其他潜在的测试目录隔离性更好。第二config目录和core目录分离配置是静态数据core是读写配置和实例化对象的逻辑改配置不用动代码改读取逻辑不会误碰数据文件。第三pages目录下文件名与页面一一对应比如login_page.py对应登录页新接手的人看到目录基本就能猜出业务结构。2.3 从能跑到能维护的关键转变点说实话目录结构是最容易抄的真正难的是代码职责的边界划分。我在重构过程中定了两条硬性规矩项目组里所有人都得遵守。第一条规矩测试用例里不允许直接出现playwright的API调用比如page.click、page.fill这些。用例层只能调用pages层封装好的业务方法比如login_page.login(username, password)。这条规矩极度重要它的价值体现在有一天底层定位方式从CSS选择器改成XPath所有用例代码一行都不用动只需要改pages层对应的那个方法。第二条规矩元素定位表达式只允许出现在pages层不允许出现在测试数据文件里。原因很简单测试数据应该是业务层面的输入和期望结果比如一个用户名、一个期望的提示信息而元素选择器是实现层面的东西比如#login-btn或者text登录。把它们混在一起会导致数据文件对页面结构高度敏感页面一改数据和脚本要一起改这违背了数据驱动的初衷。这两条规矩执行了大概两周效果非常明显。后期新增用例的耗时下降了很多新同事也不需要理解整个框架内部实现按pages层的方法往上堆业务即可。3. 基础设施完善配置管理、日志与报告3.1 多环境配置文件管理在实际项目里你几乎不可能只有一个测试环境。开发环境、测试环境、预发布环境甚至你本地的环境URL、账号、数据库连接串都不一样。之前脚本里写死URL的方式就不用提了我在重构时把配置做了统一管理。我选了YAML格式存配置文件主要因为可读性好嵌套结构也清晰。config.yaml大致长这样base_url: https://test.example.com browser: browser_type: chromium headless: false viewport: width: 1920 height: 1080 timeout: 30000 slow_mo: 500 account: admin: username: admin password: admin123 normal: username: user01 password: user123针对不同环境我用了一个很土但很有效的方式通过环境变量指定加载哪个配置文件默认加载config.yaml如果设置了RUN_ENVstaging就加载config_staging.yaml。这比在代码里做复杂的if-else要直观得多。配置读取的封装settings.py核心代码大致是这样的import os import yaml class Settings: _config None classmethod def load(cls, envNone): env env or os.getenv(RUN_ENV, test) config_file os.path.join( os.path.dirname(__file__), fconfig_{env}.yaml if env ! test else config.yaml ) with open(config_file, r, encodingutf-8) as f: cls._config yaml.safe_load(f) classmethod def get(cls, key, defaultNone): if cls._config is None: cls.load() keys key.split(.) value cls._config for k in keys: value value.get(k, None) if value is None: return default return value这样在代码里调用Settings.get(browser.timeout)就能拿到配置值而且配置是运行时加载不需要改代码就能切换环境。这个封装非常简单但它解决了自动化项目里最烦人的环境混乱问题。3.2 日志系统搭建UI自动化的日志系统经常被低估。很多人觉得print一下就够了但当你跑一条包含50个步骤的用例时print出来的信息根本没有层次你无法快速定位是第几步出了问题更没法把日志和playwright的页面操作对应起来。我用Python标准库logging做了一个轻量封装核心配置为import logging import os from datetime import datetime def setup_logger(nameui_auto, log_levellogging.INFO, log_dirlogs): os.makedirs(log_dir, exist_okTrue) logger logging.getLogger(name) if logger.handlers: return logger logger.setLevel(log_level) log_file os.path.join( log_dir, fui_auto_{datetime.now().strftime(%Y%m%d_%H%M%S)}.log ) fh logging.FileHandler(log_file, encodingutf-8) fh.setLevel(logging.INFO) sh logging.StreamHandler() sh.setLevel(logging.INFO) fmt logging.Formatter( %(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s ) fh.setFormatter(fmt) sh.setFormatter(fmt) logger.addHandler(fh) logger.addHandler(sh) return logger这个日志系统有几个比较实用的小设计。第一文件名带时间戳每次运行生成独立的日志文件不会越跑越大排查问题也方便跟具体的运行时间对应上。第二日志格式里包含了文件名和行号等查日志时候就能直接定位到是哪一行的代码打出来的非常实用。第三logger有handlers判断避免重复添加handler这在pytest的fixture中反复调用时特别重要。在实际项目里我要求pages层里面的公共方法都写入关键信息。登录成功时打一条login success with user: xxx断言失败时打一条assert failed, actual value is xxx。这样排查问题的时候打开日志就能还原整个操作轨迹再配合截图基本上能还原现场。3.3 测试报告集成与截图机制报告这块我选了Allure。原因很简单项目组成员都习惯看Allure的报告而且它支持步骤分组、附件截图、日志挂载pytest集成又成熟。集成方式就是pip安装allure-pytest然后在pytest.ini里加一行addopts[pytest] addopts -v --alluredir./reports/allure-results --clean-alluredir真正有价值的不是这个基础配置而是自动截图机制。UI自动化中用例失败瞬间的页面状态是最重要的排查线索。我在conftest.py里写了一个hook用例失败时自动截图并附加到Allure报告中import allure import pytest from core.screenshot import take_screenshot pytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: page item.funcargs.get(page, None) if page: screenshot take_screenshot(page, item.name) allure.attach.file( screenshot, namefailure_screenshot, attachment_typeallure.attachment_type.PNG )截图方法take_screenshot封装在core/screenshot.py里import os from datetime import datetime def take_screenshot(page, prefixfailure): os.makedirs(screenshots, exist_okTrue) filename os.path.join( screenshots, f{prefix}_{datetime.now().strftime(%Y%m%d_%H%M%S)}.png ) page.screenshot(pathfilename, full_pageTrue) return filename这个机制带来的体验提升是巨大的。你去Allure报告里看一个失败的用例能直接看到失败那一刻整页截图而不是只有一段孤零零的堆栈。如果你的页面很长可以设置full_pageTrue截全屏我测试过后台管理系统时喜欢用全屏截图因为很多表格操作错误要到页面底部才能看到关键信息。4. 关键机制实现fixture、重试与数据驱动4.1 conftest.py 中的核心fixture设计pytest的fixture是整个框架的粘合剂playwright的浏览器实例、配置对象、登录状态、公共接口都应该通过fixture生成。下面是我在conftest.py里核心的fixture设计import pytest from core.browser import BrowserManager from core.logger import setup_logger from config.settings import Settings logger setup_logger() pytest.fixture(scopesession) def browser_context(): manager BrowserManager() context manager.new_context() yield context manager.close() pytest.fixture() def page(browser_context): page browser_context.new_page() yield page page.close() pytest.fixture() def login_page(page): from pages.login_page import LoginPage return LoginPage(page)这里有两个设计点说一下。第一browser_context的scope是session也就是说整个测试会话只启动一个浏览器context每个用例独立开新页面既快又隔离。第二page和login_page的scope都是function保证每个用例拿到的是干净页面用例之间互不污染。登录态的复用是UI自动化里提升速度的核心。每个用例都从登录页输入账号密码走一遍100个用例就是100次登录时间和网络开销都不小。我用storage_state来实现登录态的会话级复用pytest.fixture(scopesession) def auth_state(browser_context): context browser_context login_page LoginPage(context.new_page()) login_page.login(Settings.get(account.admin.username), Settings.get(account.admin.password)) state context.storage_state() context.clear_cookies() return state pytest.fixture() def page(browser_context, auth_state): context browser_context context.add_cookies(auth_state[cookies]) page context.new_page() yield page page.close()这样第一个用例执行时完成登录后续用例直接复用cookie不需要重新登录。这里要注意storage_state保存的cookies有时候会带上不该带的东西比如本地的开发环境标识所以我在生成state之后主动clear_cookies再按需添加避免脏数据污染。4.2 用例失败自动重试机制UI自动化的脆弱性决定了失败重试是刚需。我用的工具是pytest-rerunfailures插件安装完之后在pytest.ini里配置addopts -v --alluredir./reports/allure-results --clean-alluredir --reruns 2 --reruns-delay 1意思是最多重试2次每次重试间隔1秒。这里必须说一个我的心酸教训重试并非越多越好之前项目里设置了5次重试用例跑下来耗时翻倍而且某些用例在重试期间反复操作同一个按钮把测试环境的脏数据搞出来一堆。最终确定了非关键用例重试2次、重要业务用例不重试的规则稳定性反而更高。如果一个重要用例跑挂了你要的应该是准确的失败现场而不是它在后台偷偷重试之后假装成功。另外重试和截图机制要配合好。pytest-rerunfailures默认在重试成功后会把之前的失败信息吞掉但截图是hook里生成的仍然会附着到最后的Allure报告里看起来就像成功用例带了一张失败截图容易误导人。我后来在截图文件命名上加了时间戳并且在截图前打一条warning日志基本能区分是哪一次运行留下的。4.3 数据驱动与参数化UI自动化里的数据驱动很多人只是简单用一下parametrize但我项目里遇到的真实需求要多一些字段组合多、数据量大、不同用例需要不同数据文件。我的做法是把测试数据写到YAML/JSON文件由工具方法读取后返回列表再喂给parametrize。data/login_data.yaml长这样login_success: - case: valid admin login username: admin password: admin123 expect: 欢迎回来admin - case: valid normal user login username: user01 password: user123 expect: 欢迎回来user01 login_fail: - case: wrong password username: admin password: wrongpass expect: 用户名或密码错误 - case: empty username username: password: admin123 expect: 用户名不能为空然后在用例里这样用import pytest from utils.read_data import load_yaml_data data load_yaml_data(data/login_data.yaml) pytest.mark.parametrize(username,password,expect, data[login_success]) def test_login_success(page, username, password, expect): ...load_yaml_data是一个很简单的封装import yaml import os def load_yaml_data(path): with open(path, r, encodingutf-8) as f: return yaml.safe_load(f)这里要提醒一个坑parametrize的参数列表是静态的在用例导入时就会执行所以load_yaml_data不能依赖运行时才生成的配置或环境变量否则很容易报参数化数据读取失败而且这个报错信息还特别难排查。4.4 用例依赖与执行顺序控制很多刚入门的人喜欢让用例之间有依赖比如test_b必须先执行test_a执行成功之后它才执行。在pytest里用--durations控制执行耗时用pytest-order插件控制用例顺序但我强烈建议你尽量避免用例间依赖。UI自动化的用例应该是独立的因为一旦你设置了顺序依赖某个用例失败就会导致下游一串用例失败排查成本剧增。如果你确实需要控制用例执行顺序比如先跑冒烟测试再跑回归测试可以这么做pytest testcases -m smoke pytest testcases -m regression用mark来区分优先级比在代码里硬编码顺序要优雅得多。在用例上这样打标pytest.mark.smoke def test_login_and_logout(page): ...在pytest.ini里注册markmarkers smoke: 冒烟用例 regression: 回归用例这样整个执行策略就被你拿捏在了手里。回归时只跑regression冒烟时只跑smoke都不需要改动任何用例代码。5. 代码实战页面对象模型与业务封装5.1 Page Object 模式的落地Page Object Model是UI自动化里最经典的封装模式核心思想把一个页面的元素定位和业务操作封装到一个类里测试用例只跟业务方法打交道不直接操作选择器。base_page.py我做了这样一个基类from core.logger import setup_logger logger setup_logger() class BasePage: def __init__(self, page): self.page page def navigate_to(self, url): logger.info(fnavigate to {url}) self.page.goto(url) def click(self, selector, **kwargs): logger.info(fclick element: {selector}) self.page.click(selector, **kwargs) def fill(self, selector, text, **kwargs): logger.info(ffill element: {selector}, text: {text}) self.page.fill(selector, text, **kwargs) def get_text(self, selector, **kwargs): text self.page.text_content(selector, **kwargs) logger.info(fget text: {selector}, return: {text}) return text def is_visible(self, selector, **kwargs): return self.page.is_visible(selector, **kwargs)你可能会觉得这几个方法没什么技术含量不就是包了一层嘛。但它带来的两个好处值得说说。第一所有日志在这里统一输出每次操作都有痕迹排查问题时不需要在几十个用例里猜。第二如果playwright升级后某个API废弃了或者你决定从playwright换成其他工具只需要改base_page这些公共方法用例层的代码全部不动。拿login_page.py来说from core.base_page import BasePage class LoginPage(BasePage): username_selector #username password_selector #password login_btn_selector #loginBtn error_tip .el-form-item__error def login(self, username, password): self.fill(self.username_selector, username) self.fill(self.password_selector, password) self.click(self.login_btn_selector) def get_error_message(self): return self.get_text(self.error_tip) def login_success_expect_text(self): return 欢迎回来这里面就把登录这个动作封装成了一个稳定的业务方法元素定位如果在某个版本变了改这一处就好。5.2 复杂业务场景的封装示例页面对象不只是把点击填充包一层更重要的是把复杂的业务操作串起来。我项目里有一个比较典型的场景在用户管理页面新增一个用户需要填写表单、选择角色、上传头像、点击保存然后校验列表里出现新用户。这个场景如果写在用例里会非常长而且每一步的细节比如选择器、操作顺序都属于页面实现细节不该让用例层看到。我把它封装成user_manage_page.py的一个方法class UserManagePage(BasePage): add_user_btn #addUserBtn name_input #userName role_select #roleSelect avatar_upload #avatarInput save_btn #saveBtn search_input #searchInput search_btn #searchBtn table_rows .el-table__row def create_user(self, name, role, avatar_path): self.click(self.add_user_btn) self.fill(self.name_input, name) self.select_option(self.role_select, role) self.page.set_input_files(self.avatar_upload, avatar_path) self.click(self.save_btn) toast self.get_text(.el-message--success) assert 新增成功 in toast def search_user(self, name): self.fill(self.search_input, name) self.click(self.search_btn) return self.get_text(self.table_rows)这样用例层就非常清爽def test_create_and_search_user(user_manage_page): user_manage_page.create_user(zhangsan, admin, data/avatar.png) assert zhangsan in user_manage_page.search_user(zhangsan)这种封装让用例更像一份操作说明书而不是一串技术细节集合。你甚至可以让测试人员直接按照用例的步骤去手工复现因为他们看得懂这些业务方法名。6. 常见问题与排查技巧实录6.1 元素定位失败iframe、shadow DOM、动态属性UI自动化里80%以上的失败都跟元素定位有关。我项目中遇到最多的三类情况这里把排查方法一并写了。iframe场景后台管理系统很多都嵌入了旧的iframe页面。playwright里处理iframe的方式比较优雅用frame_locator接口frame page.frame_locator(#mainFrame) frame.locator(#btnInFrame).click()不直接定位iframe里面元素的原生选择器而是先声明frame_locator再在这个上下文里定位这让代码的意图清晰很多。shadow DOM场景某些表单控件内部是shadow DOM结构普通CSS选择器进不去。playwright也支持穿透shadow DOM用CSS里的深度选择器可以搞定。这种情况下我一般会把shadow DOM里的元素定位封装到pages层用例层完全无感。动态属性场景很多前端框架会在每一次渲染时给元素生成随机ID比如idbtn-12345这种。这时候不能硬用id去定位要么用相对稳定的class或data属性要么用文本定位。playwright的text定位器非常好用特别是在按钮文案不容易变的场景下。6.2 等待策略使用不当导致的稳定性问题playwright最强大的地方之一是它的自动等待机制元素可交互时才执行下一步操作。但自动等待解决不了所有问题特别是接口返回和页面渲染不同步时。我遇到一个经典问题点击保存按钮后页面上先弹出一个加载中的遮罩遮罩消失后才显示成功提示。自动等待对遮罩存在的判定不够精确直接去读成功提示会扑空。解决办法是显式隐藏等待def wait_mask_hidden(self): self.page.wait_for_selector(.loading-mask, statehidden, timeout10000)把这些等待逻辑也封装到pages层方法中不要散落在用例里。另外一个经验是playwright默认的timeout是30秒这个数值在绝大多数场景下太长。如果页面确实挂了30秒的等待会让整个测试套件变得不可容忍。我建议在浏览器context初始化时把这个值调成10秒或者15秒让失败来得快一点早暴露早修复。6.3 并发执行下的环境隔离问题UI自动化上并发多数人一开始都想跑得越快越好。pytest-xdist就是干这个的。但在真实项目里并发执行对测试环境有非常高的要求。如果你要并发执行必须保证测试环境能承受多个浏览器同时操作测试数据之间没有互相覆盖的可能每个worker进程的日志和截图输出不会写到同一个文件。我项目里前期并发跑的时候最典型的问题是多个worker同时操作同一个用户的登录状态导致session混乱用例全部失败。后来我改为给每个worker分配不同的测试账号或者使用无共享状态的独立用户问题才消停。如果你们环境不具备并发条件别硬上串行跑完整套回归加上合理的重试和日志其实也够用。我见过不少团队为了并发而并发最后投入产出比惨不忍睹。6.4 其他踩坑记录最后补几个小坑。一是关于first/last元素的处理。playwright的locator.first 可以直接用但要注意first本身也是一个locator不是点击之后返回什么。如果你需要操作多个匹配元素中的某一个不要用page.click直接传一个会命中多个元素的定位表达式playwright会报strict mode violation。它的报错信息很明确说strict mode violation解决方法就是locator.first或者locator.nth(0)明确指定。二是关于本地存储和cookie的坑。storage_state保存下来的是所有域的cookie如果你的测试环境有多个子域后续登录态复用时候可能会带上一些失效的域名cookie导致登录跳转异常。建议在保存state之前清理掉与本次测试无关的domain或者只保留目标根域名的cookie。三是关于慢速操作。本地调试时建议把browser的slow_mo设置为200到500毫秒这样能看到playwright每一步操作过程定位问题非常直观。CI环境里则把slow_mo设成0追求速度。四是我个人的一个小习惯凡是用到页面元素定位的地方我都会在pages层的方法里加一个日志把用的选择器和关键参数打印出来。这样不仅排查问题快而且当你去优化定位方式时日志能帮你确认现在跑的到底是哪条路径。这套框架到目前为止我已经在项目里跑了三个月覆盖了六个典型业务模块回归用例量从最初的30条涨到了160条左右执行时间从早期的25分钟压到了13分钟串行登录态复用。稳定性上最近的完整回归成功率基本维持在95%以上剩下的失败大多来自前端改动引发的定位失效这部分靠截图和日志能很快定位修起来也就是改一个selector的事。最后再分享一个个人很深的体会框架完善这件事没有终点它是跟着项目走的。项目页面在变业务逻辑在变测试环境和数据也在变框架只有在这个持续变化的过程中不断被调整才有存在的价值。不要为了某个看起来很规范的架构而不愿意修改它也不要因为某个脚本写得丑就一直拒绝动手两者都会让自动化体系悄悄烂掉。我觉得最好的状态是框架的每一次调整都能减少一点维护成本、提升一点排查效率这就值了。