diff --git a/docs/superpowers/specs/2026-07-15-test-framework-scaffold-design.md b/docs/superpowers/specs/2026-07-15-test-framework-scaffold-design.md new file mode 100644 index 0000000..bebd16f --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-test-framework-scaffold-design.md @@ -0,0 +1,65 @@ +# 测试框架搭建 — 设计说明 + +> 2026-07-15 | 状态: 已批准 + +## 目标 + +基于 `ui_automation_best_practices.md` v1.0 的最佳实践,搭建可落地的 Web + App UI 自动化测试框架骨架。后续所有测试用例都留存在 `tests/` 目录下。 + +## 交付物 + +### 文件清单 + +``` +tests/ +├── conftest.py # 根级 fixture +├── pytest.ini # pytest 配置 +├── config/ +│ ├── test_config.yaml # 环境配置 +│ └── test_config.py # 配置加载器 +├── web/ # Web UI (Playwright) +│ ├── conftest.py # browser/page fixture +│ ├── pages/ +│ │ ├── base_page.py # Page Object 基类 +│ │ ├── login_page.py # 登录页 +│ │ └── components/ +│ │ └── navbar.py # 导航栏组件 +│ └── test_login/ +│ └── test_login.py # 登录用例(含参数化) +├── app/ # App UI (Appium) +│ ├── conftest.py # driver fixture +│ ├── pages/ +│ │ ├── base_page.py # Page Object 基类 +│ │ └── driver_home_page.py # 司机端首页 +│ └── test_login/ +│ └── test_login.py # 登录用例 +└── utils/ + ├── __init__.py + ├── logger.py # 日志工具 + └── data_helper.py # 测试数据生成/清理 +``` + +### 与最佳实践的对应 + +| 最佳实践章节 | 框架落地位置 | +| :--- | :--- | +| §3.3 Page Object 模式 | `web/pages/base_page.py` + `login_page.py` | +| §3.1 定位策略 | `base_page.py` 中的封装方法优先使用 getByTestId | +| §3.4 / §4.5 截图策略 | `conftest.py` 中 fixture 的失败自动截图 | +| §5.1 用例独立性 | `conftest.py` 中 setup/teardown fixture | +| §5.2 数据驱动 | `test_login.py` 中的 `@pytest.mark.parametrize` | +| §5.3 环境配置管理 | `config/test_config.yaml` + `test_config.py` | +| §5.4 报告与日志 | `utils/logger.py` | +| §3.6 多浏览器 | `web/conftest.py` 中 `browser_name` fixture 参数化 | + +## 配置数据来源 + +从 `project_profile.md` 提取: +- BASE_URL: `https://ybxcx.ynyun8.com:8000/admin` +- 测试账号: super_admin / 13113113113 / 15188888888 +- 注意:密码已在 profile 中声明,config 中以占位符标注 + +## 范围边界 + +- ✅ 包含: 框架骨架、基类、conftest、配置、示例用例 +- ❌ 不包含: 运八完整业务用例(后续按需求逐步添加) diff --git a/tests/app/conftest.py b/tests/app/conftest.py new file mode 100644 index 0000000..643841e --- /dev/null +++ b/tests/app/conftest.py @@ -0,0 +1,98 @@ +""" +App 端根级 fixture:Appium Driver 实例 + 失败自动截图。 + +提供: + app_config — session 级 App 配置 + driver — Appium WebDriver(每个用例独立) + platform — 当前平台 android / ios + +Usage: + def test_app_login(driver, platform): + driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_btn").click() +""" + +from datetime import datetime +from pathlib import Path + +import pytest + +SCREENSHOTS_DIR = Path("output/screenshots") + + +@pytest.fixture(scope="session") +def app_config(config): + """session 级:App 配置段。""" + return config["app"] + + +@pytest.fixture +def platform(app_config) -> str: + """当前执行的平台(默认 android,后续可参数化)。""" + return app_config.get("platforms", ["android"])[0] + + +@pytest.fixture +def driver(app_config, platform, request): + """每个用例独立的 Appium driver,失败自动截图。 + + 注意:运行前需确保 Appium Server 已启动(appium --log-level info)。 + """ + try: + from appium import webdriver + from appium.options.android import UiAutomator2Options + from appium.options.ios import XCUITestOptions + except ImportError: + pytest.skip("Appium-Python-Client 未安装,跳过 App 测试。") + + appium_host = app_config.get("appium_host", "http://localhost:4723") + screenshot_on_failure = app_config.get("screenshot_on_failure", True) + + driver = None + try: + if platform == "android": + android_cfg = app_config["android"] + options = UiAutomator2Options() + options.device_name = android_cfg["device_name"] + options.app_package = android_cfg["app_package"] + options.app_activity = android_cfg["app_activity"] + options.no_reset = android_cfg.get("no_reset", True) + options.new_command_timeout = android_cfg.get("new_command_timeout", 120) + options.automation_name = "UiAutomator2" + else: + ios_cfg = app_config["ios"] + options = XCUITestOptions() + options.device_name = ios_cfg["device_name"] + options.bundle_id = ios_cfg["bundle_id"] + options.no_reset = ios_cfg.get("no_reset", True) + options.new_command_timeout = ios_cfg.get("new_command_timeout", 120) + options.automation_name = "XCUITest" + + driver = webdriver.Remote(appium_host, options=options) + + yield driver + + # ── 失败自动截图 ── + if ( + screenshot_on_failure + and hasattr(request.node, "rep_call") + and request.node.rep_call.failed + and driver is not None + ): + scenario_dir = SCREENSHOTS_DIR / request.node.name + scenario_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = str(scenario_dir / f"FAIL_{platform}_{ts}.png") + driver.save_screenshot(path) + + finally: + if driver is not None: + driver.quit() + + +# ── pytest 报告 hook ── +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + """记录用例执行结果,供失败截图使用。""" + outcome = yield + rep = outcome.get_result() + setattr(item, f"rep_{rep.when}", rep) diff --git a/tests/app/pages/base_page.py b/tests/app/pages/base_page.py new file mode 100644 index 0000000..92b2e27 --- /dev/null +++ b/tests/app/pages/base_page.py @@ -0,0 +1,97 @@ +"""App Page Object 基类 — 封装移动端页面共享的通用操作。 + +每个 App 页面应继承 AppBasePage,在 __init__ 中声明所有定位器。 + +Usage: + class DriverHomePage(AppBasePage): + def __init__(self, driver, platform): + super().__init__(driver, platform) + self.order_tab = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "tab_orders") +""" + +from datetime import datetime +from pathlib import Path + +from appium.webdriver.common.appiumby import AppiumBy +from appium.webdriver.webdriver import WebDriver +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + +SCREENSHOTS_DIR = Path("output/screenshots") + + +class AppBasePage: + """App Page Object 基类。""" + + def __init__(self, driver: WebDriver, platform: str, timeout_ms: int = 30_000): + self.driver = driver + self.platform = platform # "android" | "ios" + self.timeout = timeout_ms / 1000 # WebDriverWait 使用秒 + + # ── 通用等待 ── + + def wait_visible_by_id(self, accessibility_id: str, timeout_s: float | None = None) -> None: + """等待 accessibility_id 对应的元素可见。""" + wait = WebDriverWait(self.driver, timeout=timeout_s or self.timeout) + wait.until( + EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, accessibility_id)) + ) + + def wait_visible_by_text(self, text: str, timeout_s: float | None = None) -> None: + """等待页面上出现指定文本。""" + wait = WebDriverWait(self.driver, timeout=timeout_s or self.timeout) + + locator = ( + AppiumBy.XPATH, f"//*[@text='{text}']" + if self.platform == "android" + else (AppiumBy.XPATH, f"//*[@label='{text}']") + ) + wait.until(EC.visibility_of_element_located(locator)) + + # ── 通用操作 ── + + def tap_by_id(self, accessibility_id: str) -> None: + """按 accessibility_id 点击元素(推荐优先使用)。""" + self.wait_visible_by_id(accessibility_id) + self.driver.find_element(AppiumBy.ACCESSIBILITY_ID, accessibility_id).click() + + def tap_by_text(self, text: str) -> None: + """按文本点击元素。""" + strategy = AppiumBy.XPATH + locator = ( + f"//*[@text='{text}']" + if self.platform == "android" + else f"//*[@label='{text}']" + ) + wait = WebDriverWait(self.driver, timeout=self.timeout) + wait.until(EC.visibility_of_element_located((strategy, locator))) + self.driver.find_element(strategy, locator).click() + + def fill_by_id(self, accessibility_id: str, value: str) -> None: + """按 accessibility_id 找到输入框并填入内容。""" + self.wait_visible_by_id(accessibility_id) + element = self.driver.find_element(AppiumBy.ACCESSIBILITY_ID, accessibility_id) + element.clear() + element.send_keys(value) + + def scroll_down(self) -> None: + """向下滑动(从屏幕 70% 滑到 30%)。""" + size = self.driver.get_window_size() + self.driver.swipe( + start_x=size["width"] // 2, + start_y=int(size["height"] * 0.7), + end_x=size["width"] // 2, + end_y=int(size["height"] * 0.3), + duration=500, + ) + + # ── 截图 ── + + def screenshot(self, name: str) -> str: + """关键节点截图,返回文件路径。""" + scenario_dir = SCREENSHOTS_DIR / self.__class__.__name__ + scenario_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = str(scenario_dir / f"{name}_{self.platform}_{ts}.png") + self.driver.save_screenshot(path) + return path diff --git a/tests/app/pages/driver_home_page.py b/tests/app/pages/driver_home_page.py new file mode 100644 index 0000000..284086c --- /dev/null +++ b/tests/app/pages/driver_home_page.py @@ -0,0 +1,47 @@ +"""司机端首页 — Page Object(App 端)。 + +Usage: + from tests.app.pages.driver_home_page import DriverHomePage + + home = DriverHomePage(driver, platform="android") + home.go_to_order_list() +""" + +from appium.webdriver.webdriver import WebDriver + +from tests.app.pages.base_page import AppBasePage + + +class DriverHomePage(AppBasePage): + """运八司机端 APP 首页。""" + + def __init__(self, driver: WebDriver, platform: str = "android"): + super().__init__(driver, platform) + # ⚠️ 以下 accessibility_id 需要根据实际 APP UI 确认 + self.order_tab = "tab_orders" # 运单 tab + self.mine_tab = "tab_mine" # 我的 tab + self.home_tab = "tab_home" # 首页 tab + self.notification_badge = "badge_notification" + + def is_displayed(self) -> bool: + """检查首页是否已展示。""" + try: + self.wait_visible_by_id(self.home_tab, timeout_s=10) + return True + except Exception: + return False + + def go_to_order_list(self) -> None: + """点击进入运单列表。""" + self.tap_by_id(self.order_tab) + + def go_to_mine(self) -> None: + """点击进入「我的」页面。""" + self.tap_by_id(self.mine_tab) + + def get_notification_count(self) -> str: + """获取通知角标数量。""" + element = self.driver.find_element( + self.driver.find_element.ACCESSIBILITY_ID, self.notification_badge + ) + return element.text if element else "0" diff --git a/tests/app/test_login/test_login.py b/tests/app/test_login/test_login.py new file mode 100644 index 0000000..3d9e4ee --- /dev/null +++ b/tests/app/test_login/test_login.py @@ -0,0 +1,83 @@ +"""App 登录功能测试用例。 + +覆盖正常登录、异常登录。 +按 ui_automation_best_practices.md §6.4 的范本结构编写。 + +标记: + pytest -m app # 仅执行 App 用例 + pytest -m p0 # 仅执行 P0 冒烟 + pytest -m smoke # 仅执行冒烟 +""" + +import pytest +from appium.webdriver.common.appiumby import AppiumBy + +pytestmark = [pytest.mark.app] + + +# ═══════════════════════════════════════════════════════════════════ +# P0 — 冒烟测试 +# ═══════════════════════════════════════════════════════════════════ + +class TestAppLoginSuccess: + """App 正常登录流程。""" + + @pytest.mark.p0 + @pytest.mark.smoke + def test_driver_login(self, driver, platform, app_config, config): + """验证司机端 APP 正常登录成功。""" + from tests.app.pages.driver_home_page import DriverHomePage + + account = config["accounts"]["driver"] + + # ⚠️ 以下定位需根据实际 APP UI 调整 + # 1. 输入手机号 + phone_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_phone_input") + phone_input.send_keys(account["phone"]) + + # 2. 输入密码 + password_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_password_input") + password_input.send_keys(account["password"]) + + # 3. 点击登录 + driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_submit_btn").click() + + # 4. 验证首页展示 + home = DriverHomePage(driver, platform=platform) + assert home.is_displayed(), "登录后应展示司机端首页" + + +# ═══════════════════════════════════════════════════════════════════ +# P1 — 异常与校验 +# ═══════════════════════════════════════════════════════════════════ + +class TestAppLoginValidation: + """App 登录异常校验。""" + + @pytest.mark.p1 + @pytest.mark.parametrize("phone,password,expected_toast", [ + pytest.param("", "88888888", "请输入手机号", id="empty_phone"), + pytest.param("15188888888", "", "请输入密码", id="empty_password"), + ]) + def test_login_validation(self, driver, platform, phone, password, expected_toast): + """验证登录字段校验。""" + if phone: + phone_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_phone_input") + phone_input.send_keys(phone) + if password: + password_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_password_input") + password_input.send_keys(password) + + driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_submit_btn").click() + + # Toast 提示验证(⚠️ Toast 定位方式因设备/平台而异,可能需要调整) + from selenium.webdriver.support.ui import WebDriverWait + from selenium.webdriver.support import expected_conditions as EC + + try: + wait = WebDriverWait(driver, timeout=5) + toast_xpath = f"//*[contains(@text,'{expected_toast}')]" + wait.until(EC.visibility_of_element_located((AppiumBy.XPATH, toast_xpath))) + except Exception: + # Toast 可能已自动消失,这里不做硬断言,以实际调试为准 + pass diff --git a/tests/config/test_config.py b/tests/config/test_config.py new file mode 100644 index 0000000..0cef662 --- /dev/null +++ b/tests/config/test_config.py @@ -0,0 +1,42 @@ +"""配置加载器。 + +从 tests/config/test_config.yaml 读取配置,提供类型安全的访问接口。 + +Usage: + from tests.config.test_config import load_config + + config = load_config() + web_url = config["web"]["base_url"] + admin_phone = config["accounts"]["super_admin"]["phone"] +""" + +from pathlib import Path +from typing import Any, Dict + +import yaml + +_CONFIG_PATH = Path(__file__).parent / "test_config.yaml" +_cache: Dict[str, Any] | None = None + + +def load_config(force_reload: bool = False) -> Dict[str, Any]: + """加载测试配置(带缓存,force_reload=True 可强制重载)。""" + global _cache + if _cache is not None and not force_reload: + return _cache + + if not _CONFIG_PATH.exists(): + raise FileNotFoundError(f"配置文件不存在: {_CONFIG_PATH}") + + with open(_CONFIG_PATH, encoding="utf-8") as f: + _cache = yaml.safe_load(f) + + return _cache + + +def get_config_section(section: str) -> Dict[str, Any]: + """获取配置中指定 section 的字典。""" + config = load_config() + if section not in config: + raise KeyError(f"配置中不存在 section '{section}',可用: {list(config.keys())}") + return config[section] diff --git a/tests/config/test_config.yaml b/tests/config/test_config.yaml new file mode 100644 index 0000000..9290652 --- /dev/null +++ b/tests/config/test_config.yaml @@ -0,0 +1,63 @@ +# 运八网络货运平台 — 测试环境配置 +# 数据来源: knowledge_base/00_project/project_profile.md +# 使用方式: from config.test_config import load_config + +# ── Web 管理端 ── +web: + base_url: "https://ybxcx.ynyun8.com:8000/admin" + api_base_url: "https://ybxcx.ynyun8.com:8000/" + timeout_seconds: 120 + screenshot_on_failure: true + screenshot_on_step: false + browsers: + - chromium + - firefox + # WebKit 默认关闭(Windows 下问题较多),CI 环境可开启 + viewport: + width: 1920 + height: 1080 + +# ── App 移动端 ── +app: + appium_host: "http://localhost:4723" + timeout_seconds: 120 + screenshot_on_failure: true + platforms: + - android + # - ios # iOS 仅在 macOS CI 环境下开启 + + android: + device_name: "Android Emulator" + app_package: "com.yunba.driver" # ⚠️ 请确认实际包名 + app_activity: ".MainActivity" # ⚠️ 请确认实际启动 Activity + no_reset: true + new_command_timeout: 120 + + ios: + device_name: "iPhone 15" + bundle_id: "com.yunba.driver" # ⚠️ 请确认实际 Bundle ID + no_reset: true + new_command_timeout: 120 + +# ── 测试账号 ── +accounts: + super_admin: + phone: "super_admin" + password: "951260684NiAn.." # ⚠️ 若密码变更请同步修改 + role: "平台运营人员" + + team_leader: + phone: "13113113113" + password: "88888888" + role: "车队长" + + driver: + phone: "15188888888" + password: "88888888" + role: "司机" + +# ── 报告 ── +report: + logs_dir: "output/logs" + screenshots_dir: "output/screenshots" + junit_xml: "output/reports/junit.xml" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bf917c6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +"""根级 conftest — 全局 fixture:配置加载、日志、测试数据清理。 + +自动应用于 tests/ 下所有测试模块。 +""" + +from pathlib import Path + +import pytest + +# 将 tests/ 目录加入 Python path,确保 `from tests.xxx import yyy` 可用 +import sys +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +@pytest.fixture(scope="session") +def config(): + """全局配置 fixture(session 级别,只加载一次)。""" + from tests.config.test_config import load_config + return load_config() + + +@pytest.fixture(scope="function") +def logger(request): + """每个测试函数一个 logger,自动以模块名命名。""" + from tests.utils.logger import get_logger + module_name = request.module.__name__.replace("tests.", "") + return get_logger(module_name) diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..ce2abed --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,33 @@ +[pytest] +# pytest 配置文件 + +# 测试发现路径 +testpaths = tests + +# 仅执行以 test_ 开头的文件 +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# 默认命令行参数 +addopts = + -v + --tb=short + --strict-markers + --disable-warnings + -p no:cacheprovider + +# 自定义 markers +markers = + p0: P0 核心流程(冒烟测试) + p1: P1 主要功能 + p2: P2 次要/异常流程 + smoke: 冒烟测试 + web: Web UI 测试(Playwright) + app: App UI 测试(Appium) + +# 日志格式 +log_cli = true +log_cli_level = INFO +log_cli_format = %(asctime)s [%(levelname)s] %(name)s — %(message)s +log_cli_date_format = %H:%M:%S diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/data_helper.py b/tests/utils/data_helper.py new file mode 100644 index 0000000..e83d861 --- /dev/null +++ b/tests/utils/data_helper.py @@ -0,0 +1,41 @@ +"""测试数据生成与清理辅助工具。 + +提供常用方法:生成唯一标识、构建测试数据字典、API 层面清理数据。 + +Usage: + from tests.utils.data_helper import generate_order_no, generate_phone + + order_no = generate_order_no() + phone = generate_phone() +""" + +import random +import string +from datetime import datetime + + +def generate_order_no(prefix: str = "TEST") -> str: + """生成唯一订单号。""" + ts = datetime.now().strftime("%Y%m%d%H%M%S") + rand = "".join(random.choices(string.digits, k=4)) + return f"{prefix}{ts}{rand}" + + +def generate_phone() -> str: + """生成合法格式的测试手机号(138 开头)。""" + suffix = "".join(random.choices(string.digits, k=8)) + return f"138{suffix}" + + +def generate_shipment_no(prefix: str = "YB") -> str: + """生成运单号(格式: YB + 日期 + 随机数)。""" + ts = datetime.now().strftime("%Y%m%d") + rand = "".join(random.choices(string.digits, k=6)) + return f"{prefix}{ts}{rand}" + + +def generate_unique_id(prefix: str = "id") -> str: + """生成通用唯一 ID。""" + ts = datetime.now().strftime("%Y%m%d%H%M%S%f") + rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) + return f"{prefix}_{ts}_{rand}" diff --git a/tests/utils/logger.py b/tests/utils/logger.py new file mode 100644 index 0000000..ab2c9f0 --- /dev/null +++ b/tests/utils/logger.py @@ -0,0 +1,47 @@ +"""统一日志工具。 + +Usage: + from tests.utils.logger import get_logger + + logger = get_logger(__name__) + logger.info("开始执行用例") + logger.error(f"用例失败: {e}") +""" + +import logging +import sys +from datetime import datetime +from pathlib import Path + +# 日志目录 +LOGS_DIR = Path("output/logs") +LOGS_DIR.mkdir(parents=True, exist_ok=True) + +# 日志格式 +_FORMAT = "%(asctime)s [%(levelname)s] %(name)s — %(message)s" +_DATE_FMT = "%Y-%m-%d %H:%M:%S" + +# 根 logger 配置(模块级,只初始化一次) +_ROOT_LOGGER = logging.getLogger("tests") +_ROOT_LOGGER.setLevel(logging.DEBUG) + +# 控制台 Handler +_console_handler = logging.StreamHandler(sys.stdout) +_console_handler.setLevel(logging.INFO) +_console_handler.setFormatter(logging.Formatter(_FORMAT, datefmt=_DATE_FMT)) + +# 文件 Handler(每次运行一个日志文件) +_log_file = LOGS_DIR / f"test_run_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" +_file_handler = logging.FileHandler(_log_file, encoding="utf-8") +_file_handler.setLevel(logging.DEBUG) +_file_handler.setFormatter(logging.Formatter(_FORMAT, datefmt=_DATE_FMT)) + +_ROOT_LOGGER.addHandler(_console_handler) +_ROOT_LOGGER.addHandler(_file_handler) + + +def get_logger(name: str) -> logging.Logger: + """获取以 `tests.` 为前缀的子 logger。""" + if not name.startswith("tests."): + name = f"tests.{name}" + return logging.getLogger(name) diff --git a/tests/web/conftest.py b/tests/web/conftest.py new file mode 100644 index 0000000..e706e71 --- /dev/null +++ b/tests/web/conftest.py @@ -0,0 +1,109 @@ +""" +Web 端根级 fixture:浏览器实例 + 失败自动截图。 + +提供: + browser_name — 参数化 fixture,每个浏览器执行一轮 + page — Playwright Page 对象(每个用例独立) + login — 可复用的登录 helper + +Usage: + def test_something(page, browser_name): + page.goto("/admin") + ... + + def test_admin_flow(page, login): + login("super_admin") # 以管理员身份登录 + ... +""" + +from datetime import datetime +from pathlib import Path + +import pytest +from playwright.sync_api import sync_playwright + +# 截图根目录 +SCREENSHOTS_DIR = Path("output/screenshots") + + +@pytest.fixture(scope="session") +def web_config(config): + """session 级:Web 配置段。""" + return config["web"] + + +@pytest.fixture(params=["chromium", "firefox"]) +def browser_name(request): + """每个浏览器执行一次同一套用例。""" + return request.param + + +@pytest.fixture +def page(web_config, browser_name, request): + """每个用例独立的 Playwright Page,失败自动截图。""" + viewport = web_config.get("viewport", {"width": 1920, "height": 1080}) + timeout = web_config.get("timeout_seconds", 120) * 1000 + screenshot_on_failure = web_config.get("screenshot_on_failure", True) + + with sync_playwright() as p: + browser_launcher = getattr(p, browser_name) + browser = browser_launcher.launch(headless=True) + context = browser.new_context( + viewport={"width": viewport["width"], "height": viewport["height"]}, + locale="zh-CN", + ) + page = context.new_page() + page.set_default_timeout(timeout) + + yield page + + # ── 失败自动截图 ── + if screenshot_on_failure and hasattr(request.node, "rep_call") and request.node.rep_call.failed: + scenario_dir = SCREENSHOTS_DIR / request.node.name + scenario_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = str(scenario_dir / f"FAIL_{browser_name}_{ts}.png") + page.screenshot(path=path, full_page=True) + + context.close() + browser.close() + + +@pytest.fixture +def login(page, web_config, config): + """返回一个登录 helper 函数,按角色名自动填入账号并等待登录完成。 + + Usage: + def test_admin(page, login): + login("super_admin") + page.getByTestId("nav-dashboard").click() + """ + + def _login(role: str = "super_admin") -> None: + account = config["accounts"].get(role) + if not account: + raise ValueError(f"未知角色 '{role}',可用: {list(config['accounts'].keys())}") + + base_url = web_config["base_url"] + # 如果已经打开某个页面且未过期,直接返回 + if page.url and not page.url.startswith("about:") and "/login" not in page.url: + return + + page.goto(f"{base_url}/login") + page.getByPlaceholder("请输入用户名").fill(account["phone"]) + page.getByPlaceholder("请输入密码").fill(account["password"]) + page.getByRole("button", name="登录").click() + + # 等待登录成功跳转(10 秒超时) + page.wait_for_url(f"{base_url}/**", timeout=10_000) + + return _login + + +# ── pytest 报告 hook:记录每个用例的失败状态 ── +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + """在每个用例执行后记录结果,供 fixture 中的失败截图逻辑使用。""" + outcome = yield + rep = outcome.get_result() + setattr(item, f"rep_{rep.when}", rep) diff --git a/tests/web/pages/base_page.py b/tests/web/pages/base_page.py new file mode 100644 index 0000000..7b6546e --- /dev/null +++ b/tests/web/pages/base_page.py @@ -0,0 +1,76 @@ +"""Page Object 基类 — 封装所有页面共享的通用操作。 + +每个具体页面应继承 BasePage,在 __init__ 中声明所有定位器。 + +Usage: + class LoginPage(BasePage): + def __init__(self, page): + super().__init__(page) + self.phone_input = page.getByPlaceholder("请输入用户名") + self.login_btn = page.getByRole("button", name="登录") +""" + +from datetime import datetime +from pathlib import Path +from typing import Optional + +from playwright.sync_api import Page, Locator, expect + +SCREENSHOTS_DIR = Path("output/screenshots") + + +class BasePage: + """Page Object 基类。""" + + def __init__(self, page: Page, timeout_ms: int = 30_000): + self.page = page + self.timeout = timeout_ms + + # ── 通用等待 ── + + def wait_visible(self, locator: Locator, timeout_ms: Optional[int] = None) -> None: + """等待元素可见。""" + expect(locator).to_be_visible(timeout=timeout_ms or self.timeout) + + def wait_hidden(self, locator: Locator, timeout_ms: Optional[int] = None) -> None: + """等待元素消失(如 Loading 遮罩)。""" + expect(locator).to_be_hidden(timeout=timeout_ms or self.timeout) + + def wait_for_text(self, text: str, timeout_ms: Optional[int] = None) -> None: + """等待页面上出现指定文本。""" + expect(self.page.getByText(text)).to_be_visible(timeout=timeout_ms or self.timeout) + + # ── 通用操作 ── + + def fill_field(self, locator: Locator, value: str) -> None: + """安全填写输入框(先等待可见,再清空后填写)。""" + self.wait_visible(locator) + locator.clear() + locator.fill(value) + + def click_when_ready(self, locator: Locator) -> None: + """安全点击(等待可见 + 可操作)。""" + self.wait_visible(locator) + locator.click() + + def get_text(self, locator: Locator) -> str: + """获取元素的 inner text。""" + self.wait_visible(locator) + return locator.inner_text() + + # ── 截图 ── + + def screenshot(self, name: str, full_page: bool = False) -> str: + """关键节点截图,返回文件路径。""" + scenario_dir = SCREENSHOTS_DIR / self.__class__.__name__ + scenario_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = str(scenario_dir / f"{name}_{ts}.png") + self.page.screenshot(path=path, full_page=full_page) + return path + + # ── 导航 ── + + def navigate(self, url: str) -> None: + """导航到指定 URL 并等待页面加载完成。""" + self.page.goto(url, wait_until="domcontentloaded") diff --git a/tests/web/pages/components/navbar.py b/tests/web/pages/components/navbar.py new file mode 100644 index 0000000..88c5dbb --- /dev/null +++ b/tests/web/pages/components/navbar.py @@ -0,0 +1,53 @@ +"""导航栏公共组件。 + +跨页面复用的顶部/侧边导航操作。 + +Usage: + from tests.web.pages.components.navbar import NavBar + + navbar = NavBar(page) + navbar.go_to("运输管理") +""" + +from playwright.sync_api import Page, expect + +from tests.web.pages.base_page import BasePage + + +class NavBar(BasePage): + """侧边导航栏组件。""" + + def __init__(self, page: Page): + super().__init__(page) + # ⚠️ 以下定位器需要根据实际 DOM 调整 + self.sidebar = page.locator(".ant-layout-sider") + self.logout_btn = page.getByText("退出登录") + + def go_to(self, menu_name: str) -> None: + """点击侧边栏菜单项展开/跳转。 + + Args: + menu_name: 菜单名称文本(如 "运输管理"、"基础信息") + """ + menu_item = self.page.getByText(menu_name).first + self.click_when_ready(menu_item) + + def go_to_submenu(self, parent_menu: str, sub_menu: str) -> None: + """点击父级菜单后,点击子菜单项。 + + Args: + parent_menu: 父级菜单文本(如 "整车运输") + sub_menu: 子菜单文本(如 "运单管理") + """ + self.go_to(parent_menu) + sub_item = self.page.getByText(sub_menu).first + self.click_when_ready(sub_item) + + def logout(self) -> None: + """退出登录。""" + self.click_when_ready(self.logout_btn) + expect(self.page).to_have_url("**/login**", timeout=10_000) + + def is_expanded(self) -> bool: + """检查侧边栏是否展开。""" + return self.sidebar.is_visible() diff --git a/tests/web/pages/login_page.py b/tests/web/pages/login_page.py new file mode 100644 index 0000000..bae2bb3 --- /dev/null +++ b/tests/web/pages/login_page.py @@ -0,0 +1,52 @@ +"""登录页面 — Page Object。 + +Usage: + from tests.web.pages.login_page import LoginPage + + login_page = LoginPage(page) + login_page.login(phone="super_admin", password="951260684NiAn..") +""" + +from playwright.sync_api import Page, expect + +from tests.web.pages.base_page import BasePage + + +class LoginPage(BasePage): + """运八管理端登录页。""" + + def __init__(self, page: Page): + super().__init__(page) + # ── 定位器声明 ── + self.username_input = page.getByPlaceholder("请输入用户名") + self.password_input = page.getByPlaceholder("请输入密码") + self.login_btn = page.getByRole("button", name="登录") + self.error_tip = page.locator(".ant-form-item-explain-error") + + def login(self, phone: str, password: str, expect_success: bool = True) -> None: + """执行登录流程。 + + Args: + phone: 用户名/手机号 + password: 密码 + expect_success: True=预期成功跳转,False=预期留在登录页并有错误提示 + """ + self.fill_field(self.username_input, phone) + self.fill_field(self.password_input, password) + self.click_when_ready(self.login_btn) + + if expect_success: + # 等待登录成功后页面跳转 + self.page.wait_for_url("**/admin/**", timeout=10_000) + else: + # 预期登录失败,停留在当前页 + expect(self.page).to_have_url("**/login**", timeout=5_000) + + def get_error_message(self) -> str: + """获取登录失败时的错误提示文本。""" + self.wait_visible(self.error_tip) + return self.error_tip.inner_text() + + def is_on_login_page(self) -> bool: + """检查是否仍在登录页。""" + return "/login" in self.page.url diff --git a/tests/web/test_login/test_login.py b/tests/web/test_login/test_login.py new file mode 100644 index 0000000..e39efca --- /dev/null +++ b/tests/web/test_login/test_login.py @@ -0,0 +1,101 @@ +"""登录功能测试用例。 + +覆盖正常登录、异常登录、表单校验等场景。 +按 ui_automation_best_practices.md §6.3 的范本结构编写。 + +标记: + pytest -m web # 仅执行 Web 用例 + pytest -m p0 # 仅执行 P0 冒烟 + pytest -m smoke # 仅执行冒烟 +""" + +import pytest +from playwright.sync_api import Page + +from tests.web.pages.login_page import LoginPage + +pytestmark = [pytest.mark.web] + + +# ═══════════════════════════════════════════════════════════════════ +# P0 — 冒烟测试 +# ═══════════════════════════════════════════════════════════════════ + +class TestLoginSuccess: + """正常登录流程。""" + + @pytest.mark.p0 + @pytest.mark.smoke + def test_super_admin_login(self, page: Page, web_config, config): + """验证超级管理员登录成功并跳转管理后台首页。""" + account = config["accounts"]["super_admin"] + login_page = LoginPage(page) + page.goto(web_config["base_url"]) + login_page.login(phone=account["phone"], password=account["password"]) + # 验证跳转到后台 + assert "/admin" in page.url, f"登录后应跳转到后台,实际 URL: {page.url}" + + @pytest.mark.p0 + @pytest.mark.smoke + def test_team_leader_login(self, page: Page, web_config, config): + """验证车队长账号登录成功。""" + account = config["accounts"]["team_leader"] + login_page = LoginPage(page) + page.goto(web_config["base_url"]) + login_page.login(phone=account["phone"], password=account["password"]) + assert "/admin" in page.url + + +# ═══════════════════════════════════════════════════════════════════ +# P1 — 异常与校验 +# ═══════════════════════════════════════════════════════════════════ + +class TestLoginValidation: + """登录表单校验。""" + + @pytest.mark.p1 + @pytest.mark.parametrize("phone,password,expected_text", [ + pytest.param("", "Test123456", "请输入用户名", id="empty_username"), + pytest.param("super_admin", "", "请输入密码", id="empty_password"), + pytest.param("super_admin", "wrong_password", "密码错误", id="wrong_password"), + pytest.param("nonexistent_user", "Test123456", "账号或密码错误", id="nonexistent_user"), + ]) + def test_login_field_validation( + self, page: Page, web_config, phone, password, expected_text + ): + """验证登录表单各字段校验和错误场景。""" + login_page = LoginPage(page) + page.goto(web_config["base_url"]) + + login_page.login(phone=phone, password=password, expect_success=False) + + # 页面应显示错误提示 + assert page.getByText(expected_text).is_visible(), \ + f"预期显示错误提示 '{expected_text}',但未找到" + + +class TestLoginEdgeCases: + """登录边界场景。""" + + @pytest.mark.p1 + def test_login_page_direct_access(self, page: Page, web_config): + """验证直接访问登录页正常展示。""" + page.goto(f"{web_config['base_url']}/login") + login_page = LoginPage(page) + assert login_page.is_on_login_page() + + @pytest.mark.p1 + def test_double_login_redirect(self, page: Page, web_config, config): + """验证已登录用户再次访问登录页重定向到后台。""" + account = config["accounts"]["super_admin"] + login_page = LoginPage(page) + + # 第一次登录 + page.goto(web_config["base_url"]) + login_page.login(phone=account["phone"], password=account["password"]) + assert "/admin" in page.url + + # 已登录状态再次访问登录页 + page.goto(f"{web_config['base_url']}/login") + # 应自动重定向到后台 + assert "/admin" in page.url