基于 Playwright + Pytest的Web UI 自动化测试框架详解
项目详解:Web UI 自动化测试框架
基于 Playwright + Pytest + Allure 的 Web UI 自动化测试框架
目标站点:http://47.116.12.183/(一个测试管理平台)
目录
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 会自动:
- 读取
pytest.ini获取默认 CLI 参数 - 递归查找
conftest.py(从根目录到测试目录) - 注册
pytest_plugins中声明的插件 - 收集测试用例并解析 fixture 依赖
- 按 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_callhook 在每个测试执行前调用,读取测试类/方法的__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_urlfixture 被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 | 说明 |
|---|---|---|
TestLogin | unlogin_page | 无 cookie,访问登录页 |
TestRegister | unlogin_page | 无 cookie,访问注册页 |
TestAddProject | login_first + page | 已登录,访问新增项目页 |
TestAddModule | login_first + page | 已登录,访问新增模块页 |
TestProjectList | login_first + page | 已登录,访问项目列表页 |
TestEnvList | login_first + page | 已登录,访问环境管理页 |
TestMoreAccounts | page + 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/project | 400 | 新增项目名称重复 |
mock_project_500 | **/api/project | 500 | 新增项目服务器错误 |
mock_project_200 | **/api/project | 200 | 新增项目成功 |
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/project | 200(4条) | 模块选项列表 |
mock_add_module_400 | **/api/module | 400 | 新增模块名称重复 |
mock_add_module_200 | **/api/module | 200 | 新增模块成功 |
mock_add_env_400 | **/api/env | 400 | 新增环境名称重复 |
mock_add_env_200 | **/api/env | 200 | 新增环境成功 |
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来自插件层的pagefixture → 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_call | allure.dynamic.feature(类.__doc__) |
| 模块导入 | Python import | from pages.login_page import LoginPage |
6.2 关键设计决策
-
无 Page 基类:每个 Page 类是独立的,不继承任何基类。优点是简单直观、无耦合;缺点是公共方法(如
navigate)需要重复实现。 -
Session 级浏览器:浏览器和上下文是 session 作用域,所有测试共用。通过
login_first在 session 开始时登录一次,后续测试无需重复登录。 -
双认证路径:
login_first + page:需要登录的页面(项目管理、模块管理、环境管理)unlogin_context + unlogin_page:认证页面(登录、注册)
这种分离避免了已登录 cookie 导致登录/注册页自动重定向的问题。
-
自定义插件替代官方:不使用
pytest-playwright和pytest-base-url的官方包,而是使用定制版本(增加 Allure 附件功能)通过-p no:xxx禁用官方版本。 -
内联 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 逻辑
更多推荐
所有评论(0)