Playwright + Python+POM Web UI 自动化框架完整方案(可落地)
当前架构:Playwright(同步 API)+ Pytest + POM(页面对象);业务步骤写在
test-data/test_cases.yaml,由tests/test_yaml_cases.py+utils/yaml_pom_engine.py调用pages/。页面定位与操作写在pages/。
一、文档索引(docs/ 里各文件是干什么的)
| 文件 | 作用 | 适合谁读 |
|---|---|---|
| FRAMEWORK_FULL_GUIDE.md(本文) | 全仓库模块说明 + 完整代码 + 从零上手 | 新人、接手维护者 |
| FRAMEWORK_GUIDE.md | 精简版框架指南(安装、运行、配置要点) | 快速查阅 |
| FRAMEWORK_USER_GUIDE.md | 偏使用侧说明(若与本文冲突以本文为准) | 业务/测试使用 |
| FRAMEWORK_MAINTAINER_GUIDE.md | 维护者视角(若与本文冲突以本文为准) | 维护者 |
| CASE_EXECUTION_FLOW_FOR_BEGINNERS.md | 执行顺序白话版(从 run.py 到断言) |
零基础理解流程 |
| YAML_CASE_ORGANIZATION.md | 管理后台 YAML 用例组织、登录、tags / smoke(与 run.py smoke)、单/多文件建议 |
写用例前必读 |
| PAGES_AND_YAML_RELATIONSHIP.md | pages/ 各文件与 YAML 绑定、占位符、逐条用例说明 |
理解关联必读 |
| PAGES_YAML_SIMPLE_EXPLAIN.md | 通俗比喻(剧本/演员/点菜)讲清 YAML 与 pages 关系 | 看不懂时先读 |
| MAINTENANCE_ADDING_TEST_CASES.md | 维护视角:如何新增/调整用例与 Page | 接需求写自动化时 |
| TEST_CASE_RULES.md | 旧版 YAML 字段约定,可与当前 test_cases.yaml 对照;以仓库内实际 YAML 与 yaml_case_loader 校验为准 |
参考 |
pages/example_orders_page.py |
Page 模板(含填写说明注释) | 写新 Page 时对照 |
test-data/example_orders_case.yaml |
与模板 Page 配套的 YAML 片段(复制进 test_cases.yaml 使用) |
写新 YAML 时对照 |
二、你需要先准备的东西
- Python 3.9+(本仓库在 3.9 / 3.11 下可用;CI 使用 3.11)。
- 本机终端(macOS / Linux / Windows 均可;以下为 macOS 写法,Windows 请自行对应
venv激活命令)。 - 网络:首次需下载 Playwright 浏览器;Allure HTML 若走 Docker 需拉镜像。
- 被测系统地址与账号:在
.env中配置(或 CI 用 Secrets,见文末)。
三、从零到跑通登录冒烟(逐步操作,不要跳步)
以下路径以仓库根目录 web-ui-automation/ 为例。
1)克隆或进入项目目录
cd /path/to/web-ui-automation
2)创建虚拟环境并安装依赖
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
3)安装 Playwright 浏览器(Chromium 等)
playwright install chromium
# 若需系统依赖(常见于 Linux CI)可用:playwright install --with-deps chromium
4)准备环境变量文件
cp .env.example .env
用编辑器打开 .env,至少填写:
BASE_URL:被测站点根地址(需带https://)LOGIN_PATH:登录页路径,一般为/loginTEST_PHONE:测试用手机号STATIC_OTP_CODE:测试环境静态验证码(若未配置MOCK_OTP_API_URL则使用该固定码)
可选:
MOCK_OTP_API_URL:若环境提供取码 API,可配置;URL 中可用{{phone}}占位POST_LOGIN_URL_CONTAINS:登录成功后要求 URL 必须包含的子串(用于用例里额外断言)TEST_ENV:dev/qa/prod,决定合并哪份config/<env>.jsonHEADLESS:true/false是否无头浏览器
5)选择运行方式(任选其一)
推荐(统一清理报告并尝试生成 Allure HTML):
python run.py smoke
或直接 pytest:
pytest -m smoke
6)看结果
- pytest-html:
reports/report.html(自包含 HTML) - Allure 原始数据:
reports/allure-results/(JSON 等) - Allure 可视化报告:
reports/allure-html/index.html(需allure generate成功;run.py smoke会在 pytest 后自动尝试;无 Java 时可依赖 Docker,见下文「报告说明」)
打开 Allure(若已生成 index.html):
python run.py allure-open
四、仓库目录树(当前有效结构)
web-ui-automation/
├── .github/workflows/ui-smoke.yml # CI:安装依赖与浏览器后跑 smoke
├── config/
│ ├── base.json # 全环境公共默认
│ ├── dev.json / qa.json / prod.json
├── docs/ # 说明文档(含本文)
├── fixtures/
│ ├── __init__.py
│ └── conftest.py # Pytest 夹具:浏览器、page、截图、Allure 附件
├── logs/
│ ├── framework.log
│ └── cases/ # 按用例名生成的日志(若代码中启用)
├── pages/
│ ├── __init__.py
│ ├── base_page.py # 页面基类:导航、多 selector 兜底
│ ├── login_page.py # 登录页 POM:手机号 + 验证码流程
│ ├── account_page.py # 示例业务页:侧栏进入账户管理
│ └── example_orders_page.py # 教学模板:订单列表检索(占位选择器,按需改名使用)
├── reports/
│ ├── report.html # pytest-html 产出
│ ├── allure-results/ # allure-pytest 原始结果
│ └── allure-html/ # Allure CLI/Docker 生成的可浏览报告
├── test-data/
│ ├── test_cases.yaml # YAML 业务用例(pytest 实际加载的唯一入口)
│ └── example_orders_case.yaml # 与 example_orders_page 配套的示例片段(需手动合并进 test_cases.yaml)
├── test-results/
│ ├── screenshots/ # 失败全页截图
│ └── video/ # 用例级录屏(由 context 配置)
├── tests/
│ └── test_yaml_cases.py # 读取 YAML 并执行(一般不改)
├── utils/
│ ├── __init__.py
│ ├── config_loader.py # 合并 config/*.json 与 .env
│ ├── logger.py # 全局日志
│ ├── yaml_case_loader.py # 加载 / 校验 test_cases.yaml
│ └── yaml_pom_engine.py # 按步骤调用 Page 与 builtin
├── .env / .env.example
├── .gitignore
├── conftest.py # 根 conftest:转发到 fixtures.conftest
├── pytest.ini # pytest 默认参数、markers
├── requirements.txt
├── run.py # 入口:smoke/test/setup/allure
└── README.md
五、端到端执行流程(从命令到断言)
1)文字版顺序
- 执行
python run.py smoke(或pytest -m smoke)。 run.py可选地清理旧reports/、test-results/(见run.py)。- Pytest 加载
pytest.ini,扫描tests/下test_*.py。 - 根目录
conftest.py将fixtures/conftest.py中夹具注册到 pytest。 - session 级:
load_settings()读config/*.json+.env→settings;读必填环境变量 →env_vars;启动 Playwright →browser。 - function 级:每个测试一个
context(带录屏目录)、page、api_request。 tests/test_yaml_cases.py读取test-data/test_cases.yaml,YamlPomEngine逐步构造 Page 并调用 YAML 中的call方法(如LoginPage.login_with_phone_otp)。- 断言失败时:
pytest_runtest_makereport钩子全页截图并allure.attach.file。 - 测试结束:
run.py调用generate_allure_html():先试本机 Allure/npx,失败则 Docker 镜像andgineer/allure(可用ALLURE_DOCKER_IMAGE覆盖)。
2)流程图(Mermaid)
5.3 给非开发者的「三件事」比喻
| 概念 | 在本仓库里是什么 | 你平时主要改哪里 |
|---|---|---|
| 剧本 | test-data/test_cases.yaml 里的一条用例:从上到下写「第几步调谁、传什么参数」 |
增删步骤、改 kwargs、开关 enabled、打 smoke 标签 |
| 演员 | pages/*.py 里的类(如 LoginPage):真正去点按钮、填输入框、做断言 |
页面改版时改选择器字符串和方法里的操作顺序 |
| 导演 + 剧组 | Pytest、fixtures/conftest.py、YamlPomEngine:准备浏览器、读配置、按剧本一步步调用演员 |
一般不用改;只有要加新的「内置步骤」类型时才动引擎 |
一句话:不会写 Python 的人,只要会改 YAML 就能编排流程;一旦网页上的按钮、输入框位置变了,需要会改 Page 里的选择器(或请开发同事改 pages/)。
5.4 实操时「只记三条路径」
- 跑用例:终端在项目根目录执行
python run.py smoke(冒烟)或python run.py test(全量)。 - 改场景顺序或数据:打开
test-data/test_cases.yaml,找到对应- name:块,改steps或kwargs。 - 改页面操作方式:打开
pages/下对应文件,改类里的大写常量选择器或方法体。
六、配置如何合并(读配置的顺序)
- 读取
config/base.json。 - 按
TEST_ENV(默认dev)读取config/<TEST_ENV>.json覆盖。 .env优先覆盖部分键:BASE_URL→base_url,LOGIN_PATH→login_path,HEADLESS→headless。- 必填键:
base_url、login_path、browser、timeout_ms(由base.json提供或由合并结果包含)。
详见下方 utils/config_loader.py 源码。
七、报告说明(非常重要)
| 产物 | 是什么 | 谁生成 |
|---|---|---|
reports/report.html |
pytest-html 报告,不是 Allure UI | pytest-html 插件 |
reports/allure-results/ |
Allure 原始数据(给 Allure CLI 用) | allure-pytest |
reports/allure-html/index.html |
Allure 可浏览报告 | allure generate(本机 Java + CLI,或 Docker 镜像) |
Allure HTML 依赖 Java 运行时。若本机没有 Java,npx allure-commandline 仍会失败;run.py 会在失败后尝试 Docker(需安装并启动 Docker Desktop)。环境变量 ALLURE_DOCKER_IMAGE(默认 andgineer/allure:latest)可换镜像。
八、各模块完整源代码与说明
以下代码与仓库中文件一致;若你本地有改动,以本地为准。
每节格式为:文件路径 → 作用说明 → 完整代码。
8.1 run.py — 统一入口:清理产物、跑 pytest、生成 Allure HTML、打开报告
作用:
setup:安装依赖与 Playwright 浏览器smoke/test:清理旧报告后跑 pytest,再尝试生成 Allure HTMLallure-html:仅根据已有allure-results生成 HTMLallure-open:用系统默认程序打开reports/allure-html/index.html(不依赖本机allure命令)
import os
import shutil
import subprocess
import sys
from pathlib import Path
from utils.logger import setup_logger
LOGGER = setup_logger()
ROOT_DIR = Path(__file__).resolve().parent
def clean_report_artifacts() -> None:
targets = [
ROOT_DIR / "reports" / "report.html",
ROOT_DIR / "reports" / "allure-results",
ROOT_DIR / "reports" / "allure-html",
ROOT_DIR / "test-results" / "screenshots",
ROOT_DIR / "test-results" / "video",
]
for target in targets:
if target.is_file():
target.unlink(missing_ok=True)
LOGGER.info("Removed old report file: %s", target)
continue
if target.is_dir():
shutil.rmtree(target, ignore_errors=True)
LOGGER.info("Removed old report directory: %s", target)
(ROOT_DIR / "reports").mkdir(parents=True, exist_ok=True)
(ROOT_DIR / "test-results").mkdir(parents=True, exist_ok=True)
def run_command(command: list[str]) -> None:
LOGGER.info("Running command: %s", " ".join(command))
print(f"\n>>> Running: {' '.join(command)}")
completed = subprocess.run(command, check=False)
if completed.returncode != 0:
LOGGER.error("Command failed with return code: %s", completed.returncode)
raise SystemExit(completed.returncode)
LOGGER.info("Command completed successfully")
def run_pytest(args: list[str]) -> int:
cmd = ["pytest", *args]
LOGGER.info("Running command: %s", " ".join(cmd))
print(f"\n>>> Running: {' '.join(cmd)}")
completed = subprocess.run(cmd, cwd=str(ROOT_DIR), check=False)
if completed.returncode != 0:
LOGGER.error("Pytest failed with return code: %s", completed.returncode)
else:
LOGGER.info("Pytest completed successfully")
return completed.returncode
def _generate_allure_cli(results_dir: Path, html_dir: Path) -> int:
"""Host Allure CLI (brew / npx). npx allure-commandline still needs a Java runtime."""
html_dir.mkdir(parents=True, exist_ok=True)
allure_cmd = ["allure"] if shutil.which("allure") else ["npx", "--yes", "allure-commandline"]
cmd = allure_cmd + ["generate", str(results_dir), "-o", str(html_dir), "--clean"]
LOGGER.info("Allure CLI: %s", " ".join(cmd))
print(f"\n>>> Generating Allure HTML: {' '.join(cmd)}")
completed = subprocess.run(cmd, cwd=str(ROOT_DIR), check=False)
return completed.returncode
def _generate_allure_docker(results_dir: Path, html_dir: Path) -> int:
"""Allure generate inside Docker (bundled Java). Override image with ALLURE_DOCKER_IMAGE."""
html_dir.mkdir(parents=True, exist_ok=True)
image = os.environ.get("ALLURE_DOCKER_IMAGE", "andgineer/allure:latest")
cmd = [
"docker",
"run",
"--rm",
"-v",
f"{results_dir.resolve()}:/allure-results",
"-v",
f"{html_dir.resolve()}:/allure-report",
image,
"allure",
"generate",
"/allure-results",
"-o",
"/allure-report",
"--clean",
]
LOGGER.info("Allure Docker: %s", " ".join(cmd))
print(
f"\n>>> Allure generate via Docker (image={image}; includes Java). "
"First run may pull the image.\n"
f" {' '.join(cmd)}"
)
completed = subprocess.run(cmd, cwd=str(ROOT_DIR), check=False)
return completed.returncode
def _allure_html_ok(html_dir: Path) -> bool:
return (html_dir / "index.html").is_file()
def generate_allure_html() -> int:
"""
Build Allure HTML from reports/allure-results (pytest --alluredir output).
Order: host Allure / npx (needs Java on the machine) → Docker image andgineer/allure (Java inside container).
"""
results_dir = ROOT_DIR / "reports" / "allure-results"
html_dir = ROOT_DIR / "reports" / "allure-html"
if not results_dir.is_dir():
LOGGER.warning("Allure results directory missing: %s", results_dir)
print("\n(No Allure raw results; pytest may not have run or --alluredir is disabled.)")
return 1
if not any(results_dir.iterdir()):
LOGGER.warning("Allure results directory is empty: %s", results_dir)
print("\n(No Allure result files; nothing to generate.)")
return 1
rc_cli = _generate_allure_cli(results_dir, html_dir)
if rc_cli == 0 and _allure_html_ok(html_dir):
index = html_dir / "index.html"
LOGGER.info("Allure HTML report ready: %s", index)
print(f"\nAllure HTML (open in browser): {index.resolve().as_uri()}")
return 0
if rc_cli != 0:
LOGGER.warning(
"Allure CLI failed (exit %s). Common cause: no Java on macOS (npx downloads CLI but JVM is required).",
rc_cli,
)
if shutil.which("docker"):
print(
"\n--- Retrying with Docker (no Java on host required) ---\n"
"Ensure Docker Desktop is running.\n"
)
rc_docker = _generate_allure_docker(results_dir, html_dir)
if rc_docker == 0 and _allure_html_ok(html_dir):
index = html_dir / "index.html"
LOGGER.info("Allure HTML report ready (Docker): %s", index)
print(f"\nAllure HTML (open in browser): {index.resolve().as_uri()}")
return 0
LOGGER.warning("Docker Allure generate failed with exit %s", rc_docker)
else:
LOGGER.warning("Docker not found in PATH; cannot fall back to containerized Allure.")
print(
"\n仍无法生成 Allure HTML 时,请任选其一:\n"
" 1) 安装 Java:macOS 可 `brew install openjdk@17`,配置 PATH 后执行 `java -version`,再运行 `python run.py allure-html`\n"
" 2) 安装并启动 Docker Desktop,再运行 `python run.py allure-html`(脚本会自动用镜像生成报告)\n"
"\n说明:`reports/report.html` 是 pytest-html;Allure 报告入口是 `reports/allure-html/index.html`。\n"
)
return 1
def main() -> None:
action = sys.argv[1] if len(sys.argv) > 1 else "test"
if action == "setup":
run_command([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])
run_command(["playwright", "install"])
return
if action == "smoke":
clean_report_artifacts()
rc = run_pytest(["-m", "smoke"])
generate_allure_html()
if rc != 0:
raise SystemExit(rc)
return
if action == "test":
clean_report_artifacts()
rc = run_pytest([])
generate_allure_html()
if rc != 0:
raise SystemExit(rc)
return
if action == "allure-html":
if generate_allure_html() != 0:
raise SystemExit(1)
return
if action == "allure-open":
index = ROOT_DIR / "reports" / "allure-html" / "index.html"
if index.is_file():
print(f"\n>>> Opening Allure report: {index}")
if sys.platform == "darwin":
subprocess.run(["open", str(index)], check=False)
elif sys.platform == "win32":
os.startfile(str(index)) # type: ignore[attr-defined]
else:
subprocess.run(["xdg-open", str(index)], check=False)
return
LOGGER.error("Missing %s — run: python run.py smoke or python run.py allure-html", index)
raise SystemExit(1)
print("Usage:")
print(" python run.py setup")
print(" python run.py smoke")
print(" python run.py test")
print(" python run.py allure-html # only generate Allure HTML from existing results")
print(" python run.py allure-open")
raise SystemExit(1)
if __name__ == "__main__":
main()
8.2 conftest.py(项目根)— 将 fixtures/conftest.py 暴露给 pytest
作用:Pytest 默认会加载测试目录及上级的 conftest.py。此处用通配导入把 fixtures/ 里的夹具与钩子挂到全局。
from fixtures.conftest import * # noqa: F401,F403
8.3 fixtures/conftest.py — 浏览器生命周期、环境、失败截图与 Allure 附件
作用:
settings:合并后的运行配置env_vars:从.env读取的账号与可选 OTP API 等playwright_instance/browser/context/page/api_request:Playwright 对象树pytest_runtest_makereport:用例失败时截图并附加到 Allure
import os
import re
from pathlib import Path
from typing import Dict, Generator
import pytest
import allure
from dotenv import load_dotenv
from playwright.sync_api import APIRequestContext, Browser, BrowserContext, Page, Playwright, sync_playwright
from utils.config_loader import load_settings
from utils.logger import setup_logger
ROOT_DIR = Path(__file__).resolve().parents[1]
LOGGER = setup_logger()
def pytest_sessionstart(session):
LOGGER.info("Pytest session started")
def pytest_sessionfinish(session, exitstatus):
LOGGER.info("Pytest session finished with exit status: %s", exitstatus)
@pytest.fixture(scope="session")
def settings() -> Dict:
loaded = load_settings()
LOGGER.info("Loaded settings for environment")
return loaded
@pytest.fixture(scope="session")
def env_vars() -> Dict[str, str]:
load_dotenv(ROOT_DIR / ".env")
required = ["TEST_PHONE", "STATIC_OTP_CODE"]
values: Dict[str, str] = {}
missing = []
for key in required:
value = os.getenv(key, "").strip()
if not value:
missing.append(key)
values[key] = value
if missing:
raise RuntimeError(f"Missing required .env values: {', '.join(missing)}")
values["MOCK_OTP_API_URL"] = os.getenv("MOCK_OTP_API_URL", "").strip()
values["POST_LOGIN_URL_CONTAINS"] = os.getenv("POST_LOGIN_URL_CONTAINS", "").strip()
LOGGER.info("Loaded .env variables for test execution")
return values
@pytest.fixture(scope="session")
def playwright_instance() -> Generator[Playwright, None, None]:
with sync_playwright() as playwright:
yield playwright
@pytest.fixture(scope="session")
def browser(playwright_instance: Playwright, settings: Dict) -> Generator[Browser, None, None]:
browser_name = settings.get("browser", "chromium")
launch = getattr(playwright_instance, browser_name).launch
browser = launch(headless=settings.get("headless", True), slow_mo=settings.get("slow_mo", 0))
LOGGER.info("Browser launched: %s", browser_name)
yield browser
browser.close()
LOGGER.info("Browser closed")
@pytest.fixture
def context(browser: Browser) -> Generator[BrowserContext, None, None]:
context = browser.new_context(record_video_dir="test-results/video")
yield context
context.close()
@pytest.fixture
def page(context: BrowserContext) -> Generator[Page, None, None]:
page = context.new_page()
yield page
@pytest.fixture
def api_request(playwright_instance: Playwright) -> Generator[APIRequestContext, None, None]:
request_context = playwright_instance.request.new_context()
yield request_context
request_context.dispose()
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
result = outcome.get_result()
if result.when != "call" or result.passed:
return
page = item.funcargs.get("page")
if page:
screenshot_dir = ROOT_DIR / "test-results" / "screenshots"
screenshot_dir.mkdir(parents=True, exist_ok=True)
safe_name = re.sub(r"[^0-9A-Za-z_.-]+", "_", item.name).strip("_") or "failed_test"
screenshot_path = screenshot_dir / f"{safe_name}.png"
page.screenshot(path=str(screenshot_path), full_page=True)
allure.attach.file(str(screenshot_path), name=f"{safe_name}-screenshot", attachment_type=allure.attachment_type.PNG)
LOGGER.error("Test failed: %s, screenshot saved: %s", item.name, screenshot_path)
8.4 fixtures/__init__.py
作用:声明 fixtures 为 Python 包(可为空)。
# Pytest fixtures package marker.
8.5 pages/base_page.py — 页面基类:导航与多 selector 兜底
作用:封装 goto、按 || 分割的多套 selector 依次尝试、点击/输入/等待可见。具体页面选择器放在子类(如 LoginPage)。
import re
from typing import Any, Dict, Optional
from playwright.sync_api import Locator, Page, TimeoutError as PlaywrightTimeoutError
from utils.logger import setup_logger
class BasePage:
"""Shared Playwright helpers. UI selectors live in concrete page classes."""
def __init__(self, page: Page, settings: Dict[str, Any]) -> None:
self.page = page
self.settings = settings
self.logger = setup_logger()
def goto(self, path: str) -> None:
path = path.strip()
if path.startswith("http://") or path.startswith("https://"):
self.page.goto(path)
return
base = str(self.settings.get("base_url", "")).rstrip("/")
if not path.startswith("/"):
path = f"/{path}"
self.page.goto(f"{base}{path}")
def first_visible_locator(self, selector_group: str, try_timeout_ms: int = 1500) -> Locator:
if not selector_group:
raise ValueError("selector is required")
selector_list = [item.strip() for item in selector_group.split("||") if item.strip()]
for selector in selector_list:
locator = self.page.locator(selector).first
try:
locator.wait_for(state="visible", timeout=try_timeout_ms)
return locator
except PlaywrightTimeoutError:
continue
raise ValueError(f"No visible element found by selectors: {selector_group}")
def click_first(self, selector_group: str, *, force: bool = False) -> None:
self.first_visible_locator(selector_group).click(force=force)
def fill_first(self, selector_group: str, value: str) -> None:
self.first_visible_locator(selector_group).fill(value)
def wait_first_visible(self, selector_group: str, timeout_ms: Optional[int] = None) -> None:
timeout = timeout_ms if timeout_ms is not None else int(self.settings.get("timeout_ms", 30000))
selector_list = [item.strip() for item in selector_group.split("||") if item.strip()]
last_error: Optional[Exception] = None
for selector in selector_list:
locator = self.page.locator(selector).first
try:
locator.wait_for(state="visible", timeout=timeout)
return
except PlaywrightTimeoutError as exc:
last_error = exc
continue
raise ValueError(f"Element not visible for selectors: {selector_group}") from last_error
@staticmethod
def resolve_placeholders(template: str, variables: Dict[str, str]) -> str:
def replace(match: re.Match[str]) -> str:
key = match.group(1).strip()
if key not in variables:
raise ValueError(f"Undefined placeholder variable: {key}")
return str(variables.get(key, ""))
return re.sub(r"\{\{([^{}]+)\}\}", replace, template)
8.6 pages/login_page.py — 登录页 POM(定位与手机号验证码流程)
作用:
- 所有登录相关 选择器字符串 集中在该类(页面改版主要改这里);多条用
||分隔,由BasePage依次尝试直到可见。 fetch_otp:若配置了MOCK_OTP_API_URL则 HTTP 取码,否则使用 YAML 传入的静态码。login_with_phone_otp:供 YAML 一步完成登录 的聚合方法。
"""
登录页页面对象(Page Object)。
职责:
- 集中维护登录相关 CSS 选择器(含多套 || 兜底);
- 封装「手机号 + 验证码」登录的完整操作与断言;
- 不读取业务 YAML,由引擎调用本类方法;账号等来自调用方传入或 .env。
"""
from typing import Any, Dict
# Playwright 同步 API:APIRequestContext 用于 HTTP 取验证码;expect 用于断言元素可见
from playwright.sync_api import APIRequestContext, expect
# 基类提供 goto、click_first、fill_first、wait_first_visible 等通用能力
from pages.base_page import BasePage
class LoginPage(BasePage):
"""
手机号 + 短信验证码登录流程。
UI 改版时主要修改本类中的选择器常量及方法内顺序;
账号、环境地址来自 settings / kwargs,与 test-data/test_cases.yaml 中占位符对应。
"""
# -------------------------------------------------------------------------
# 以下为「选择器组」:多个选择器用 || 分隔,BasePage 会按顺序尝试直到找到可见元素
# -------------------------------------------------------------------------
# 登录页上「手机号登录」Tab(部分站点默认账号密码,需强制点到手机登录)
PHONE_LOGIN_TAB = (
"元素路径"
)
# 手机号输入框:多套 placeholder / name / type 兜底,适配不同皮肤
PHONE_INPUT = (
'元素路径'
)
# 「获取验证码 / 发送验证码」按钮
SEND_OTP_BUTTON = (
'元素路径'
)
# 短信验证码输入框
OTP_INPUT = (
'元素路径'
)
# 登录提交按钮(主按钮、文案「登录」等)
LOGIN_BUTTON = (
'元素路径'
)
# 登录成功后页头/用户区可见元素,用于断言已登录(多套站点兜底)
LOGIN_SUCCESS = (
'元素路径'
)
def __init__(
self,
page,
settings: Dict[str, Any],
api_request: APIRequestContext,
) -> None:
"""
构造登录页对象。
:param page: Playwright 页面对象,与整条用例共用同一浏览器 Tab
:param settings: 合并后的配置(含 base_url、login_path、timeout_ms 等)
:param api_request: Playwright 请求上下文,用于 MOCK_OTP_API_URL 拉取验证码 JSON
"""
# 初始化基类:绑定 page、settings,并注册 logger
super().__init__(page, settings)
# 保存 API 上下文,供 fetch_otp 发 GET 请求(无 mock URL 时也可不传,仅用静态码)
self.api_request = api_request
def open_login(self) -> None:
"""打开登录页:使用 settings 中的 login_path,与 base_url 拼接后跳转。"""
# 从配置读取登录路径,缺省为 /login
path = str(self.settings.get("login_path", "/login"))
# 基类 goto:支持相对路径(拼 base_url)或绝对 http(s) URL
self.goto(path)
def switch_to_phone_login_tab(self) -> None:
"""切换到「手机号登录」Tab;force=True 避免被遮挡或未完全可点导致失败。"""
self.click_first(self.PHONE_LOGIN_TAB, force=True)
def wait_phone_input(self, timeout_ms: int = 8000) -> None:
"""等待手机号输入框出现,避免 Tab 切换后 DOM 尚未渲染。"""
self.wait_first_visible(self.PHONE_INPUT, timeout_ms=timeout_ms)
def enter_phone(self, phone: str) -> None:
"""在手机号输入框中填入号码(由 YAML kwargs / 测试传入)。"""
self.fill_first(self.PHONE_INPUT, phone)
def send_otp(self) -> None:
"""点击发送验证码按钮,触发服务端下发短信(或测试环境 mock)。"""
self.click_first(self.SEND_OTP_BUTTON)
def fetch_otp(self, phone: str, static_code: str, api_url: str) -> str:
"""
获取本次登录要填入的验证码。
- 若 api_url 为空:直接返回 static_code(测试环境固定验证码);
- 否则:GET api_url(可将 URL 中的 {{phone}} 替换为当前手机号),解析 JSON 的 code 字段。
"""
phone = phone.strip()
api_url = api_url.strip()
# 未配置取码接口时,使用调用方提供的静态验证码
if not api_url:
return static_code
# 部分 mock 服务用 {{phone}} 占位,这里替换为真实手机号
request_url = api_url.replace("{{phone}}", phone)
# 使用 Playwright 的 request 上下文发 GET(与浏览器 Cookie 隔离,仅适合 mock 接口)
response = self.api_request.get(request_url)
if not response.ok:
raise AssertionError(f"OTP API request failed: {response.status} {request_url}")
# 假定响应体为 JSON,且业务约定字段名为 code
payload = response.json()
code = payload.get("code")
if not code:
raise AssertionError(f"OTP API response missing code field: {payload}")
return str(code)
def enter_otp(self, code: str) -> None:
"""将拿到的验证码填入验证码输入框。"""
self.fill_first(self.OTP_INPUT, code)
def submit_login(self) -> None:
"""点击登录按钮提交表单。"""
self.click_first(self.LOGIN_BUTTON)
def assert_logged_in(self) -> None:
"""断言登录成功:页面上出现代表已登录的用户区域/头像等任一匹配节点。"""
expect(self.first_visible_locator(self.LOGIN_SUCCESS)).to_be_visible()
def login_with_phone_otp(self, phone: str, static_otp: str, mock_otp_api_url: str = "") -> None:
"""
一键完成「手机号 + 验证码」登录(YAML 中通常只调本方法)。
顺序:打开登录页 → 切手机 Tab → 等输入框 → 填手机 → 发码 → 取码 → 填码 → 提交 → 断言成功。
"""
self.open_login()
self.switch_to_phone_login_tab()
self.wait_phone_input()
self.enter_phone(phone)
self.send_otp()
# 静态码或 mock 接口二选一,由 mock_otp_api_url 是否为空决定
otp = self.fetch_otp(phone, static_otp, mock_otp_api_url)
self.enter_otp(otp)
self.submit_login()
self.assert_logged_in()
8.6.1 pages/account_page.py — 登录后业务页示例(侧栏导航)
作用:演示仅需 page + settings 的 Page(构造函数无 api_request);YAML 中通过多步 call 完成「点击侧栏 → 断言视图」。
"""
账户管理相关页面:侧栏入口与进入后的视图断言。
选择器由产品 DOM 提供,若布局变更请只改本文件常量。
"""
from typing import Any, Dict
from playwright.sync_api import expect
from pages.base_page import BasePage
class AccountPage(BasePage):
"""登录后从侧栏进入「账户管理」并校验视图菜单。"""
# 侧栏第一个菜单项(进入账户管理)
SIDEBAR_ACCOUNT_ENTRY = (
"元素路径"
)
# 进入后视图菜单中需可见的文案节点
VIEW_MENU_ACTIVE_ITEM = (
"元素路径"
)
def __init__(self, page, settings: Dict[str, Any]) -> None:
super().__init__(page, settings)
def enter_account_management_from_sidebar(self) -> None:
"""点击侧栏入口进入账户管理。"""
self.click_first(self.SIDEBAR_ACCOUNT_ENTRY)
def assert_account_management_visible(self) -> None:
"""断言已进入账户管理区域(视图菜单首项可见)。"""
expect(self.first_visible_locator(self.VIEW_MENU_ACTIVE_ITEM)).to_be_visible()
8.6.2 pages/example_orders_page.py — 可拷贝的 Page 模板(含文件头填写说明)
作用:教学用「订单列表」占位实现;与仓库中源文件一致。配套 YAML 见 8.8.5。将选择器与路由换成真实值后,可把用例片段合并进 test_cases.yaml 并设 enabled: true。
"""
示例:订单列表相关页面对象(模板,非业务正式代码)。
================================================================================
【本文件用途】
演示在本仓库中如何编写一个继承 BasePage 的 Page 类,并与 YAML 中的
`page` + `call` + `kwargs` 一一对应。将选择器与方法体换成你们系统真实 DOM
与流程后,可把本文件改名为业务名(如 order_page.py / OrderPage),并同步
修改 YAML 里的 `page` 字段。
【与引擎的约定】(一般无需改引擎)
- `__init__(self, page, settings)`:引擎会注入 Playwright Page 与合并后的
settings(见 config/*.json 与 .env 覆盖)。若还需发 HTTP,可像 LoginPage
一样增加 `api_request` 参数名,引擎会按需注入。
- YAML 调用的方法必须存在于本类,且参数能被 `kwargs` 传入(名称一致)。
【填写说明 — 请按顺序替换为真实数据】
1) 类名 / 文件名
- 类名 `ExampleOrdersPage`、文件 `example_orders_page.py` 可整体重命名;
- YAML 中 `page` 必须为「模块名.类名」,例如
`example_orders_page.ExampleOrdersPage`。
2) 下列「选择器常量」
- 当前值为**占位示意**,在你们环境上大概率无法定位;请用浏览器开发者工具
复制稳定选择器(优先 data-testid、role、name),多条用 `||` 连接以兜底,
规则同 `BasePage.first_visible_locator`。
3) 方法 `open_orders_list` 的 `path` 参数
- 由 YAML `kwargs.path` 传入;可为相对路径(会拼 `settings["base_url"]`)或
完整 http(s) URL。请改为你们菜单「订单列表」对应的真实路由。
4) 方法 `search_order_by_keyword` / `assert_list_shows_text`
- 占位逻辑为「在搜索框输入 → 点搜索 → 断言页面出现某段文案」;若你们列表
无搜索框,可删改方法并在 YAML 中改调其他 `call`。
================================================================================
"""
from __future__ import annotations
from typing import Any, Dict
from playwright.sync_api import expect
from pages.base_page import BasePage
class ExampleOrdersPage(BasePage):
"""
示例:登录后台后访问「订单列表」并做简单检索断言。
仅作模板;选择器与路由请按文件头「填写说明」替换。
"""
# -------------------------------------------------------------------------
# 选择器占位:请替换为你们系统真实 DOM(可保留 || 多候选写法)
# -------------------------------------------------------------------------
# 订单列表页面上「关键词 / 订单号」搜索输入框
ORDER_SEARCH_INPUT = (
"input[placeholder*='订单']||input[placeholder*='单号']||"
"input[placeholder*='关键词']||input[type='search']||"
"[data-testid='order-search']"
)
# 「搜索 / 查询」主按钮
ORDER_SEARCH_BUTTON = (
"button:has-text('搜索')||button:has-text('查询')||"
"button[type='submit']||[data-testid='order-search-submit']"
)
# 列表区域容器(用于等待列表渲染;不需要可改为更具体的行选择器)
ORDER_TABLE_OR_LIST = (
"table||.el-table__body||[class*='order-list']||[data-testid='order-table']"
)
def __init__(self, page, settings: Dict[str, Any]) -> None:
super().__init__(page, settings)
def open_orders_list(self, path: str) -> None:
"""
打开订单列表页。
:param path: 相对路径(如 /admin/orders)或完整 URL;由 YAML kwargs 传入。
"""
self.goto(path.strip())
def search_order_by_keyword(self, keyword: str) -> None:
"""
在列表页输入关键词并点击搜索。
:param keyword: 订单号片段、客户名等;由 YAML kwargs 传入。
"""
self.wait_first_visible(self.ORDER_SEARCH_INPUT)
self.fill_first(self.ORDER_SEARCH_INPUT, keyword)
self.click_first(self.ORDER_SEARCH_BUTTON)
self.wait_first_visible(self.ORDER_TABLE_OR_LIST)
def assert_list_shows_text(self, text: str) -> None:
"""
断言当前页面可见区域内出现指定文案(例如完整订单号)。
:param text: 期望出现的字符串;由 YAML kwargs 传入。
"""
expect(self.page.get_by_text(text, exact=False).first).to_be_visible()
8.7 pages/__init__.py
作用:包标识;建议从子模块显式导入页面对象。
# Page objects live in submodules, e.g. `from pages.login_page import LoginPage`.
8.8 YAML 驱动层(统一入口 + 加载器 + 引擎 + 用例数据)
日常新增场景主要改 test-data/test_cases.yaml 与 pages/,一般不改本节文件;若需新增 builtin 步骤类型,才改 utils/yaml_pom_engine.py 中的 BUILTIN_STEPS。
8.8.1 tests/test_yaml_cases.py — 从 YAML 生成 pytest 用例并执行引擎
"""
YAML 用例统一入口:从 test-data/test_cases.yaml 读取场景,交给 YamlPomEngine 执行。
新增用例请编辑 test-data/test_cases.yaml,并在 pages/ 中实现对应 Page 方法。
"""
import re
import allure
import pytest
from utils.yaml_case_loader import load_yaml_cases
from utils.yaml_pom_engine import YamlPomEngine
ALL_CASES = load_yaml_cases()
PARAMS = []
for idx, item in enumerate(ALL_CASES, start=1):
marks = [pytest.mark.smoke] if "smoke" in item.get("tags", []) else []
raw_name = str(item.get("name", f"case_{idx}"))
# 保留中文等 Unicode 字母,便于在 IDE / 报告里识别用例(Python 3 的 \w 含中日韩字符)
safe_id = re.sub(r"[^\w.-]+", "_", raw_name, flags=re.UNICODE).strip("_") or f"case_{idx}"
if len(safe_id) > 120:
safe_id = safe_id[:120].rstrip("_")
PARAMS.append(pytest.param(item, id=f"c{idx:02d}_{safe_id}", marks=marks))
@pytest.mark.parametrize("case_data", PARAMS)
def test_yaml_pom_cases(page, settings, env_vars, api_request, case_data):
meta = case_data.get("allure") or {}
dyn = getattr(allure, "dynamic", None)
if dyn is not None:
if meta.get("title"):
dyn.title(meta["title"])
if meta.get("epic"):
dyn.epic(meta["epic"])
if meta.get("feature"):
dyn.feature(meta["feature"])
if meta.get("story"):
dyn.story(meta["story"])
engine = YamlPomEngine(
page=page,
api_request=api_request,
settings=settings,
env_vars=env_vars,
case_name=case_data["name"],
)
engine.run_case(case_data)
8.8.2 utils/yaml_case_loader.py — 加载并校验 test_cases.yaml
"""
从 test-data/test_cases.yaml 加载用例列表并做基础校验。
业务同学新增场景时主要编辑该 YAML,一般不需要改本文件。
"""
from pathlib import Path
from typing import Any, Dict, List
import yaml
ROOT_DIR = Path(__file__).resolve().parents[1]
DATA_FILE = ROOT_DIR / "test-data" / "test_cases.yaml"
def load_yaml_cases() -> List[Dict[str, Any]]:
if not DATA_FILE.exists():
raise FileNotFoundError("Missing test-data/test_cases.yaml")
with DATA_FILE.open("r", encoding="utf-8") as f:
payload = yaml.safe_load(f)
if not isinstance(payload, list) or not payload:
raise ValueError("test-data/test_cases.yaml must be a non-empty list.")
enabled_cases = [item for item in payload if item.get("enabled", True)]
if not enabled_cases:
raise ValueError("No enabled cases found in test-data/test_cases.yaml.")
names = []
for case in enabled_cases:
name = str(case.get("name", "")).strip()
if not name:
raise ValueError("Each enabled case must include non-empty name.")
if name in names:
raise ValueError(f"Duplicate case name: {name}")
names.append(name)
steps = case.get("steps")
if not isinstance(steps, list) or not steps:
raise ValueError(f"Case '{name}' must include non-empty steps list.")
_validate_case_steps(name, steps)
return enabled_cases
def _validate_case_steps(case_name: str, steps: List[Any]) -> None:
"""
在收集用例前校验每一步形状,避免运行到一半才报错。
合法形态二选一:builtin;或 page + call(可选 kwargs)。
"""
for i, step in enumerate(steps, start=1):
if not isinstance(step, dict):
raise ValueError(
f"Case {case_name!r} step #{i} must be a mapping (dict), got {type(step).__name__}"
)
has_builtin = "builtin" in step and step.get("builtin") is not None
has_page = "page" in step and "call" in step
if has_builtin and has_page:
raise ValueError(
f"Case {case_name!r} step #{i}: use either 'builtin' or 'page'+'call', not both. "
f"Keys: {list(step.keys())}"
)
if has_builtin:
if not str(step.get("builtin", "")).strip():
raise ValueError(f"Case {case_name!r} step #{i}: 'builtin' must be non-empty")
continue
if has_page:
if not str(step.get("page", "")).strip():
raise ValueError(f"Case {case_name!r} step #{i}: 'page' must be non-empty")
if not str(step.get("call", "")).strip():
raise ValueError(f"Case {case_name!r} step #{i}: 'call' must be non-empty")
continue
raise ValueError(
f"Case {case_name!r} step #{i}: need ('page' + 'call') or 'builtin'. Keys: {list(step.keys())}"
)
8.8.3 utils/yaml_pom_engine.py — 解析步骤、动态加载 pages.*、调用方法或 builtin
"""
执行 YAML 用例:按步骤调用 pages 包中的 Page 类方法,或执行少量内置断言。
新增业务场景时:在 pages/ 写 Page 方法,在 test_cases.yaml 里写 page + call + kwargs。
一般不需要修改本引擎,除非要新增「内置步骤」类型(builtin)。
"""
import importlib
import inspect
import re
from typing import Any, Callable, Dict, List, Optional
import allure
from playwright.sync_api import APIRequestContext, Page
from utils.logger import setup_case_logger, setup_logger
class YamlPomEngine:
def __init__(
self,
page: Page,
api_request: APIRequestContext,
settings: Dict[str, Any],
env_vars: Dict[str, str],
case_name: str = "yaml_case",
) -> None:
self.page = page
self.api_request = api_request
self.settings = settings
self.env_vars = env_vars
self.case_name = case_name
self.vars: Dict[str, str] = {
**env_vars,
"BASE_URL": str(settings.get("base_url", "")),
"LOGIN_PATH": str(settings.get("login_path", "")),
}
self.logger = setup_logger()
self.case_logger = setup_case_logger(case_name)
def _resolve_text(self, value: str) -> str:
"""将 {{VAR}} 替换为 self.vars 中的值。"""
def replace(match: re.Match[str]) -> str:
key = match.group(1).strip()
if key not in self.vars:
raise ValueError(f"Undefined placeholder variable: {key}")
return str(self.vars.get(key, ""))
return re.sub(r"\{\{([^{}]+)\}\}", replace, value)
def _resolve_obj(self, obj: Any) -> Any:
if isinstance(obj, str):
return self._resolve_text(obj)
if isinstance(obj, dict):
return {k: self._resolve_obj(v) for k, v in obj.items()}
if isinstance(obj, list):
return [self._resolve_obj(v) for v in obj]
return obj
@staticmethod
def _load_page_class(spec: str) -> type:
"""
spec 格式:「模块名.类名」,模块位于 pages 包下。
例:login_page.LoginPage -> pages.login_page.LoginPage
"""
spec = spec.strip()
if "." not in spec:
raise ValueError(f"Invalid page spec '{spec}', expected like 'login_page.LoginPage'")
module_part, class_name = spec.rsplit(".", 1)
try:
module = importlib.import_module(f"pages.{module_part}")
except ModuleNotFoundError as exc:
raise ValueError(
f"Cannot import pages.{module_part}: add file pages/{module_part}.py (see YAML page: {spec})"
) from exc
cls = getattr(module, class_name, None)
if cls is None:
raise ValueError(f"Class '{class_name}' not found in pages.{module_part}")
return cls
def _instantiate_page(self, page_class: type) -> Any:
"""按 Page 的 __init__ 形参注入 page / settings / api_request(按需)。"""
sig = inspect.signature(page_class.__init__)
params = list(sig.parameters.keys())
if params and params[0] == "self":
params = params[1:]
kwargs: Dict[str, Any] = {}
if "page" in params:
kwargs["page"] = self.page
if "settings" in params:
kwargs["settings"] = self.settings
if "api_request" in params:
kwargs["api_request"] = self.api_request
return page_class(**kwargs)
def _run_builtin(self, step: Dict[str, Any], idx: int) -> None:
name = str(step.get("builtin", "")).strip()
if not name:
raise ValueError(f"Step #{idx} builtin name is empty")
fn: Optional[Callable[..., None]] = BUILTIN_STEPS.get(name)
if not fn:
raise ValueError(f"Unknown builtin '{name}'. Available: {sorted(BUILTIN_STEPS.keys())}")
fn(self, step)
def _step_label(self, idx: int, step: Dict[str, Any]) -> str:
if step.get("step_name"):
return str(step["step_name"])
if step.get("builtin"):
return f"builtin:{step.get('builtin')}"
return f"{step.get('page')}.{step.get('call')}"
def run_steps(self, steps: List[Dict[str, Any]]) -> None:
for idx, step in enumerate(steps, start=1):
label = self._step_label(idx, step)
self.logger.info("YAML step #%s %s: %s", idx, label, step)
self.case_logger.info("YAML step #%s %s: %s", idx, label, step)
with allure.step(f"#{idx} {label}"):
try:
if "builtin" in step:
self._run_builtin(step, idx)
continue
page_spec = step.get("page")
method_name = step.get("call")
if not page_spec or not method_name:
raise ValueError(
f"Step #{idx} must have 'page' + 'call', or 'builtin'. Got keys: {list(step.keys())}"
)
page_class = self._load_page_class(str(page_spec))
instance = self._instantiate_page(page_class)
method = getattr(instance, str(method_name), None)
if not callable(method):
raise ValueError(f"{page_spec} has no callable method '{method_name}'")
raw_kwargs = step.get("kwargs") or {}
kwargs = self._resolve_obj(raw_kwargs)
method(**kwargs)
except Exception as exc:
raise RuntimeError(
f"YAML case {self.case_name!r} failed at step #{idx} ({label}): {exc}"
) from exc
def run_case(self, case: Dict[str, Any]) -> None:
self.run_steps(case["steps"])
def _builtin_assert_url_contains(engine: YamlPomEngine, step: Dict[str, Any]) -> None:
"""断言当前 URL 包含指定子串;value 支持 {{VAR}} 或普通字符串。"""
resolved = engine._resolve_obj(step.get("value", ""))
if not isinstance(resolved, str):
raise ValueError("assert_url_contains value must resolve to a string")
if step.get("skip_if_empty") and not resolved.strip():
return
if not resolved.strip():
raise ValueError(
"assert_url_contains: empty value; use skip_if_empty: true for optional URL checks"
)
assert resolved in engine.page.url, (
f"Expected URL to contain {resolved!r}, got {engine.page.url!r}"
)
BUILTIN_STEPS: Dict[str, Callable[[YamlPomEngine, Dict[str, Any]], None]] = {
"assert_url_contains": _builtin_assert_url_contains,
}
8.8.4 test-data/test_cases.yaml — 业务用例数据(结构示例)
以下为最小可读片段;仓库内完整用例(含注释头、多条用例)以磁盘文件为准。
- name: 手机号验证码登录成功
enabled: true
tags: [smoke]
allure:
epic: 认证
feature: 登录
story: 手机号验证码登录
title: 手机号验证码登录成功
steps:
- page: login_page.LoginPage
call: login_with_phone_otp
kwargs:
phone: "{{TEST_PHONE}}"
static_otp: "{{STATIC_OTP_CODE}}"
mock_otp_api_url: "{{MOCK_OTP_API_URL}}"
- builtin: assert_url_contains
value: "{{POST_LOGIN_URL_CONTAINS}}"
skip_if_empty: true
8.8.5 test-data/example_orders_case.yaml — 与模板 Page 配套的 YAML 片段(默认不加载)
本文件不是 load_yaml_cases() 的入口;需将其中 - name: ... 整块复制进 test-data/test_cases.yaml 根列表后才会执行。详见文件内注释与 第九节。
# =============================================================================
# 示例用例片段:与 pages/example_orders_page.py 配套(模板,默认不接入主列表)
# -----------------------------------------------------------------------------
# 【本文件用途】
# 演示一条完整 YAML 用例如何编排:先调现有 LoginPage,再调示例 ExampleOrdersPage。
#
# 【如何使用】
# 1) 按 pages/example_orders_page.py 文件头说明,把选择器与 path 改成真实值;
# 2) 将下方「从第一个 - name: 到该用例字段结束」整块复制到 test_cases.yaml 的
# 根数组中(与其它 - name 同级);
# 3) 把 `name` 改成全局唯一标题(加载器要求 name 不重复);
# 4) 将 `enabled: false` 改为 `true` 后再执行;未改选择器前请保持 false,避免 CI 误跑。
#
# 【占位符 {{...}} 说明】(与 fixtures/conftest.py 中 env_vars 一致)
# TEST_PHONE / STATIC_OTP_CODE / MOCK_OTP_API_URL:登录用,与现有用例相同;
# 若需自定义变量(如 {{ORDERS_PATH}}),须在 conftest 的 env_vars 里增加读取逻辑,
# 否则运行时会报 Undefined placeholder variable。
#
# 【kwargs 里不用占位符的字段】
# path、keyword、text 可直接写死字符串,便于先跑通一条真实路径。
# =============================================================================
- name: "[模板示例] 登录后打开订单列表并搜索"
# false:复制进 test_cases.yaml 前不会被执行;改 true 前务必完成 Page 选择器替换
enabled: false
tags: []
allure:
epic: 示例模板
feature: 订单
story: Page+YAML 对照示例
title: 登录后订单列表搜索(需替换选择器与 path)
steps:
- step_name: 使用封装好的手机号验证码登录
page: login_page.LoginPage
call: login_with_phone_otp
kwargs:
phone: "{{TEST_PHONE}}"
static_otp: "{{STATIC_OTP_CODE}}"
mock_otp_api_url: "{{MOCK_OTP_API_URL}}"
# --- 以下为示例 Page:page 必须是「文件名(无.py).类名」---
- step_name: 打开订单列表(请把 path 改成你们系统真实路由)
page: example_orders_page.ExampleOrdersPage
call: open_orders_list
kwargs:
# 相对路径示例:会拼到 settings 的 base_url 后
path: "/orders/list"
# 若需绝对地址可写 "https://staging.example.com/admin/orders"
- step_name: 在列表页搜索(keyword 改为真实存在的订单号片段)
page: example_orders_page.ExampleOrdersPage
call: search_order_by_keyword
kwargs:
keyword: "DEMO-ORDER-REPLACE-ME"
- step_name: 断言列表中出现期望文案(与 keyword 或完整单号一致即可)
page: example_orders_page.ExampleOrdersPage
call: assert_list_shows_text
kwargs:
text: "DEMO-ORDER-REPLACE-ME"
说明:本节 8.8.1~8.8.3 与仓库源文件一致。若你本地修改了加载器或引擎,请同步更新本文对应代码块(见第十一节)。
8.9 utils/config_loader.py — 加载并合并配置
import json
import os
from pathlib import Path
from typing import Any, Dict
from dotenv import load_dotenv
ROOT_DIR = Path(__file__).resolve().parents[1]
CONFIG_DIR = ROOT_DIR / "config"
def _read_json(path: Path) -> Dict[str, Any]:
if not path.exists():
return {}
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def load_settings() -> Dict[str, Any]:
load_dotenv(ROOT_DIR / ".env")
env_name = os.getenv("TEST_ENV", "dev")
base = _read_json(CONFIG_DIR / "base.json")
env_overrides = _read_json(CONFIG_DIR / f"{env_name}.json")
merged = {**base, **env_overrides}
# Environment variable overrides always win.
if os.getenv("BASE_URL"):
merged["base_url"] = os.getenv("BASE_URL")
if os.getenv("LOGIN_PATH"):
merged["login_path"] = os.getenv("LOGIN_PATH")
if os.getenv("HEADLESS"):
merged["headless"] = os.getenv("HEADLESS", "true").lower() == "true"
required_keys = ["base_url", "login_path", "browser", "timeout_ms"]
missing = [key for key in required_keys if key not in merged]
if missing:
raise ValueError(f"Missing settings keys: {', '.join(missing)}")
return merged
8.10 utils/logger.py — 全局日志与按用例日志
作用:setup_logger() 写入 logs/framework.log 并输出控制台;setup_case_logger 供将来按用例名拆分日志(当前登录用例未强制调用)。
import logging
import re
from logging.handlers import RotatingFileHandler
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[1]
LOG_DIR = ROOT_DIR / "logs"
LOG_FILE = LOG_DIR / "framework.log"
CASE_LOG_DIR = LOG_DIR / "cases"
def setup_logger() -> logging.Logger:
LOG_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("framework")
if logger.handlers:
return logger
logger.setLevel(logging.INFO)
formatter = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler = RotatingFileHandler(
LOG_FILE,
maxBytes=5 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
file_handler.setFormatter(formatter)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.addHandler(console_handler)
return logger
def setup_case_logger(case_name: str) -> logging.Logger:
CASE_LOG_DIR.mkdir(parents=True, exist_ok=True)
safe_name = re.sub(r"[^\w\-.]+", "_", case_name).strip("_") or "unknown_case"
case_log_file = CASE_LOG_DIR / f"{safe_name}.log"
logger_name = f"framework.case.{safe_name}"
case_logger = logging.getLogger(logger_name)
if case_logger.handlers:
return case_logger
case_logger.setLevel(logging.INFO)
case_logger.propagate = False
formatter = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler = RotatingFileHandler(
case_log_file,
maxBytes=2 * 1024 * 1024,
backupCount=3,
encoding="utf-8",
)
file_handler.setFormatter(formatter)
case_logger.addHandler(file_handler)
return case_logger
8.11 utils/__init__.py
# Utils package marker.
8.12 pytest.ini — Pytest 默认参数
作用:限定测试目录、启用 pytest-html、allure 结果目录、--clean-alluredir,注册 smoke marker。
[pytest]
testpaths = tests
python_files = test_*.py
addopts =
-v
--tb=short
--html=reports/report.html
--self-contained-html
--alluredir=reports/allure-results
--clean-alluredir
markers =
smoke: smoke test cases
8.13 requirements.txt — Python 依赖锁定
playwright==1.54.0
pytest==8.3.5
pytest-html==4.1.1
allure-pytest==2.13.5
python-dotenv==1.0.1
PyYAML==6.0.2
8.14 config/base.json
{
"base_url": "https://your-test-domain.example.com",
"login_path": "/login",
"headless": true,
"slow_mo": 0,
"browser": "chromium",
"timeout_ms": 30000
}
8.15 config/dev.json
{
"base_url": "https://test-xxx.com",
"login_path": "/login"
}
8.16 config/qa.json
{
"base_url": "https://your-qa-domain.example.com",
"login_path": "/login"
}
8.17 config/prod.json
{
"base_url": "https://your-prod-domain.example.com",
"login_path": "/login"
}
8.18 .env.example — 环境变量模板(复制为 .env 后填写)
# ===== Runtime =====
TEST_ENV=dev
HEADLESS=true
# ===== Mandatory =====
BASE_URL=https://your-test-domain.example.com
LOGIN_PATH=/login
TEST_PHONE=13800138000
STATIC_OTP_CODE=123456
# Optional: target URL must contain this string after successful login (assertion)
POST_LOGIN_URL_CONTAINS=
# Optional: if your test environment has API for OTP retrieval, configure it
# Example: https://api.test.local/mock/otp?mobile={{phone}}
MOCK_OTP_API_URL=
# Selectors for login UI live in pages/login_page.py (change there when DOM changes).
# Optional: Docker image for Allure HTML when host has no Java (run.py allure-html fallback)
# ALLURE_DOCKER_IMAGE=andgineer/allure:latest
8.19 .gitignore — 不把敏感与生成物提交进 Git
node_modules
playwright-report
test-results
.env
.venv
venv
__pycache__
.pycache
.pytest_cache
reports/report.html
reports/allure-results
reports/allure-html
logs/*.log
logs/cases/*.log
test-results/**
!test-results/.gitkeep
!logs/.gitkeep
8.20 .github/workflows/ui-smoke.yml — CI 冒烟(需自行配置密钥才能在真实环境通过)
作用:在 GitHub Actions 上安装依赖与 Chromium,执行 pytest -m smoke。
注意:工作流里使用 cp .env.example .env,默认是示例域名与假账号,登录用例在真实 CI 中大概率失败,除非你在仓库 Secrets 中注入 BASE_URL、TEST_PHONE、STATIC_OTP_CODE 等,并在 Workflow 中改写为写入 .env 的步骤。
name: UI Smoke Test
on:
workflow_dispatch:
push:
branches: ["main", "master"]
pull_request:
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Install Playwright browsers
run: playwright install --with-deps chromium
- name: Prepare env
run: cp .env.example .env
- name: Run smoke tests
run: pytest -m smoke
九、如何编写 Page 与 YAML(零基础可跟做)
本仓库的主路径是:只改 pages/ 与 test-data/test_cases.yaml,不必新建 tests/test_*.py(tests/test_yaml_cases.py 已统一 parametrize 所有 YAML 用例)。下面按「先 Page、后 YAML」说明。
9.1 编写 pages/ 下 Page 文件的固定步骤
- 新建文件:在
pages/下创建业务模块_page.py(文件名全小写+下划线,与 Python 模块规范一致)。 - 类名:习惯与文件对应,例如
order_page.py→class OrderPage(BasePage)。 - 继承与构造:
class XxxPage(BasePage);__init__里只写引擎能注入的参数名:- 必有:
page、settings(与AccountPage、ExampleOrdersPage相同)。 - 若类里要用 Playwright 的 HTTP 客户端取数(如登录 mock 验证码):增加
api_request形参(与LoginPage相同);引擎会按形参名自动注入,不要在__init__里写引擎不认识的参数。
- 必有:
- 选择器:用大写常量字符串保存;多条候选用
||拼接(见BasePage.first_visible_locator)。优先data-testid、role、name,少依赖易变的nth-child长链。 - 方法:每个「可被 YAML 调用」的方法,参数应能通过
kwargs传入(名称与 YAML 里键名一致)。方法内用self.click_first/self.fill_first/self.wait_first_visible/self.goto等基类能力,断言可用playwright.sync_api.expect。 - YAML 里怎么写
page字段:格式为文件名(不含 .py).类名,例如order_page.OrderPage,对应pages/order_page.py。
对照学习:打开仓库中的 pages/example_orders_page.py(带长注释的模板)与 pages/account_page.py(真实业务页较短示例)。
9.2 Page 方法 与 YAML call / kwargs 的对应关系(必读)
引擎对每一步执行等价于(概念上):
实例 = Page类(page, settings[, api_request]) # 由引擎按 __init__ 形参构造
实例[YAML 里的 call 方法名](**YAML 里 kwargs 解析后的字典)
因此你必须保证:
| 检查项 | 说明 |
|---|---|
call 拼写 |
与 Python 方法名完全一致(区分大小写)。 |
kwargs 键名 |
与方法参数名一致;多余键会报错,缺参也会报错。 |
占位符 {{VAR}} |
仅支持引擎 self.vars 里有的变量:默认含 TEST_PHONE、STATIC_OTP_CODE、MOCK_OTP_API_URL、POST_LOGIN_URL_CONTAINS,以及 BASE_URL、LOGIN_PATH(见 yaml_pom_engine)。新增变量需在 fixtures/conftest.py 的 env_vars fixture 里从 os.getenv 读入。 |
| 普通字符串 | kwargs 里可直接写 " /orders/list " 等,无需 {{ }}。 |
9.3 编写 test-data/test_cases.yaml 的一条用例(字段逐项)
根文件必须是 YAML 数组,每个元素是一条用例,以 - 开头。
| 字段 | 是否必填 | 含义 |
|---|---|---|
name |
是 | 用例标题;所有 enabled: true 的用例之间 name 不得重复(加载器在 yaml_case_loader 中校验)。 |
enabled |
否 | 默认 true;false 时整条跳过且不进入 ALL_CASES。 |
tags |
否 | 列表;含字符串 smoke 时该条带 pytest.mark.smoke,可被 python run.py smoke 选中。 |
allure |
否 | 可选 epic / feature / story / title,写入 Allure 动态标签。 |
steps |
是 | 非空数组;从上到下执行,中途失败则停止。 |
每一步 steps 里只能是下面两种之一:
A)调用 Page 方法
- step_name: 可选;报告里显示的中文标题
page: login_page.LoginPage
call: login_with_phone_otp
kwargs:
phone: "{{TEST_PHONE}}"
static_otp: "{{STATIC_OTP_CODE}}"
mock_otp_api_url: "{{MOCK_OTP_API_URL}}"
B)内置断言(不经过 Page)
- builtin: assert_url_contains
value: "{{POST_LOGIN_URL_CONTAINS}}"
skip_if_empty: true
含义:当前浏览器地址栏 URL 必须包含 value 解析后的子串;skip_if_empty: true 且解析结果为空时跳过本步。
9.4 从模板到可运行:推荐操作顺序
- 复制
pages/example_orders_page.py为新文件(如invoice_page.py),类改名为InvoicePage,替换选择器与方法逻辑。 - 打开
test-data/example_orders_case.yaml,理解「先登录、再调自己的 Page」的写法。 - 在
test-data/test_cases.yaml末尾新增一条用例:page改为你的模块与类,kwargs与方法的参数对齐。 - 先设
enabled: true且不要加smoke,执行python run.py test单条调试用;稳定后再按需加tags: [smoke]。 - 若要用
{{MY_SECRET}}这类新占位符:在fixtures/conftest.py的env_vars中增加values["MY_SECRET"] = os.getenv("MY_SECRET", "").strip(),并在.env.example/ 文档中说明。
9.5 切换运行环境(与 Page/YAML 的关系)
- 改
.env中TEST_ENV为dev/qa/prod**,或直接用BASE_URL/LOGIN_PATH覆盖;不改 YAML 即可切环境,因为goto使用合并后的base_url。 - 同一套 YAML 可在多环境跑,只要各环境
.env或config/<env>.json中地址与账号正确。
十、常见问题(排错清单)
| 现象 | 可能原因 | 处理 |
|---|---|---|
Missing required .env values |
TEST_PHONE / STATIC_OTP_CODE 未填 |
编辑 .env |
Missing settings keys |
config 合并后缺少 timeout_ms 等 |
检查 base.json 是否被删改 |
| 元素找不到 | 选择器变更 | 改 pages/login_page.py 中对应常量 |
reports/report.html 不像 Allure |
那是 pytest-html | 打开 reports/allure-html/index.html |
| Allure HTML 未生成 | 无 Java 且 Docker 未跑通 | 安装 Java 或启动 Docker 后重试 python run.py allure-html |
| CI 永远失败 | .env.example 为占位地址 |
配置 Secrets + 修改 Workflow 写真实 .env |
更多推荐


所有评论(0)