Playwright + pytest UI 自动化测试框架实践:从用例设计到 CI 落地
Playwright + pytest UI 自动化测试框架实践:从用例设计到 CI 落地
本文记录我实现的一套 Web UI 自动化测试框架,基于 Playwright + pytest,覆盖业务用例设计、插件二次开发、失败自动诊断、多上下文隔离、API Mock、三层断言、Allure 报告与 Jenkins CI 全流程。
🔗 项目仓库:DjTikas/playwright-web-auto-framework: 基于 Python + Playwright + Pytest 搭建 Web UI 自动化测试框架 Demo。
📊 在线报告:Allure Report
一、项目介绍
1.1 被测系统与业务背景
我基于 Playwright 从零搭建了一套 UI 自动化测试框架,并在一个接口测试平台上落地验证。这是一套典型的后台管理 Web 系统,核心业务围绕"资源管理"展开:
- 账号体系:用户注册、登录
- 项目域:项目的创建、列表查询、搜索、编辑、删除
- 模块域:项目下模块的新增、列表、联动搜索
- 环境域:项目环境的配置与管理(名称、地址校验)
- 协作场景:多账号间的资源创建与权限管理(A 账号创建、B 账号删除)
选择这个系统作为测试对象,是因为它具备一条完整且可闭环的业务主线,非常适合检验自动化框架的业务覆盖能力和工程化能力:
注册 → 登录 → 新增项目 → 新增模块 → 配置环境 → 列表查询/搜索/分页 → 编辑/删除
1.2 技术栈一览
| 层级 | 技术 |
|---|---|
| 测试框架 | pytest 8.3.5 |
| 浏览器驱动 | Playwright 1.48.0(Python 同步 API) |
| 插件 | pytest-playwright 0.5.2(二次开发版) |
| 测试报告 | Allure 2.16.0 |
| 持续集成 | Jenkins + Docker |
| 通知 | 钉钉自定义机器人 |
| 代码托管 | GitHub(Actions 自动镜像 Gitee) |
二、技术选型:为什么是 Playwright
在选型阶段,我对比了 Selenium、Cypress 和 Playwright:
- Selenium:生态最成熟,但 API 偏底层,自动等待弱,需要自己封装大量
WebDriverWait。 - Cypress:开发体验好,但只支持 JavaScript,多浏览器支持有限,且对多标签页、多域名支持不好。
- Playwright:自动等待(auto-waiting)、多浏览器(Chromium/Firefox/WebKit)、多上下文隔离、Trace Viewer、网络拦截,且支持 Python。
最终选择 Playwright,核心原因是它的 自动等待机制 和 BrowserContext 隔离模型——这两个特性直接对应了"用例不稳定"和"多账号测试"两大痛点。
三、整体架构
3.1 目录结构
web_playwright/
├── cases/ # 测试用例集(按业务域划分,而非按页面平铺)
│ ├── common/ # 公共测试数据(validation_data.py,一份规则多处复用)
│ ├── test_auth/ # 认证域:登录/注册
│ ├── test_project/ # 项目域:新增页 + 列表页(职责分离)
│ ├── test_module/ # 模块域:新增页 + 列表页
│ ├── test_more_accounts/ # 多账号权限协作场景
│ ├── demo/ # 技术演示用例(默认不参与回归,保证主套件全绿)
│ └── conftest.py # 用例级 fixture(页面实例、上下文隔离)
├── pages/ # POM 页面对象层(7 个页面类)
├── mocks/ # API Mock 数据集中管理
├── plugins/ # 二次开发的 pytest-playwright 插件
├── conftest.py # 全局配置(Allure 动态标题、钉钉通知、浏览器参数)
├── pytest.ini # pytest 运行配置
├── run.py # 运行入口(一键执行回归 + 生成报告)
├── Jenkinsfile # CI/CD 流水线
└── requirement.txt # 依赖清单
3.2 分层设计思想
测试用例层 (cases/) → 只描述业务流程,不写元素定位
↓
页面对象层 (pages/) → 封装元素定位 + 页面操作
↓
Fixture 层 (conftest) → 管理浏览器、上下文、登录态、失败产物
↓
插件层 (plugins/) → 二开 pytest-playwright,扩展截图/视频/Trace
↓
基础设施层 → Jenkins + Docker + Allure + 钉钉
用例、页面、数据、基础设施四层分离,新增业务模块时只需新增页面对象和用例文件,不需要改动框架代码。
四、业务分析与用例设计
4.1 用例覆盖矩阵(按业务域)
我按业务域组织用例,每个模块遵循正常 → 边界 → 异常 → 数据闭环四个层次(下表的"闭环"即新增后回列表验证数据真实落库):
| 业务域 | 覆盖场景 | 关键断言 / 亮点 |
|---|---|---|
| 公共表单校验 | 用户名:空 / 超 30 位 / 特殊字符;密码:空 / 长度边界 / 特殊字符 | 提示文案 + 提交按钮禁用;一份数据驱动登录、注册两页 |
| 注册 | 注册成功(uuid 唯一账号)、用户名已存在、跳转登录链接 | 成功后可登录(数据闭环);重复注册报错 |
| 登录 | 登录成功、用户名/密码错误(数据驱动)、跳转注册链接 | 登录后进入受保护页面(cookie 生效) |
| 项目管理(新增页) | 名称/应用/描述规则校验、名称重复(mock 400)、服务器异常(mock 500)、新增成功、成功后列表可查 | 前端规则 + mock 异常 + 数据闭环 |
| 项目管理(列表页) | 列表渲染、搜索命中/空结果、分页/每页条数、新增弹窗打开/取消、删除(403 无权限 / 确认后消失)、编辑回显 | 请求参数断言 + 表格内容断言;职责分离:列表页不再混入新增用例 |
| 模块管理(新增页) | 必填为空、模块名重复(mock 400)、新增成功(mock 201)、成功后模块列表可查 | 数据闭环 |
| 模块管理(列表页) | 列表渲染、项目下拉 + 关键词组合搜索、分页、刷新复位到第一页 | 组合条件搜索 + 请求参数断言 |
| 环境管理 | 环境名规则校验、地址必填/必须以 http(s) 开头、已存在(mock 400)、新增成功 → 列表出现、取消关闭模态框 | 模态框交互 + 数据闭环 |
| 多账号协作 | A 账号创建项目 → B 账号(admin)搜索并删除 | 真实权限场景,两个独立 Context 并行操作 |
用例数量以仓库实际 Allure 报告为准。
4.2 用例设计的四个原则
① 业务主线驱动,而不是页面罗列
用例组织完全围绕 注册 → 登录 → 项目 → 模块 → 环境 → 列表 → 编辑/删除 这条业务主线。而不是"登录页 10 条、列表页 12 条"这样按页面机械堆叠。
② 数据驱动
注册和登录的表单校验是同一套业务规则(用户名 1-30 位、无特殊字符;密码 6-16 位、无特殊字符)。如果每个页面各抄一遍校验用例,规则变更要改多处。我抽成 cases/common/validation_data.py 一份数据,用 @pytest.mark.parametrize 同时驱动登录页和注册页:
FORM_VALIDATION_CASES = [
("username", "", "不能为空"),
("username", "x" * 31, "1-30位字符"),
("username", "daij@", "不能有特殊字符"),
("password", "", "不能为空"),
("password", "123", "6-16位字符"),
# ...
]
收益:一处维护、两页覆盖,规则变更只改 validation_data.py。
③ 列表页与新增页职责分离
列表页面有一个新增按钮,功能与新增页完全一致。为了避免用例冗余,新增页只测新增的校验、异常、成功与闭环;列表页只测渲染、搜索、分页、编辑、删除。这样每个文件的职责单一,维护时改动范围可控。
④ 每类业务场景都有对应层次的断言
成功类用例不止断言跳转,还要验证数据闭环;异常类用例通过 mock 稳定复现;列表交互类用例同时断言请求参数和表格内容。
五、核心设计详解
5.1 pytest-playwright 插件二次开发
为什么要二开? 官方 pytest-playwright 插件虽然提供了 --screenshot、--video、--tracing 参数,但截图和视频只是保存到本地目录,不会自动附加到 Allure 报告中。每次失败后需要手动去 test-results/ 目录找文件,体验很差。
怎么做的?
- 在
pytest.ini中通过-p no:playwright禁用官方插件,由本地插件接管:
addopts = -p no:playwright
--tracing=retain-on-failure
--screenshot=only-on-failure
--video=retain-on-failure
- 将官方插件源码复制到
plugins/pytest_playwright.py,在context/pagefixture 的 teardown 阶段增加 Allure 附件挂载逻辑:
# 失败时截图,并附加到 Allure 报告
if capture_screenshot:
for index, page in enumerate(pages):
screenshot_path = _build_artifact_test_folder(...)
page.screenshot(timeout=5000, path=screenshot_path)
allure.attach.file(
screenshot_path,
name=f"{request.node.name}-{status}-{index + 1}",
attachment_type=allure.attachment_type.PNG,
)
视频同理,以 WEBM 格式附加。这样在 Allure 报告中,每个失败用例都能直接看到截图和录屏,不需要离开报告页面。
- 通过
conftest.py中的pytest_plugins = ['plugins.pytest_playwright']注册本地插件。
效果:失败用例在 Allure 报告中自带截图 + 录屏 + Trace。

5.2 多 BrowserContext 隔离设计
为什么需要三套上下文? 这是由被测系统的业务特点决定的:
- 大部分业务用例(项目/模块/环境)需要已登录状态,登录一次复用 cookie 可以大幅提速;
- 登录/注册用例需要未登录状态,否则全局 cookie 会把登录页直接重定向到首页;
- 多账号协作用例需要第二个账号(admin)的独立会话。
Playwright 的 BrowserContext 相当于一个独立的浏览器会话,cookie、localStorage、缓存完全隔离。我利用这个特性设计了三套上下文:
| Fixture | 作用域 | 用途 |
|---|---|---|
login_prepare + page |
session | 全局登录一次,大部分业务用例复用登录态 |
unlogin_context + unlogin_page |
module | 登录/注册页面专用,避免已登录 cookie 导致跳转首页 |
admin_context |
module | 管理员账号,用于多账号权限测试 |
关键代码(cases/conftest.py):
@pytest.fixture(scope="session")
def login_prepare(context, base_url):
"""全局登录一次,所有业务用例复用登录态"""
page = context.new_page()
LoginPage(page).navigate()
LoginPage(page).login(os.getenv("TEST_USER"), os.getenv("TEST_PWD"))
page.wait_for_url(url="**/index.html")
@pytest.fixture(scope="module")
def unlogin_context(browser, browser_context_args):
"""独立上下文,不加载全局登录态,专用于登录/注册测试"""
context = browser.new_context(**browser_context_args)
yield context
context.close()
设计取舍:登录为什么做成 session 级共享?——登录一次复用 cookie,避免每条用例重复登录,回归提速;注册/登录页为什么用独立 context?——避免全局 cookie 把未登录页面直接跳转到首页,保证被测页面纯净。
多账号协作场景(test_more_accounts/test_admin.py):A 账号创建项目 → B 账号(admin)删除项目,真实模拟了团队协作与权限控制:
def test_delete_project(self):
# 账号1:新增项目
self.user1_project.fill_project_name(test_project_name)
with self.user1_project.page.expect_navigation(url="**/list_project.html"):
self.user1_project.click_submit_btn()
# 账号2(admin):搜索并删除
self.user2_project.search_project_fill(test_project_name)
self.user2_project.locator_table_delete.click()
with self.user2_project.page.expect_response("**/api/project**") as resp:
self.user2_project.locator_bootbox_accept.click()
assert resp.value.status == 200
5.3 API Mock 拦截:让异常分支不依赖后端
业务背景:被测系统里"名称重复(400)、无权限删除(403)、服务器异常(500)"这些异常分支,在真实环境里很难稳定构造——重复名称要等真实数据、无权限要换账号、500 要等后端出故障。用 Playwright 的 page.route() 拦截 API 请求返回自定义响应,就能让这些分支稳定复现、可重复执行:
- 测试前端对 400/403/500 等异常状态的处理
- 构造特定数据场景(如搜索 0 条结果、列表 10 条数据)
- 不依赖后端环境和测试数据
Mock 数据集中管理在 mocks/mock_api.py,每个 Mock 是一个字典,包含 URL 匹配模式和 handler:
mock_project_400 = {
"url": "**/api/project",
"handler": lambda route: route.fulfill(
status=400,
body=json.dumps({
"errors": {"project_name": "test 已存在"},
"message": "Input payload validation failed",
}),
),
}
用例中使用:
def test_add_project_repeat_400(self):
"""项目已存在,400 状态码"""
self.add_project.fill_project_name("test")
self.add_project.page.route(**mock_api.mock_project_400) # 拦截
self.add_project.click_submit_btn()
expect(self.add_project.locator_bootbox).to_contain_text("已存在")
踩坑记录:URL 匹配模式要精确。删除项目接口是
/api/project/{id},必须写成"**/api/project/**",如果写成"**/api/project**"会错误匹配到列表接口。
5.4 数据驱动与表单校验去重
测试数据与用例分离,统一放在 cases/common/validation_data.py:
FORM_VALIDATION_CASES = [
("username", "", "不能为空"),
("username", "x" * 31, "1-30位字符"),
("username", "daij@", "不能有特殊字符"),
("password", "", "不能为空"),
("password", "123", "6-16位字符"),
# ...
]
用例通过 @pytest.mark.parametrize 驱动,一组数据覆盖空值、长度边界、特殊字符等典型校验场景:
@pytest.mark.parametrize("field, value, keyword", FORM_VALIDATION_CASES)
def test_form_validation(self, field, value, keyword):
tip_text = self.login.fill_invalid_and_get_tip(field, value)
assert keyword in tip_text
expect(self.login.locator_login_btn).not_to_be_enabled()
5.5 AJAX 请求/响应断言
除了 UI 层面的断言,还可以直接捕获异步请求进行接口级校验:
def test_login_ajax_request(self):
with self.login.page.expect_request("**/api/login") as req:
self.login.click_login_btn()
assert req.value.method == "POST"
assert req.value.post_data_json == {"username": os.getenv("TEST_USER"), "password": os.getenv("TEST_PWD")}
这在调试"点了按钮但请求没发出去"或"请求参数不对"的问题时非常有用。
5.6 三层断言 + 数据闭环
我把断言分成三个层次:
| 层级 | 断什么 | 典型写法 | 用在哪些场景 |
|---|---|---|---|
| UI 界面层 | 提示文案、按钮禁用态、页面跳转 | to_contain_text / not_to_be_enabled / to_have_url |
表单校验、成功跳转 |
| 接口层 | 请求参数、method、响应码、Mock 异常 | expect_request / expect_response / page.route |
搜索、分页、400/403/500 异常分支 |
| 数据闭环层 | 新增后可查、编辑后回显、删除后消失 | expect(表格行).to_contain_text(记录) |
各业务模块的"成功"用例 |
为什么要做数据闭环? 很多 UI 用例断言"提示成功 + 跳转页面"就结束了,但真实项目里存在"前端提示成功、后端没落库"的隐蔽 Bug。以本项目为例:新增项目成功后,我会回到项目列表页验证这条记录真实出现;删除后确认该行消失。
我的设计:成功类用例至少覆盖"UI 层 + 数据层"两层;搜索/分页类覆盖"UI 层 + 接口层";异常类覆盖"UI 层 + 接口层(Mock)"。关键链路(新增 → 列表查询)三层全通。
六、CI/CD 闭环
6.1 触发方案选型与踩坑
最初我计划使用 Gitee Webhook 实现"代码推送实时触发流水线",但实际踩了两个坑:
- Jenkins CSRF 403:Gitee 的 Webhook 请求返回
403 No valid crumb was included in the request,因为 Jenkins 默认开启跨站请求伪造防护,外部回调需要携带 crumb 令牌,而 Gitee Webhook 不提供。 - 公网暴露安全风险:Webhook 要求 Gitee 能访问 Jenkins 服务器,需要开放公网入站端口(如 8080),安全风险高。
权衡后的选择:放弃 Webhook,改用 Poll SCM 轮询——Jenkins 每 3 分钟主动检查 Gitee 仓库是否有新提交,有变更才触发构建。
| 方案 | 实时性 | 外网要求 | 坑点 |
|---|---|---|---|
| Gitee Webhook | push 后实时 | 需开放公网端口 | CSRF 403、安全组、密钥 |
| Poll SCM 轮询 | 最多延迟 3 分钟 | 不需要 | 几乎无坑,调试首选 |
6.2 Jenkins Pipeline
使用 Playwright 官方 Docker 镜像 mcr.microsoft.com/playwright/python:v1.48.0-focal,容器自带完整浏览器依赖,屏蔽本地与服务器环境差异:
pipeline {
agent any
stages {
stage("playwright-demo") {
agent {
docker {
image 'mcr.microsoft.com/playwright/python:v1.48.0-focal'
customWorkspace "workspace/docker-demo-play" // 固定工作目录
}
}
steps {
sh 'pip install -r requirement.txt --index-url https://pypi.tuna.tsinghua.edu.cn/simple/ --default-timeout=120'
sh 'python run.py'
}
}
}
post {
always {
// 从 customWorkspace 拷贝报告到当前 job workspace,供 Allure 解析
sh 'cp -rf ./../docker-demo-play/reports ${WORKSPACE}/reports'
allure includeProperties: false,
jdk: '',
resultPolicy: 'LEAVE_AS_IS',
results: [[path: 'reports']]
}
}
}
踩坑记录:Jenkins 的
workspace目录不是安装即存在,任务至少执行过一次构建才会生成。
6.3 钉钉通知
测试完成后,通过 pytest_terminal_summary 钩子收集结果,自动发送钉钉群消息:

6.4 GitHub → Gitee 自动镜像
通过 GitHub Actions,代码 push 到 main 分支时自动同步到 Gitee,兼顾国内外访问速度。国内服务器拉取 Gitee 更快、更稳定:
name: Mirror to Gitee
on:
push:
branches: [ main, master ]
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Mirror code to gitee
uses: Yikun/hub-mirror-action@master
env:
GITEE_REPO: DJawsl/playwright-web-auto-framework
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
七、测试覆盖与成果
7.1 覆盖维度(按业务域)
- 认证域:注册(成功/重复/跳转)、登录(成功/错误凭证/跳转)、公共表单校验(一份数据驱动两页)
- 项目域:新增(规则校验/400/500/成功/闭环)、列表(渲染/搜索/分页/弹窗/删除权限/编辑回显)
- 模块域:新增(必填/重复/成功/闭环)、列表(渲染/组合搜索/分页/刷新复位)
- 环境域:新增(名称校验/地址校验/重复/成功闭环/模态框交互)
- 协作域:多账号创建 + 删除的权限场景
7.2 执行结果


八、不足与改进方向
坦诚地说,这个框架还有不少可以改进的地方:
- 敏感信息硬编码:早期代码把钉钉 access_token、测试账号密码写在源码里,存在泄露风险。
改造方案:已规划迁移到.env本地配置文件,.env加入.gitignore不提交版本库;CI 流水线通过 Jenkins 凭证(Credentials)注入环境变量,Jenkinsfile 中不写明文。 - 并行执行:目前串行执行,用例量增大后可引入
pytest-xdist并行,但需要解决 context 隔离和报告合并问题。 - 元素定位维护:部分定位器使用 XPath(如
//table[@id="table"]//td[3]/a),前端结构变化时容易失效,应尽量迁移到语义化定位。 - 数据清理:新增项目/模块后没有自动清理,长期运行会产生垃圾数据,应增加 teardown 或接口级数据清理。
- 部分硬编码等待:个别用例仍使用
wait_for_timeout,后续应替换为 Playwright 事件驱动等待(expect_response/ 元素状态),进一步降低 flaky。 - 移动端测试:Playwright 支持设备模拟,后续可以扩展移动端 H5 页面测试。

浙公网安备 33010602011771号