当前架构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 时对照

二、你需要先准备的东西

  1. Python 3.9+(本仓库在 3.9 / 3.11 下可用;CI 使用 3.11)。
  2. 本机终端(macOS / Linux / Windows 均可;以下为 macOS 写法,Windows 请自行对应 venv 激活命令)。
  3. 网络:首次需下载 Playwright 浏览器;Allure HTML 若走 Docker 需拉镜像。
  4. 被测系统地址与账号:在 .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:登录页路径,一般为 /login
  • TEST_PHONE:测试用手机号
  • STATIC_OTP_CODE:测试环境静态验证码(若未配置 MOCK_OTP_API_URL 则使用该固定码)

可选:

  • MOCK_OTP_API_URL:若环境提供取码 API,可配置;URL 中可用 {{phone}} 占位
  • POST_LOGIN_URL_CONTAINS:登录成功后要求 URL 必须包含的子串(用于用例里额外断言)
  • TEST_ENVdev / qa / prod,决定合并哪份 config/<env>.json
  • HEADLESStrue/false 是否无头浏览器

5)选择运行方式(任选其一)

推荐(统一清理报告并尝试生成 Allure HTML):

python run.py smoke

或直接 pytest:

pytest -m smoke

6)看结果

  • pytest-htmlreports/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)文字版顺序

  1. 执行 python run.py smoke(或 pytest -m smoke)。
  2. run.py 可选地清理旧 reports/test-results/(见 run.py)。
  3. Pytest 加载 pytest.ini,扫描 tests/test_*.py
  4. 根目录 conftest.pyfixtures/conftest.py 中夹具注册到 pytest。
  5. session 级load_settings()config/*.json + .envsettings;读必填环境变量 → env_vars;启动 Playwrightbrowser
  6. function 级:每个测试一个 context(带录屏目录)、pageapi_request
  7. tests/test_yaml_cases.py 读取 test-data/test_cases.yamlYamlPomEngine 逐步构造 Page 并调用 YAML 中的 call 方法(如 LoginPage.login_with_phone_otp)。
  8. 断言失败时:pytest_runtest_makereport 钩子全页截图并 allure.attach.file
  9. 测试结束:run.py 调用 generate_allure_html():先试本机 Allure/npx,失败则 Docker 镜像 andgineer/allure(可用 ALLURE_DOCKER_IMAGE 覆盖)。

2)流程图(Mermaid)

run.py smoke / pytest -m smoke

pytest.ini 加载插件与 addopts

conftest: settings / env_vars

Playwright: browser / context / page / api_request

test_cases.yaml + YamlPomEngine

pages/*.py 执行业务步骤

通过?

截图 + Allure 附件

pytest-html + allure-results

run.py: allure generate CLI 或 Docker

reports/allure-html/index.html

5.3 给非开发者的「三件事」比喻

概念 在本仓库里是什么 你平时主要改哪里
剧本 test-data/test_cases.yaml 里的一条用例:从上到下写「第几步调谁、传什么参数」 增删步骤、改 kwargs、开关 enabled、打 smoke 标签
演员 pages/*.py 里的类(如 LoginPage):真正去点按钮、填输入框、做断言 页面改版时改选择器字符串和方法里的操作顺序
导演 + 剧组 Pytest、fixtures/conftest.pyYamlPomEngine:准备浏览器、读配置、按剧本一步步调用演员 一般不用改;只有要加新的「内置步骤」类型时才动引擎

一句话:不会写 Python 的人,只要会改 YAML 就能编排流程;一旦网页上的按钮、输入框位置变了,需要会改 Page 里的选择器(或请开发同事改 pages/)。

5.4 实操时「只记三条路径」

  1. 跑用例:终端在项目根目录执行 python run.py smoke(冒烟)或 python run.py test(全量)。
  2. 改场景顺序或数据:打开 test-data/test_cases.yaml,找到对应 - name: 块,改 stepskwargs
  3. 改页面操作方式:打开 pages/ 下对应文件,改类里的大写常量选择器或方法体。

六、配置如何合并(读配置的顺序)

  1. 读取 config/base.json
  2. TEST_ENV(默认 dev)读取 config/<TEST_ENV>.json 覆盖。
  3. .env 优先覆盖部分键:BASE_URLbase_urlLOGIN_PATHlogin_pathHEADLESSheadless
  4. 必填键:base_urllogin_pathbrowsertimeout_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 HTML
  • allure-html:仅根据已有 allure-results 生成 HTML
  • allure-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.yamlpages/,一般不改本节文件;若需新增 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_URLTEST_PHONESTATIC_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_*.pytests/test_yaml_cases.py 已统一 parametrize 所有 YAML 用例)。下面按「先 Page、后 YAML」说明。


9.1 编写 pages/ 下 Page 文件的固定步骤

  1. 新建文件:在 pages/ 下创建 业务模块_page.py(文件名全小写+下划线,与 Python 模块规范一致)。
  2. 类名:习惯与文件对应,例如 order_page.pyclass OrderPage(BasePage)
  3. 继承与构造class XxxPage(BasePage)__init__只写引擎能注入的参数名:
    • 必有:pagesettings(与 AccountPageExampleOrdersPage 相同)。
    • 若类里要用 Playwright 的 HTTP 客户端取数(如登录 mock 验证码):增加 api_request 形参(与 LoginPage 相同);引擎会按形参名自动注入,不要__init__ 里写引擎不认识的参数。
  4. 选择器:用大写常量字符串保存;多条候选用 || 拼接(见 BasePage.first_visible_locator)。优先 data-testidrolename,少依赖易变的 nth-child 长链。
  5. 方法:每个「可被 YAML 调用」的方法,参数应能通过 kwargs 传入(名称与 YAML 里键名一致)。方法内用 self.click_first / self.fill_first / self.wait_first_visible / self.goto 等基类能力,断言可用 playwright.sync_api.expect
  6. 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_PHONESTATIC_OTP_CODEMOCK_OTP_API_URLPOST_LOGIN_URL_CONTAINS,以及 BASE_URLLOGIN_PATH(见 yaml_pom_engine)。新增变量需在 fixtures/conftest.pyenv_vars fixture 里从 os.getenv 读入。
普通字符串 kwargs 里可直接写 " /orders/list " 等,无需 {{ }}

9.3 编写 test-data/test_cases.yaml 的一条用例(字段逐项)

根文件必须是 YAML 数组,每个元素是一条用例,以 - 开头。

字段 是否必填 含义
name 用例标题;所有 enabled: true 的用例之间 name 不得重复(加载器在 yaml_case_loader 中校验)。
enabled 默认 truefalse 时整条跳过且不进入 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 从模板到可运行:推荐操作顺序

  1. 复制 pages/example_orders_page.py 为新文件(如 invoice_page.py),类改名为 InvoicePage,替换选择器与方法逻辑。
  2. 打开 test-data/example_orders_case.yaml,理解「先登录、再调自己的 Page」的写法。
  3. test-data/test_cases.yaml 末尾新增一条用例:page 改为你的模块与类,kwargs 与方法的参数对齐。
  4. 先设 enabled: true不要smoke,执行 python run.py test 单条调试用;稳定后再按需加 tags: [smoke]
  5. 若要用 {{MY_SECRET}} 这类新占位符:在 fixtures/conftest.pyenv_vars 中增加 values["MY_SECRET"] = os.getenv("MY_SECRET", "").strip(),并在 .env.example / 文档中说明。

9.5 切换运行环境(与 Page/YAML 的关系)

  • .envTEST_ENVdev/qa/prod**,或直接用 BASE_URL / LOGIN_PATH 覆盖;不改 YAML 即可切环境,因为 goto 使用合并后的 base_url
  • 同一套 YAML 可在多环境跑,只要各环境 .envconfig/<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

更多推荐