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/ 目录找文件,体验很差。

怎么做的?

  1. pytest.ini 中通过 -p no:playwright 禁用官方插件,由本地插件接管:
addopts = -p no:playwright
          --tracing=retain-on-failure
          --screenshot=only-on-failure
          --video=retain-on-failure
  1. 将官方插件源码复制到 plugins/pytest_playwright.py,在 context / page fixture 的 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 报告中,每个失败用例都能直接看到截图和录屏,不需要离开报告页面。

  1. 通过 conftest.py 中的 pytest_plugins = ['plugins.pytest_playwright'] 注册本地插件。

效果:失败用例在 Allure 报告中自带截图 + 录屏 + Trace。

image-20260913192422817

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 实现"代码推送实时触发流水线",但实际踩了两个坑:

  1. Jenkins CSRF 403:Gitee 的 Webhook 请求返回 403 No valid crumb was included in the request,因为 Jenkins 默认开启跨站请求伪造防护,外部回调需要携带 crumb 令牌,而 Gitee Webhook 不提供。
  2. 公网暴露安全风险: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 钩子收集结果,自动发送钉钉群消息:

1

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 执行结果

image-20260913200454126

image-20260913200528158


八、不足与改进方向

坦诚地说,这个框架还有不少可以改进的地方:

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