项目详解:Web UI 自动化测试框架

基于 Playwright + Pytest + Allure 的 Web UI 自动化测试框架
目标站点:http://47.116.12.183/(一个测试管理平台)


目录

  1. 总体架构概览
  2. 目录结构与职责
  3. 模块关联图
  4. 模块详解与关联实现
  5. 核心数据流追踪
  6. 设计原则与最佳实践

1. 总体架构概览

本框架采用 Page Object Model (POM) 设计模式,结合 Pytest fixture 依赖注入机制,将页面元素、测试逻辑、基础设施(浏览器生命周期、报告生成)解耦为独立模块。模块间通过 Pytest 的 fixture 链 和 Python import 建立关联。

┌─────────────────────────────────────────────────────────┐
│                    run.py (入口)                          │
│  pytest.main(['--alluredir', './reports'])               │
└──────────────┬──────────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────────┐
│                  pytest.ini (默认参数)                     │
│  headed + tracing + screenshot + video + base-url        │
└──────────────┬──────────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────────┐
│              conftest.py (根级配置)                        │
│  ┌─────────────────────────────────────────────────────┐ │
│  │ ① 注册 plugins/ 下的两个自定义插件                     │ │
│  │ ② 覆写 browser_context_args (最大化、无viewport)     │ │
│  │ ③ pytest_runtest_call → 动态 Allure 标记             │ │
│  └─────────────────────────────────────────────────────┘ │
└──────────────┬──────────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────────┐
│              plugins/ (基础设施层)                         │
│  ┌───────────────────┐   ┌────────────────────────────┐ │
│  │ pytest_playwright  │   │ pytest_base_url_plugin     │ │
│  │ (浏览器生命周期)    │   │ (base URL 解析)            │ │
│  │                   │   │                            │ │
│  │ playwright(session)│   │ base_url ← CLI/env/ini    │ │
│  │  → browser(session)│   │ _verify_url → ping 目标   │ │
│  │    → context(sess) │   └────────────────────────────┘ │
│  │      → page(func) │                                    │
│  └───────────────────┘                                    │
└──────────────┬──────────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────────┐
│            cases/conftest.py (业务fixture层)              │
│  ┌─────────────────────────────────────────────────────┐ │
│  │ login_first(session): 全局登录一次 (py/123456)       │ │
│  │ unlogin_context(module): 无cookie新上下文             │ │
│  │ unlogin_page(function): 无cookie页面 + 截图+视频      │ │
│  └─────────────────────────────────────────────────────┘ │
└──────────────┬──────────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────────┐
│  测试用例层 (cases/test_*.py)                             │
│  ┌─────────────────────────────────────────────────────┐ │
│  │ 每个测试类 = 一个 fixture(start_for_each)             │ │
│  │  + N 个 test_* 方法                                  │ │
│  │ 依赖: unlogin_page 或 login_first + page              │ │
│  │ 调用: pages/*.Page 的方法                             │ │
│  │ 断言: playwright.expect() + Allure 动态标记           │ │
│  └─────────────────────────────────────────────────────┘ │
└──────────────┬──────────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────────┐
│  页面对象层 (pages/*.py)                                  │
│  ┌─────────────────────────────────────────────────────┐ │
│  │ LoginPage / RegisterPage / AddProjectPage / ...     │ │
│  │ __init__(page) → 定义所有定位器                       │ │
│  │ navigate() → goto 页面                               │ │
│  │ 业务方法 → fill / click / select_option              │ │
│  └─────────────────────────────────────────────────────┘ │
└──────────────┬──────────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────────┐
│  Mock 层 (mocks/mock_api.py)                             │
│  提供路由拦截字典 → page.route(**dict)                    │
│  模拟 HTTP 200/400/403/500 响应                          │
└─────────────────────────────────────────────────────────┘

2. 目录结构与职责

项目根目录/
│
├── run.py                        # 【入口】执行 pytest + 生成 Allure 报告
├── pytest.ini                    # 【配置】pytest 默认参数
├── conftest.py                   # 【根fixture】注册插件 + 覆写浏览器参数
├── requirements.txt              # 依赖库
├── README.md                     # 说明文档
│
├── plugins/                      # 【基础设施】自定义 pytest 插件
│   ├── __init__.py
│   ├── pytest_playwright.py      # 浏览器生命周期 (session级)
│   └── pytest_base_url_plugin.py # base URL 解析
│
├── pages/                        # 【POM】页面对象模型
│   ├── __init__.py
│   ├── login_page.py             # 登录页
│   ├── register_page.py          # 注册页
│   ├── add_project_page.py       # 新增项目页
│   ├── add_module_page.py        # 新增模块页
│   ├── project_list_page.py      # 项目列表页
│   └── list_env_page.py          # 环境列表页
│
├── cases/                        # 【测试用例】
│   ├── __init__.py
│   ├── conftest.py               # 业务 fixture (login_first / unlogin_*)
│   ├── test_login.py             # 登录功能测试
│   ├── test_register.py          # 注册功能测试
│   ├── test_add_project.py       # 新增项目测试
│   ├── test_add_module.py        # 新增模块测试
│   ├── test_project_list.py      # 项目列表测试
│   ├── test_env_list.py          # 环境管理测试
│   └── more_accounts/            # 多账号场景
│       ├── conftest.py           # admin_context fixture
│       └── test_x.py             # 多账号协作测试
│
├── mocks/                        # 【Mock】API 路由拦截数据
│   ├── __init__.py
│   └── mock_api.py               # 11 个 mock 字典
│
├── reports/                      # Allure 结果临时文件
├── test-results/                 # 截图/视频/trace 文件
└── allure_report/                # 生成的 HTML 报告

3. 模块关联图

以下展示各模块之间的导入关系和fixture 依赖关系:

  ┌────────────────────────────────────────────────────────────────────┐
  │                          run.py                                    │
  │   pytest.main(['--alluredir', './reports'])                       │
  └──────────────┬─────────────────────────────────────────────────────┘
                 │ 启动 pytest
                 ▼
  ┌────────────────────────────────────────────────────────────────────┐
  │  pytest.ini                                                        │
  │  addopts = --headed --tracing=retain-on-failure                    │
  │            --screenshot=only-on-failure --video=retain-on-failure  │
  │            --base-url=http://47.116.12.183                         │
  │  log_cli = true                                                    │
  └──────────────┬─────────────────────────────────────────────────────┘
                 │ pytest 读取 ini 配置, 注入到 config
                 ▼
  ┌────────────────────────────────────────────────────────────────────┐
  │  conftest.py (根)                                                  │
  │  ╔══════════════════════════════════════════════════════════════╗  │
  │  ║ ① pytest_plugins = [                                       ║  │
  │  ║     'plugins.pytest_playwright',       ← 注册自定义插件     ║  │
  │  ║     'plugins.pytest_base_url_plugin'                        ║  │
  │  ║   ]                                                         ║  │
  │  ║                                                             ║  │
  │  ║ ② def pytest_runtest_call(item):         ← Hook: 每个测试   ║  │
  │  ║     allure.dynamic.feature(类__doc__)    ← 自动设 feature   ║  │
  │  ║     allure.dynamic.title(方法__doc__)    ← 自动设 title     ║  │
  │  ║                                                             ║  │
  │  ║ ③ def browser_type_launch_args(...):    ← 覆写浏览器参数    ║  │
  │  ║     return {"args": ['--start-maximized'], **原有参数}       ║  │
  │  ║                                                             ║  │
  │  ║ ④ def browser_context_args(...):         ← 覆写上下文参数   ║  │
  │  ║     return {"no_viewport": True,                            ║  │
  │  ║             "ignore_https_errors": True,                    ║  │
  │  ║             **原有参数}                                      ║  │
  │  ╚══════════════════════════════════════════════════════════════╝  │
  └──────────────┬─────────────────────────────────────────────────────┘
                 │ pytest_plugins 注册, 触发 Fixture 链
                 ▼
  ┌────────────────────────────────────────────────────────────────────┐
  │  plugins/pytest_playwright.py             plugins/pytest_base_url  │
  │  ╔══════════════════════════════════════╗  ╔══════════════════════╗ │
  │  ║ Fixture 链 (session 级)              ║  ║ base_url(session)   ║ │
  │  ║                                     ║  ║ ← --base-url CLI     ║ │
  │  ║ ① pytest_addoption → 注册CLI参数    ║  ║ ← base_url ini 值   ║ │
  │  ║                                     ║  ║ ← PYTEST_BASE_URL   ║ │
  │  ║ ② playwright(session)               ║  ║        env           ║ │
  │  ║    sync_playwright().start()         ║  ╚══════════════════════╝ │
  │  ║                                     ║                            │
  │  ║ ③ browser_type(session)              ║                            │
  │  ║    getattr(playwright, browser_name) ║                            │
  │  ║                                     ║                            │
  │  ║ ④ launch_browser(session)            ║                            │
  │  ║    browser_type.launch(**args)       ║                            │
  │  ║                                     ║                            │
  │  ║ ⑤ browser(session)                   ║                            │
  │  ║    launch_browser() → yield → close  ║                            │
  │  ║                                     ║                            │
  │  ║ ⑥ browser_context_args(session)       ║                            │
  │  ║    base_url + video_dir 整合         ║                            │
  │  ║                                     ║                            │
  │  ║ ⑦ context(session) ★★★              ║                            │
  │  ║    browser.new_context()             ║                            │
  │  ║    + tracing.start()                 ║                            │
  │  ║    + teardown: 截图→allure           ║                            │
  │  ║    + teardown: 视频→allure           ║                            │
  │  ║                                     ║                            │
  │  ║ ⑧ page(function) ★★★                ║                            │
  │  ║    context.new_page()                ║                            │
  │  ║    + teardown: 截图→allure           ║                            │
  │  ║    + teardown: 视频→allure           ║                            │
  │  ╚══════════════════════════════════════╝                            │
  └──────────────┬─────────────────────────────────────────────────────┘
                 │ context 和 page 被 cases/conftest 和测试用例 consume
                 ▼
  ┌────────────────────────────────────────────────────────────────────┐
  │  cases/conftest.py                                                │
  │  ╔══════════════════════════════════════════════════════════════╗  │
  │  ║ ★ login_first(session)                                      ║  │
  │  ║   依赖: context(session) ← 来自插件                           ║  │
  │  ║   行为: context.new_page() → LoginPage.login("py","123456")  ║  │
  │  ║         → 等待跳转 /index.html → 页面关闭                    ║  │
  │  ║   结果: context 中种下了登录 cookie                          ║  │
  │  ║                                                             ║  │
  │  ║ ★ unlogin_context(module)                                   ║  │
  │  ║   依赖: browser(session) + browser_context_args(session)     ║  │
  │  ║   行为: browser.new_context(**browser_context_args)          ║  │
  │  ║   结果: 独立的上下文,无任何 cookie                          ║  │
  │  ║                                                             ║  │
  │  ║ ★ unlogin_page(function)                                    ║  │
  │  ║   依赖: unlogin_context(module)                              ║  │
  │  ║   行为: unlogin_context.new_page() → yield → teardown        ║  │
  │  ║   特点: 自身实现了截图+视频→allure (与插件重复但必要)         ║  │
  │  ╚══════════════════════════════════════════════════════════════╝  │
  └──────────────┬─────────────────────────────────────────────────────┘
                 │ 测试用例依赖这些 fixture
                 ▼
  ┌────────────────────────────────────────────────────────────────────┐
  │  测试用例 (cases/test_*.py)                                       │
  │                                                                   │
  │  ┌─ TestLogin ──────────────────────────────────────────────────┐ │
  │  │  @pytest.fixture(autouse=True)                               │ │
  │  │  def start_for_each(self, unlogin_page: Page):      ← 依赖   │ │
  │  │      self.login = LoginPage(unlogin_page)  ← import pages    │ │
  │  │      self.login.navigate()                                  │ │
  │  │      yield                                                   │ │
  │  │                                                             │ │
  │  │  def test_login_success(self):                              │ │
  │  │      self.login.login("py","123456")  ← 调用 POM 方法       │ │
  │  │      expect(...).to_have_url("/index.html") ← Playwright断言 │ │
  │  └─────────────────────────────────────────────────────────────┘ │
  │                                                                   │
  │  ┌─ TestAddProject ─────────────────────────────────────────────┐ │
  │  │  def start_for_each(self, login_first, page: Page): ← 依赖   │ │
  │  │      self.add_project = AddProjectPage(page)  ← import pages │ │
  │  │      self.add_project.navigate()                             │ │
  │  │                                                              │ │
  │  │  def test_add_project_400(self, page: Page):                 │ │
  │  │      page.route(**mock_api.mock_project_400)  ← import mock │ │
  │  │      self.add_project.click_save_button()                    │ │
  │  │      expect(self.add_project.locator_boot_box)...             │ │
  │  └──────────────────────────────────────────────────────────────┘ │
  │                                                                   │
  │  ┌─ TestMoreAccounts (more_accounts/) ──────────────────────────┐ │
  │  │  def start_for_each(self, page: Page,                    ← 双依赖│ │
  │  │                       admin_context: BrowserContext):        │ │
  │  │      self.user1 = AddProjectPage(page)          ← 用户1 POM  │ │
  │  │      page2 = admin_context.new_page()          ← 用户2 新页  │ │
  │  │      self.user2 = ProjectListPage(page2)        ← 用户2 POM  │ │
  │  └──────────────────────────────────────────────────────────────┘ │
  └────────────────────────────────────────────────────────────────────┘

4. 模块详解与关联实现

4.1 运行入口 → Pytest 核心

run.py — 启动一切的地方
import os
import pytest

if __name__ == '__main__':
    # ① 执行 pytest,结果输出到 ./reports 目录
    pytest.main(['--alluredir', './reports'])
    # ② 生成 Allure HTML 报告
    os.system('allure generate ./reports -o ./allure_report --clean')

关联方式:pytest.main() 启动后,Pytest 会自动:

  1. 读取 pytest.ini 获取默认 CLI 参数
  2. 递归查找 conftest.py(从根目录到测试目录)
  3. 注册 pytest_plugins 中声明的插件
  4. 收集测试用例并解析 fixture 依赖
  5. 按 fixture 链的顺序初始化 → 执行测试 → teardown

4.2 配置层 (pytest.ini + conftest.py)

pytest.ini
[pytest]
addopts = -p no:playwright          # 禁用官方的 pytest-playwright 插件
          -p no:base_url            # 禁用官方的 pytest-base-url 插件
          --headed                  # 显示浏览器窗口
          --tracing=retain-on-failure  # 失败时保留 trace 日志
          --screenshot=only-on-failure  # 失败时截图
          --video=retain-on-failure     # 失败时录屏
          --base-url=http://47.116.12.183  # 被测网站地址

log_cli = true
log_cli_level = info

关键点:-p no:playwright 和 -p no:base_url 禁用了官方插件,因为本项目的 plugins/ 目录下提供了定制版本(加入了 Allure 附件功能)。

conftest.py(根)
from pytest import Item
import allure
import pytest
from typing import Dict

# ★ 注册自定义插件:这是"模块关联"的关键步骤!
pytest_plugins = ['plugins.pytest_playwright', 'plugins.pytest_base_url_plugin']

def pytest_runtest_call(item: Item):
    """动态设置 Allure 标记——实现『测试类doc → feature』的关联"""
    if item.parent._obj.__doc__:
        allure.dynamic.feature(item.parent._obj.__doc__)  # 类文档 → @allure.feature
    if item.function.__doc__:
        allure.dynamic.title(item.function.__doc__)        # 方法文档 → @allure.title

@pytest.fixture(scope="session")
def browser_type_launch_args(browser_type_launch_args) -> Dict:
    """覆写插件中的同名 fixture——『参数覆写』实现关联"""
    return {"args": ['--start-maximized'], **browser_type_launch_args}

@pytest.fixture(scope="session")
def browser_context_args(browser_context_args, playwright, pytestconfig) -> Dict:
    """覆写插件中的同名 fixture"""
    return {
        "no_viewport": True,
        "ignore_https_errors": True,
        **browser_context_args
    }

关联实现:

  • 插件注册:pytest_plugins 列表让 Pytest 在启动时加载 plugins/pytest_playwright.py 和 plugins/pytest_base_url_plugin.py 中定义的所有 hook 和 fixture
  • 覆写机制:根 conftest.py 定义的同名 fixture(browser_type_launch_args、browser_context_args)会覆盖插件中的同名 fixture,因为 Pytest 的 conftest 层级更高
  • Allure 动态标记:pytest_runtest_call hook 在每个测试执行前调用,读取测试类/方法的 __doc__ 并注入到 Allure 报告中

4.3 自定义插件层 (plugins)

plugins/pytest_base_url_plugin.py — Base URL 解析
@pytest.fixture(scope="session")
def base_url(request):
    """返回 base URL —— 三级优先级"""
    config = request.config
    base_url = config.getoption("base_url") or config.getini("base_url")
    # 优先级: CLI --base-url > pytest.ini base_url > PYTEST_BASE_URL env
    if base_url is not None:
        return base_url

@pytest.fixture(scope="session", autouse=True)
def _verify_url(request, base_url):
    """自动验证 base_url 可达性(需 --verify-base-url 开启)"""
    verify = request.config.option.verify_base_url
    if base_url and verify:
        import requests
        session = requests.Session()
        retries = Retry(backoff_factor=0.1, status_forcelist=[500, 502, 503, 504])
        session.mount(base_url, HTTPAdapter(max_retries=retries))
        session.get(base_url)  # 如果连不通会抛异常

def pytest_addoption(parser):
    parser.addini("base_url", help="base url for the application under test.")
    parser.addoption("--base-url", default=os.getenv("PYTEST_BASE_URL", None), ...)

关联实现:

  • 该插件的 base_url fixture 被 pytest_playwright.py 中的 browser_context_args 消费(base_url 被传入 context_args["base_url"])
  • 也被 cases/conftest.py 中的 login_first 和 unlogin_context 消费
plugins/pytest_playwright.py — 浏览器生命周期(核心引擎)

这是框架的心脏,完整 fixture 链如下:

① playwright (session) — 启动 Playwright 引擎
@pytest.fixture(scope="session")
def playwright() -> Generator[Playwright, None, None]:
    pw = sync_playwright().start()  # 启动 Playwright 进程
    yield pw
    pw.stop()
② browser_type (session) — 选择浏览器类型
@pytest.fixture(scope="session")
def browser_type(playwright: Playwright, browser_name: str) -> BrowserType:
    return getattr(playwright, browser_name)  # playwright.chromium / .firefox / .webkit
③ browser_type_launch_args (session) — 浏览器启动参数
@pytest.fixture(scope="session")
def browser_type_launch_args(pytestconfig: Any) -> Dict:
    launch_options = {}
    headed_option = pytestconfig.getoption("--headed")
    if headed_option:
        launch_options["headless"] = False  # 非无头模式
    slowmo_option = pytestconfig.getoption("--slowmo")
    if slowmo_option:
        launch_options["slow_mo"] = slowmo_option  # 操作慢放(毫秒)
    return launch_options
④ browser (session) — 启动浏览器实例
@pytest.fixture(scope="session")
def browser(launch_browser: Callable[[], Browser]) -> Generator[Browser, None, None]:
    browser = launch_browser()  # 实际调用 browser_type.launch()
    yield browser
    browser.close()
    artifacts_folder.cleanup()  # 清空临时文件
⑤ context (session) ★★★ — 浏览器上下文(含 Allure 增强)

这是最重要的 fixture之一,管理 tracing、截图和视频:

@pytest.fixture(scope="session")
def context(browser, browser_context_args, pytestconfig, request):
    pages: List[Page] = []
    context = browser.new_context(**browser_context_args)  # 创建上下文
    context.on("page", lambda page: pages.append(page))    # 监听所有新页面

    # ★ 启动 tracing(浏览器操作录制)
    tracing_option = pytestconfig.getoption("--tracing")
    capture_trace = tracing_option in ["on", "retain-on-failure"]
    if capture_trace:
        context.tracing.start(name=slugify(request.node.nodeid),
                              screenshots=True, snapshots=True, sources=True)

    yield context  # ← 测试在此执行

    # ★★ Teardown:判断失败状态
    failed = request.node.rep_call.failed if hasattr(request.node, "rep_call") else True

    # ★ 保存 trace 文件
    if capture_trace:
        retain_trace = tracing_option == "on" or (failed and tracing_option == "retain-on-failure")
        if retain_trace:
            context.tracing.stop(path=trace_path)
        else:
            context.tracing.stop()

    # ★★ 截图 → Allure 附件 (PNG)
    screenshot_option = pytestconfig.getoption("--screenshot")
    capture_screenshot = screenshot_option == "on" or (failed and screenshot_option == "only-on-failure")
    if capture_screenshot:
        for index, page in enumerate(pages):
            page.screenshot(timeout=5000, path=screenshot_path)
            allure.attach.file(screenshot_path,
                               name=f"...{human_readable_status}-{index+1}",
                               attachment_type=allure.attachment_type.PNG)

    context.close()

    # ★★ 视频 → Allure 附件 (WEBM)
    video_option = pytestconfig.getoption("--video")
    preserve_video = video_option == "on" or (failed and video_option == "retain-on-failure")
    if preserve_video:
        for page in pages:
            video = page.video
            if video:
                video.save_as(path=file_path)
                allure.attach.file(file_path, ...,
                                   attachment_type=allure.attachment_type.WEBM)
⑥ page (function) ★★★ — 每个测试函数一个页面
@pytest.fixture
def page(context, pytestconfig, request) -> Generator[Page, None, None]:
    pages: List[Page] = []
    context.on("page", lambda page: pages.append(page))
    page = context.new_page()
    yield page
    # ★★ Teardown:与 context 几乎相同的截图+视频→Allure 逻辑
    # (因为 page 是 function 级,context 是 session 级,
    #  只有最后一个测试能触发 context 的 teardown,
    #  所以每个测试自己的 teardown 需要单独处理截图)
    failed = ...
    if capture_screenshot:
        for page in pages:
            page.screenshot(...)
            allure.attach.file(...)
    page.close()
    if preserve_video:
        for page in pages:
            video.save_as(...)
            allure.attach.file(...)

注意:context 和 page 两个 fixture 都做了截图+视频→Allure 的处理。这是为了覆盖不同作用域——page (function 级) 确保每个测试失败都能捕获,context (session 级) 作为保底。


4.4 页面对象层 (pages)

所有 Page 类遵循统一模式,没有基类继承,通过构造函数注入 Page 对象来建立关联。

模式模板
from playwright.sync_api import Page

class SomePage:
    def __init__(self, page: Page):
        self.page = page
        # ★ 所有定位器在构造函数中预定义(eager initialization)
        self.locator_xxx = page.get_by_label("标签名:")
        self.locator_yyy = page.locator('selector')

    def navigate(self):
        self.page.goto("/some_page.html")  # base_url 自动拼接

    def do_something(self, value):
        self.locator_xxx.fill(value)
LoginPage (login_page.py) — 登录页
class LoginPage:
    def __init__(self, page: Page):
        self.page = page
        # ★ 通过标签文本定位(get_by_label)
        self.locator_username = page.get_by_label("用 户 名:")
        self.locator_password = page.get_by_label("密     码:")
        self.locator_login_btn = page.locator('text=立即登录')
        # ★ 表单校验提示:通过 data-fv-* 属性定位
        self.locator_username_tip1 = page.locator(
            '[data-fv-validator="notEmpty"][data-fv-for="username"]')
        self.locator_username_tip2 = page.locator(
            '[data-fv-validator="stringLength"][data-fv-for="username"]')
        self.locator_username_tip3 = page.locator(
            '[data-fv-validator="regexp"][data-fv-for="username"]')
        # 错误文案
        self.locator_login_error = page.locator('text=账号或密码不正确!')

    def login(self, username, password) -> None:
        """完整登录操作"""
        self.locator_username.fill(username)
        self.locator_password.fill(password)
        self.locator_login_btn.click()

关联方式:page(Playwright 的 Page 对象)由插件层的 page fixture 或 cases/conftest.py 的 unlogin_page fixture 提供,通过构造函数传入 Page 类中。

AddProjectPage (add_project_page.py) — 新增项目页
class AddProjectPage:
    def __init__(self, page: Page):
        self.page = page
        self.locator_project_name = page.get_by_label("项目名称:")
        self.locator_publish_app = page.get_by_label("所属应用:")
        self.locator_project_desc = page.get_by_label('项目描述:')
        self.locator_save_button = page.get_by_text('点击提交')
        self.locator_boot_box = page.locator('.bootbox-body')  # 弹框

    def input_project(self, name: str, app: str, desc: str) -> None:
        """批量填充项目信息"""
        self.locator_project_name.fill(name)
        self.locator_publish_app.fill(app)
        self.locator_project_desc.fill(desc)
ProjectListPage (project_list_page.py) — 项目列表页(最复杂)
class ProjectListPage:
    def __init__(self, page: Page):
        self.page = page
        self.locator_add_project = page.get_by_role("button", name="新增项目")
        self.locator_search_project = page.get_by_placeholder("项目名称")
        # ★ 模态框内的定位器:先定位模态框容器,再在其中查找
        self.locator_add_modal = page.locator('#addModal')
        self.locator_modal_project_name = self.locator_add_modal.get_by_label("项目名称:")
        self.locator_modal_save = self.locator_add_modal.get_by_text('保存')
        self.locator_modal_dismiss = self.locator_add_modal.get_by_text("取消")
        # ★ XPath 定位表格行
        self.locator_table_tr = page.locator('//tbody/tr')
        self.locator_table_edit = self.locator_table_tr.get_by_title('编辑')
        self.locator_table_delete = self.locator_table_tr.get_by_title('删除')
        # 删除确认框
        self.locator_boot_box_accept = page.get_by_text("确定删除")
        self.locator_boot_box_dismiss = page.get_by_text("取消操作")

4.5 测试用例层 (cases)

cases/conftest.py — 业务层 fixture
@pytest.fixture(scope="session")
def login_first(context, base_url, pytestconfig) -> None:
    """
    ★ 全局登录一次 —— 实现『认证状态复用』
    依赖: context(session) → 来自 plugins/pytest_playwright.py
          base_url(session) → 来自 plugins/pytest_base_url_plugin.py
    作用: 在 session 级的上下文里执行一次登录,之后的 page 都自带 cookie
    """
    page = context.new_page()
    LoginPage(page).navigate()         # → goto /login.html
    LoginPage(page).login("py", "123456")  # → fill + click
    page.wait_for_url(url='**/index.html')  # 等待登录成功跳转
    # 页面关闭,但 cookie 留在了 context 中


@pytest.fixture(scope="module")
def unlogin_context(browser, base_url, pytestconfig, browser_context_args: Dict):
    """
    ★ 创建无 cookie 的独立上下文 —— 实现『认证隔离』
    登录/注册页面需要干净的上下文,否则会被已登录 cookie 自动重定向
    """
    context = browser.new_context(**browser_context_args)  # 全新上下文
    yield context
    context.close()


@pytest.fixture
def unlogin_page(unlogin_context, pytestconfig, request):
    """
    从无 cookie 上下文创建 page,自带截图+视频捕获
    ★ 与插件 page fixture 做相同的事,但作用于 unlogin 路径
    """
    pages: List[Page] = []
    unlogin_context.on("page", lambda page: pages.append(page))
    page = unlogin_context.new_page()
    yield page
    # teardown: 截图 + 视频 → Allure(代码与插件 page fixture 相同)
    ...
    page.close()
测试用例模式

每个测试模块的结构完全一致:

class TestXxx:
    """功能名称(自动变成 @allure.feature)"""

    @pytest.fixture(autouse=True)
    def start_for_each(self, login_first, page: Page):   # ← fixture 注入
        """初始化(autouse=True 自动在每个测试前执行)"""
        self.page_obj = SomePage(page)   # ← 传入 page,建立 POM 关联
        self.page_obj.navigate()
        yield
        # 可选后置操作

    def test_xxx(self):
        """用例标题(自动变成 @allure.title)"""
        # 1. 操作
        self.page_obj.do_something()
        # 2. 断言
        expect(self.page_obj.locator_xxx).to_be_visible()

三种依赖模式:

测试类依赖的 fixture说明
TestLoginunlogin_page无 cookie,访问登录页
TestRegisterunlogin_page无 cookie,访问注册页
TestAddProjectlogin_first + page已登录,访问新增项目页
TestAddModulelogin_first + page已登录,访问新增模块页
TestProjectListlogin_first + page已登录,访问项目列表页
TestEnvListlogin_first + page已登录,访问环境管理页
TestMoreAccountspage + admin_context双用户并行操作
Mock 集成 — page.route()
from mocks import mock_api  # ★ 关联 mock 模块

class TestAddProject:
    def test_add_project_400(self, page: Page):
        # ... 填充表单 ...
        # ★★ 关键关联:page.route(**mock_dict) 拦截 API 请求
        page.route(**mock_api.mock_project_400)
        self.add_project.click_save_button()
        expect(self.add_project.locator_boot_box).to_contain_text('已存在')

关联方式:

  • mock_api.mock_project_400 是一个字典 {"url": "**/api/project", "handler": lambda route: ...}
  • page.route(**dict) 将字典解包为关键字参数,等价于 page.route(url="**/api/project", handler=lambda...)
  • Playwright 自动拦截匹配 **/api/project 的请求,执行 handler 中的 route.fulfill() 返回模拟数据
AJAX 断言 — page.expect_request() / page.expect_response()
def test_login_ajax(self):
    """捕获并断言登录的 AJAX 请求"""
    self.login.fill_username("py")
    self.login.fill_password('123456')
    # ★ 通过 context manager 捕获匹配的请求
    with self.login.page.expect_request('**/api/login') as req:
        self.login.click_login_button()
    # ★ 断言请求的详细信息
    assert req.value.method == 'POST'
    assert req.value.header_value('content-type') == 'application/json'
    assert req.value.post_data_json == {'username': 'py', 'password': '123456'}

4.6 Mock 数据层 (mocks)

mocks/mock_api.py — 纯字典式 Mock
import json

# 每个 mock 是一个字典,可直接解包给 page.route()
mock_project_400 = {
    "url": "**/api/project",                      # URL 匹配模式(支持 ** 通配)
    "handler": lambda route: route.fulfill(        # 处理函数
        status=400,
        body=json.dumps({
            "errors": {"project_name": "yo yo 已存在"},
            "message": "Input payload validation failed"
        })
    )
}

mock_project_search_1 = {
    "url": "**/api/project**",   # ★ ** 匹配任意路径和查询参数
    "handler": lambda route: route.fulfill(
        status=200,
        body=json.dumps({
            "total": 1,
            "rows": [{"id": 1, "project_name": "testzz", ...}]
        })
    )
}

关联方式:

  • mocks/ 是一个纯数据模块,不依赖其他任何模块
  • 被 cases/test_*.py 通过 from mocks import mock_api 导入
  • 测试方法中通过 page.route(**mock_dict) 注入到 Playwright 的路由拦截系统
  • 支持模拟各种 HTTP 状态码:200(成功)、400(参数校验失败)、403(无权限)、500(服务器错误)

完整 Mock 清单:

Mock 名称拦截 URL返回码用途
mock_project_400**/api/project400新增项目名称重复
mock_project_500**/api/project500新增项目服务器错误
mock_project_200**/api/project200新增项目成功
mock_project_search_0**/api/project**200(空)搜索0条结果
mock_project_search_1**/api/project**200(1条)搜索1条结果
mock_project_delete_403**/api/project**403删除无权限
mock_project_select_200**/api/project200(4条)模块选项列表
mock_add_module_400**/api/module400新增模块名称重复
mock_add_module_200**/api/module200新增模块成功
mock_add_env_400**/api/env400新增环境名称重复
mock_add_env_200**/api/env200新增环境成功

4.7 多账号机制 (cases/more_accounts/)

cases/more_accounts/conftest.py
@pytest.fixture(scope="module")
def admin_context(browser, base_url, pytestconfig, browser_context_args):
    """
    创建 admin 账号的独立上下文
    依赖: browser(session) + browser_context_args(session)
    """
    context = browser.new_context(**browser_context_args)  # 全新上下文
    page = context.new_page()
    LoginPage(page).navigate()
    LoginPage(page).login("admin", "123456")  # 登录为 admin
    page.wait_for_url(url='**/index.html')
    yield context
    context.close()
cases/more_accounts/test_x.py — 双用户协作场景
class TestMoreAccounts:
    @pytest.fixture(autouse=True)
    def start_for_each(self, page: Page, admin_context: BrowserContext):
        """同时获得两个账号的页面对象"""
        # 用户1 (py) — 使用默认 page fixture(已登录)
        self.user1_project = AddProjectPage(page)
        self.user1_project.navigate()
        # 用户2 (admin) — 从 admin_context 创建新页面
        page2 = admin_context.new_page()
        self.user2_project = ProjectListPage(page2)
        self.user2_project.navigate()

    def test_delete_project(self):
        """测试流程: A创建项目 → B删除项目"""
        # Step1: py 用户创建项目
        test_project_name = str(uuid.uuid4()).replace('-', '')[:25]
        self.user1_project.fill_project_name(test_project_name)
        self.user1_project.fill_publish_app("xx")
        self.user1_project.fill_project_desc("xxx")
        with self.user1_project.page.expect_navigation(url="**/list_project.html"):
            self.user1_project.click_save_button()

        # Step2: admin 用户搜索并删除该项目
        self.user2_project.search_project(test_project_name)
        with self.user2_project.page.expect_request("**/api/project**"):
            self.user2_project.click_search_button()
        self.user2_project.locator_table_delete.click()
        with self.user2_project.page.expect_response("**/api/project**") as resp:
            self.user2_project.locator_boot_box_accept.click()
        assert resp.value.status == 200

关联实现:

  • page 来自插件层的 page fixture → session 级 context(已登录 py/123456)
  • admin_context 来自 more_accounts/conftest.py → 另一个独立上下文(已登录 admin/123456)
  • 两个上下文共享同一个浏览器实例(session 级 browser),但独立存储 cookie
  • 通过 context.new_page() 和 admin_context.new_page() 分别在两个上下文中创建页面

5. 核心数据流追踪

5.1 执行一个「已登录」测试的完整流程

以 TestAddProject.test_add_project_400 为例:

1. pytest 收集到 test_add_project_400
   │
2. 解析 fixture 依赖链:
   test_add_project_400
     └─ start_for_each (autouse)
          ├─ login_first (session)  ← 首次执行,后续复用
          │    ├─ context (session)
          │    │    ├─ browser (session)
          │    │    │    ├─ launch_browser (session)
          │    │    │    │    ├─ browser_type (session)
          │    │    │    │    │    └─ playwright (session)
          │    │    │    │    └─ browser_type_launch_args (session)
          │    │    │    └─ browser_context_args (session)
          │    │    │         └─ base_url (session)
          │    │    └─ pytestconfig, request
          │    └─ base_url (session)
          │
          └─ page (function)
               └─ context (session)  ← 上面已初始化

3. fixture 初始化(session级只做一次):
   a. playwright = sync_playwright().start()
   b. browser_type = playwright.chromium (默认)
   c. browser = browser_type.launch(headless=False, args=['--start-maximized'])
   d. base_url = "http://47.116.12.183" (来自 --base-url)
   e. context = browser.new_context(base_url=..., no_viewport=True, ...)
   f. context.tracing.start(...)       ← 启动录制
   g. login_first: context.new_page() → goto /login.html → login("py","123456")
                    → wait_for_url /index.html → page.close()
                    → context 中保留登录 cookie

4. 测试函数级初始化:
   h. page = context.new_page()        ← 带 cookie,已登录状态
   i. start_for_each:
        self.add_project = AddProjectPage(page)   ← 构造 POM
        self.add_project.navigate()               → goto /add_project.html

5. 执行测试方法:
   j. self.add_project.fill_project_name("yo yo")
   k. self.add_project.fill_publish_app("xx")
   l. self.add_project.fill_project_desc("xxx")
   m. page.route(**mock_api.mock_project_400)     ← 注册路由拦截
   n. self.add_project.click_save_button()         ← 点击保存
      → Playwright 检测到 POST /api/project 请求
      → 被 page.route 拦截
      → 执行 handler: route.fulfill(status=400, body=...)
      → 浏览器收到 400 响应,弹出 bootbox
   o. expect(self.add_project.locator_boot_box).to_contain_text('已存在')

6. Teardown:
   p. page.close() 之前: 截图(若失败) → allure.attach(PNG)
                    : 保存视频 → allure.attach(WEBM)
   q. (session结束): context 的 teardown: 停止 tracing, 截图, 关闭, 保存视频
   r. browser.close()
   s. playwright.stop()

5.2 从测试方法到报告的完整链路

测试方法 __doc__
     │
     ▼
conftest.py pytest_runtest_call()
     │
     ├─ allure.dynamic.feature(类__doc__)    → Allure report: "添加项目"
     └─ allure.dynamic.title(方法__doc__)     → Allure report: "项目名称重复,弹出模态框"

测试执行
     │
     ▼
     ├─ 成功 → 无截图、无视频
     ├─ 失败 → page fixture teardown
     │         ├─ page.screenshot() → PNG 文件
     │         └─ allure.attach.file(PNG) → Allure report
     │
     └─ 失败 → context fixture teardown
               ├─ tracing.stop() → trace.zip 文件
               ├─ page.screenshot() → PNG 文件 → allure.attach → Allure report
               └─ video.save_as() → WEBM 文件 → allure.attach → Allure report

run.py
     │
     ├─ pytest.main(['--alluredir', './reports'])  → 生成 ./reports/*.json
     └─ allure generate → ./allure_report/index.html

6. 设计原则与最佳实践

6.1 模块间的关联方式总结

关联类型实现机制示例
插件注册pytest_plugins = [...]根 conftest.py 注册 plugins/ 下的插件
Fixture 依赖注入fixture 函数参数名匹配def context(browser, browser_context_args)
Fixture 覆写同名 fixture 在优先级更高的 conftest 中重定义根 conftest 覆写 browser_context_args
POM 实例化构造函数注入 Page 对象LoginPage(page)
Mock 注入page.route(**dict) 解包page.route(**mock_api.mock_project_400)
Allure 动态标记Hook pytest_runtest_callallure.dynamic.feature(类.__doc__)
模块导入Python importfrom pages.login_page import LoginPage

6.2 关键设计决策

  1. 无 Page 基类:每个 Page 类是独立的,不继承任何基类。优点是简单直观、无耦合;缺点是公共方法(如 navigate)需要重复实现。

  2. Session 级浏览器:浏览器和上下文是 session 作用域,所有测试共用。通过 login_first 在 session 开始时登录一次,后续测试无需重复登录。

  3. 双认证路径:

    • login_first + page:需要登录的页面(项目管理、模块管理、环境管理)
    • unlogin_context + unlogin_page:认证页面(登录、注册)

    这种分离避免了已登录 cookie 导致登录/注册页自动重定向的问题。

  4. 自定义插件替代官方:不使用 pytest-playwright 和 pytest-base-url 的官方包,而是使用定制版本(增加 Allure 附件功能)通过 -p no:xxx 禁用官方版本。

  5. 内联 Mock:Mock 数据是简单的 Python 字典,不依赖 Mock 服务器。通过 page.route() 在浏览器层面拦截请求,无网络开销。

6.3 局限与改进建议

问题说明建议
截图逻辑重复context、page、unlogin_page 三个 fixture 中有相同的截图+视频→Allure 代码提取为工具函数
无 Page 基类每个页面重复 navigate 等公共方法可添加 BasePage 基类
Eager 定位器所有定位器在 __init__ 中创建,即使不被使用可改为 lazy property 或 @property
硬编码地址pytest.ini 中 base_url 是硬编码的 IP 地址提取到环境变量
测试数据硬编码测试用例中的用户名密码直接写在代码里使用配置文件或 fixture

附录:Fixture 依赖图

playwright (session)
    └─ browser_type (session)
         └─ launch_browser (session)
              └─ browser (session)
                   │
                   ├─ browser_context_args (session) ← base_url (session) ← CLI/ini/env
                   │    └─ context (session) ★
                   │         ├─ login_first (session)  ← cases/conftest.py
                   │         │    └─ (在context中登录, 种cookie)
                   │         └─ page (function) ★
                   │              └─ 测试用例 (已登录)
                   │
                   └─ unlogin_context (module)  ← cases/conftest.py
                        └─ unlogin_page (function)
                             └─ 测试用例 (未登录)

★ 标记的 fixture 中包含截图/视频→Allure 的 teardown 逻辑

更多推荐