feat: 重构测试框架 — 覆盖 6 端 (管理端 + 小程序 + 4 个移动端)

目录结构:
  tests/web/admin/       管理端 (Playwright)
  tests/web/mini_program/ 司机端小程序 (Playwright/微信 WebView)
  tests/app/driver_android/  司机安卓 (Appium)
  tests/app/shipper_android/ 货主安卓 (Appium)
  tests/app/driver_ios/      司机苹果 (Appium)
  tests/app/shipper_ios/     货主苹果 (Appium)

公共模块:
  tests/app/_shared.py       driver+ensure_logged_in 工厂
  tests/app/_base_page.py    App Page Object 基类

配置:
  test_config.yaml 拆分为 6 个独立 section + 货主账号
This commit is contained in:
xst
2026-07-15 14:02:08 +08:00
parent 4b4b83a2c0
commit 03e152baab
37 changed files with 823 additions and 727 deletions
@@ -1,19 +1,9 @@
"""App Page Object 基类 — 封装移动端页面共享的通用操作 """通用 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 datetime import datetime
from pathlib import Path from pathlib import Path
from appium.webdriver.common.appiumby import AppiumBy from appium.webdriver.common.appiumby import AppiumBy
from appium.webdriver.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
@@ -21,42 +11,33 @@ SCREENSHOTS_DIR = Path("output/screenshots")
class AppBasePage: class AppBasePage:
"""App Page Object 基类。""" """App Page Object 基类 — 各端通用"""
def __init__(self, driver: WebDriver, platform: str, timeout_ms: int = 30_000): def __init__(self, driver, platform: str, timeout_ms: int = 30_000):
self.driver = driver self.driver = driver
self.platform = platform # "android" | "ios" self.platform = platform # "android" | "ios"
self.timeout = timeout_ms / 1000 # WebDriverWait 使用秒 self.timeout = timeout_ms / 1000
# ── 通用等待 ──
def wait_visible_by_id(self, accessibility_id: str, timeout_s: float | None = None) -> None: 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 = WebDriverWait(self.driver, timeout=timeout_s or self.timeout)
wait.until( wait.until(
EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, accessibility_id)) EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, accessibility_id))
) )
def wait_visible_by_text(self, text: str, timeout_s: float | None = None) -> None: def wait_visible_by_text(self, text: str, timeout_s: float | None = None) -> None:
"""等待页面上出现指定文本。"""
wait = WebDriverWait(self.driver, timeout=timeout_s or self.timeout) wait = WebDriverWait(self.driver, timeout=timeout_s or self.timeout)
locator = ( locator = (
AppiumBy.XPATH, f"//*[@text='{text}']" (AppiumBy.XPATH, f"//*[@text='{text}']")
if self.platform == "android" if self.platform == "android"
else (AppiumBy.XPATH, f"//*[@label='{text}']") else (AppiumBy.XPATH, f"//*[@label='{text}']")
) )
wait.until(EC.visibility_of_element_located(locator)) wait.until(EC.visibility_of_element_located(locator))
# ── 通用操作 ──
def tap_by_id(self, accessibility_id: str) -> None: def tap_by_id(self, accessibility_id: str) -> None:
"""按 accessibility_id 点击元素(推荐优先使用)。"""
self.wait_visible_by_id(accessibility_id) self.wait_visible_by_id(accessibility_id)
self.driver.find_element(AppiumBy.ACCESSIBILITY_ID, accessibility_id).click() self.driver.find_element(AppiumBy.ACCESSIBILITY_ID, accessibility_id).click()
def tap_by_text(self, text: str) -> None: def tap_by_text(self, text: str) -> None:
"""按文本点击元素。"""
strategy = AppiumBy.XPATH strategy = AppiumBy.XPATH
locator = ( locator = (
f"//*[@text='{text}']" f"//*[@text='{text}']"
@@ -68,14 +49,12 @@ class AppBasePage:
self.driver.find_element(strategy, locator).click() self.driver.find_element(strategy, locator).click()
def fill_by_id(self, accessibility_id: str, value: str) -> None: def fill_by_id(self, accessibility_id: str, value: str) -> None:
"""按 accessibility_id 找到输入框并填入内容。"""
self.wait_visible_by_id(accessibility_id) self.wait_visible_by_id(accessibility_id)
element = self.driver.find_element(AppiumBy.ACCESSIBILITY_ID, accessibility_id) element = self.driver.find_element(AppiumBy.ACCESSIBILITY_ID, accessibility_id)
element.clear() element.clear()
element.send_keys(value) element.send_keys(value)
def scroll_down(self) -> None: def scroll_down(self) -> None:
"""向下滑动(从屏幕 70% 滑到 30%)。"""
size = self.driver.get_window_size() size = self.driver.get_window_size()
self.driver.swipe( self.driver.swipe(
start_x=size["width"] // 2, start_x=size["width"] // 2,
@@ -85,10 +64,7 @@ class AppBasePage:
duration=500, duration=500,
) )
# ── 截图 ──
def screenshot(self, name: str) -> str: def screenshot(self, name: str) -> str:
"""关键节点截图,返回文件路径。"""
scenario_dir = SCREENSHOTS_DIR / self.__class__.__name__ scenario_dir = SCREENSHOTS_DIR / self.__class__.__name__
scenario_dir.mkdir(parents=True, exist_ok=True) scenario_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S") ts = datetime.now().strftime("%Y%m%d_%H%M%S")
+125
View File
@@ -0,0 +1,125 @@
"""App 端公共模块 — driver 创建 + ensure_logged_in 工厂。
所有 App 端 conftest 调用 share_* 函数即可,避免重复代码。
"""
from datetime import datetime
from pathlib import Path
import pytest
SCREENSHOTS_DIR = Path("output/screenshots")
def create_driver(section_config: dict, platform: str):
"""创建 Appium driver。
Args:
section_config: 配置中对应端的 section(如 config["driver_android"]
platform: "android" | "ios"
"""
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 = section_config.get("appium_host", "http://localhost:4723")
if platform == "android":
options = UiAutomator2Options()
options.device_name = section_config["device_name"]
options.app_package = section_config["app_package"]
options.app_activity = section_config["app_activity"]
options.no_reset = section_config.get("no_reset", True)
options.new_command_timeout = section_config.get("new_command_timeout", 120)
options.automation_name = "UiAutomator2"
else:
options = XCUITestOptions()
options.device_name = section_config["device_name"]
options.bundle_id = section_config["bundle_id"]
options.no_reset = section_config.get("no_reset", True)
options.new_command_timeout = section_config.get("new_command_timeout", 120)
options.automation_name = "XCUITest"
return webdriver.Remote(appium_host, options=options)
def make_ensure_logged_in(
section_config: dict,
config: dict,
account_role: str,
home_indicator: str = "tab_home",
):
"""创建一个 ensure_logged_in 闭包。
Args:
section_config: App 端配置
config: 全局配置
account_role: 默认账号角色名(如 "driver""shipper"
home_indicator: 首页元素的 accessibility_id
"""
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from appium.webdriver.common.appiumby import AppiumBy
def _ensure_logged_in(driver, timeout_s: float = 10.0) -> None:
skip_login = section_config.get("skip_login", True)
wait = WebDriverWait(driver, timeout=timeout_s)
try:
wait.until(
EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, home_indicator))
)
return
except Exception:
pass
try:
login_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_phone_input")
if login_input.is_displayed():
if skip_login:
raise RuntimeError(
"APP 在登录页,未检测到登录态!请在设备上手动完成一次登录(含验证码),"
"之后重新运行用例即可。\n如需自动化登录,请设置 skip_login: false。"
)
account = config["accounts"].get(account_role, {})
login_input.send_keys(account.get("phone", ""))
driver.find_element(
AppiumBy.ACCESSIBILITY_ID, "login_password_input"
).send_keys(account.get("password", ""))
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_submit_btn").click()
wait.until(
EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, home_indicator))
)
return
except RuntimeError:
raise
except Exception:
pass
raise RuntimeError("无法确定 APP 的登录状态!请确保 APP 已启动且首页或登录页可见。")
return _ensure_logged_in
def save_failure_screenshot(driver, section_config, platform, request):
"""失败自动截图(各端 conftest teardown 中调用)。"""
if (
section_config.get("screenshot_on_failure", True)
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_{platform}_{ts}.png")
driver.save_screenshot(path)
@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)
-198
View File
@@ -1,198 +0,0 @@
"""
App 端根级 fixtureAppium Driver 实例 + 登录态复用 + 失败自动截图。
核心机制 — 手动登录后复用:
Appium noReset=true 确保 app 数据不重置,登录态持久保留在设备上。
只需手动在设备上登录一次,后续用例通过 ensure_logged_in 检查首页是否已登录态。
如果检测到未登录(在登录页),给出明确指引让用户先去设备上手动登录。
配置 app.skip_login: true 时:
- 用例通过 ensure_logged_in() 检查是否已登录
- 未登录则抛出明确错误,引导用户先去设备上手动登录
- 已登录则直接开始执行
配置 app.skip_login: false 时:
- 每次用例尝试完整 UI 登录(有验证码时会失败)
提供:
app_config — session 级 App 配置
driver — Appium WebDriver(每个用例独立,失败自动截图)
platform — 当前平台
"""
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:
"""当前执行的平台。"""
return app_config.get("platforms", ["android"])[0]
@pytest.fixture
def driver(app_config, platform, request):
"""每个用例独立的 Appium driver,失败自动截图。
前提: Appium Server 已启动(appium --log-level info)。
noReset=true → app 不重置 → 登录态持久保留。
"""
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.fixture
def ensure_logged_in(driver, platform, app_config, config):
"""确保 APP 已处于登录状态。
使用方式:
def test_xxx(driver, ensure_logged_in):
ensure_logged_in() # 检查登录态,未登录则报错
# 后面直接写业务断言
"""
def _ensure_logged_in(timeout_s: float = 10.0) -> None:
skip_login = app_config.get("skip_login", True)
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from appium.webdriver.common.appiumby import AppiumBy
# ⚠️ home_tab 的 accessibility_id 需根据实际 APP UI 确认
HOME_INDICATOR_ID = "tab_home" # 首页 tab 的 accessibility_id
wait = WebDriverWait(driver, timeout=timeout_s)
try:
wait.until(
EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, HOME_INDICATOR_ID))
)
# 已登录,无需操作
return
except Exception:
pass
# ── 未检测到首页元素,判断是否在登录页 ──
try:
# 尝试找登录页元素
login_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_phone_input")
if login_input.is_displayed():
if skip_login:
raise RuntimeError(
"APP 当前在登录页,未检测到登录态!\n"
"请在设备上手动完成一次登录(包含验证码),之后重新运行用例即可。\n"
"如需通过自动化登录,请设置 app.skip_login: false(不推荐,有验证码)。"
)
# skip_login: false — 走自动化登录
account = config["accounts"].get("driver")
if not account:
raise RuntimeError("配置中未找到 driver 账号")
login_input.send_keys(account["phone"])
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_password_input").send_keys(
account["password"]
)
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_submit_btn").click()
# 等待登录完成
wait.until(
EC.visibility_of_element_located(
(AppiumBy.ACCESSIBILITY_ID, HOME_INDICATOR_ID)
)
)
return
except RuntimeError:
raise # 重新抛出明确的错误提示
except Exception:
pass
raise RuntimeError(
f"无法确定 APP 的登录状态!请确保 APP 已启动且首页或登录页可见。\n"
f"当前平台: {platform}"
)
return _ensure_logged_in
@pytest.fixture
def is_logged_in(driver, platform, app_config) -> bool:
"""快速检查当前是否已登录(不执行登录操作)。"""
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from appium.webdriver.common.appiumby import AppiumBy
try:
wait = WebDriverWait(driver, timeout=5)
wait.until(
EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, "tab_home"))
)
return True
except Exception:
return False
# ── 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)
+27
View File
@@ -0,0 +1,27 @@
"""司机安卓端 fixture — Appium + 登录态复用。"""
import pytest
from tests.app._shared import create_driver, make_ensure_logged_in, save_failure_screenshot
from tests.app._shared import pytest_runtest_makereport # noqa: F401 — report hook
@pytest.fixture
def driver(driver_android_config, request):
"""司机安卓端 driver (noReset=true)。"""
cfg = driver_android_config
d = None
try:
d = create_driver(cfg, platform="android")
yield d
save_failure_screenshot(d, cfg, "android", request)
finally:
if d is not None:
d.quit()
@pytest.fixture
def ensure_logged_in(driver_android_config, config):
"""确保已登录态(司机角色)。"""
fn = make_ensure_logged_in(driver_android_config, config, account_role="driver")
return lambda d: fn(d)
@@ -0,0 +1,26 @@
"""司机安卓端 Page Object — 首页。"""
from tests.app._base_page import AppBasePage
class DriverHomePage(AppBasePage):
"""司机端 APP 首页。"""
def __init__(self, driver, platform: str = "android"):
super().__init__(driver, platform)
self.order_tab = "tab_orders"
self.mine_tab = "tab_mine"
self.home_tab = "tab_home"
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)
@@ -0,0 +1,18 @@
"""司机安卓端 — 冒烟测试。"""
import pytest
pytestmark = [pytest.mark.driver_android]
class TestDriverAndroidSmoke:
"""司机安卓端基础验证。"""
@pytest.mark.p0
@pytest.mark.smoke
def test_home_page_loaded(self, driver, ensure_logged_in):
"""验证登录态有效,首页正常。"""
ensure_logged_in(driver)
from appium.webdriver.common.appiumby import AppiumBy
home = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "tab_home")
assert home.is_displayed(), "首页 tab 应可见"
+27
View File
@@ -0,0 +1,27 @@
"""司机苹果端 fixture — Appium + 登录态复用。"""
import pytest
from tests.app._shared import create_driver, make_ensure_logged_in, save_failure_screenshot
from tests.app._shared import pytest_runtest_makereport # noqa: F401
@pytest.fixture
def driver(driver_ios_config, request):
"""司机苹果端 driver。"""
cfg = driver_ios_config
d = None
try:
d = create_driver(cfg, platform="ios")
yield d
save_failure_screenshot(d, cfg, "ios", request)
finally:
if d is not None:
d.quit()
@pytest.fixture
def ensure_logged_in(driver_ios_config, config):
"""确保已登录态(司机角色)。"""
fn = make_ensure_logged_in(driver_ios_config, config, account_role="driver")
return lambda d: fn(d)
+18
View File
@@ -0,0 +1,18 @@
"""司机苹果端 — 冒烟测试。"""
import pytest
pytestmark = [pytest.mark.driver_ios]
class TestDriverIosSmoke:
"""司机苹果端基础验证。"""
@pytest.mark.p0
@pytest.mark.smoke
def test_home_page_loaded(self, driver, ensure_logged_in):
"""验证登录态有效,首页正常。"""
ensure_logged_in(driver)
from appium.webdriver.common.appiumby import AppiumBy
home = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "tab_home")
assert home.is_displayed(), "首页 tab 应可见"
-47
View File
@@ -1,47 +0,0 @@
"""司机端首页 — Page ObjectApp 端)。
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"
+27
View File
@@ -0,0 +1,27 @@
"""货主安卓端 fixture — Appium + 登录态复用。"""
import pytest
from tests.app._shared import create_driver, make_ensure_logged_in, save_failure_screenshot
from tests.app._shared import pytest_runtest_makereport # noqa: F401
@pytest.fixture
def driver(shipper_android_config, request):
"""货主安卓端 driver。"""
cfg = shipper_android_config
d = None
try:
d = create_driver(cfg, platform="android")
yield d
save_failure_screenshot(d, cfg, "android", request)
finally:
if d is not None:
d.quit()
@pytest.fixture
def ensure_logged_in(shipper_android_config, config):
"""确保已登录态(货主角色)。"""
fn = make_ensure_logged_in(shipper_android_config, config, account_role="shipper")
return lambda d: fn(d)
@@ -0,0 +1,18 @@
"""货主安卓端 — 冒烟测试。"""
import pytest
pytestmark = [pytest.mark.shipper_android]
class TestShipperAndroidSmoke:
"""货主安卓端基础验证。"""
@pytest.mark.p0
@pytest.mark.smoke
def test_home_page_loaded(self, driver, ensure_logged_in):
"""验证登录态有效,首页正常。"""
ensure_logged_in(driver)
from appium.webdriver.common.appiumby import AppiumBy
home = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "tab_home")
assert home.is_displayed(), "首页 tab 应可见"
+27
View File
@@ -0,0 +1,27 @@
"""货主苹果端 fixture — Appium + 登录态复用。"""
import pytest
from tests.app._shared import create_driver, make_ensure_logged_in, save_failure_screenshot
from tests.app._shared import pytest_runtest_makereport # noqa: F401
@pytest.fixture
def driver(shipper_ios_config, request):
"""货主苹果端 driver。"""
cfg = shipper_ios_config
d = None
try:
d = create_driver(cfg, platform="ios")
yield d
save_failure_screenshot(d, cfg, "ios", request)
finally:
if d is not None:
d.quit()
@pytest.fixture
def ensure_logged_in(shipper_ios_config, config):
"""确保已登录态(货主角色)。"""
fn = make_ensure_logged_in(shipper_ios_config, config, account_role="shipper")
return lambda d: fn(d)
+18
View File
@@ -0,0 +1,18 @@
"""货主苹果端 — 冒烟测试。"""
import pytest
pytestmark = [pytest.mark.shipper_ios]
class TestShipperIosSmoke:
"""货主苹果端基础验证。"""
@pytest.mark.p0
@pytest.mark.smoke
def test_home_page_loaded(self, driver, ensure_logged_in):
"""验证登录态有效,首页正常。"""
ensure_logged_in(driver)
from appium.webdriver.common.appiumby import AppiumBy
home = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "tab_home")
assert home.is_displayed(), "首页 tab 应可见"
-108
View File
@@ -1,108 +0,0 @@
"""App 登录功能测试用例。
覆盖:
- (skip_login: true) 验证预登录态有效,用例直接复用已登录状态
- (skip_login: false) 完整 UI 登录流程
前置条件:
1. Appium Server 已启动: appium --log-level info
2. (推荐) 已在设备上手动登录一次,后续用例自动跳过登录
3. app.skip_login: true(默认)→ 用例通过 ensure_logged_in 检查登录态
4. app.skip_login: false → 每次执行完整 UI 登录(有验证码会失败)
标记:
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 TestAppSmoke:
"""验证 App 登录态有效,核心页面可正常访问。"""
@pytest.mark.p0
@pytest.mark.smoke
def test_already_logged_in(self, driver, platform, ensure_logged_in):
"""验证 APP 已处于登录状态(手动登录后 noReset 持久化)。"""
ensure_logged_in()
# 主页面 tab 应可见
home_tab = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "tab_home")
assert home_tab.is_displayed(), "登录后应展示首页 tab"
@pytest.mark.p0
@pytest.mark.smoke
def test_navigate_to_order_list(self, driver, platform, ensure_logged_in):
"""验证登录后可导航到运单列表。"""
from tests.app.pages.driver_home_page import DriverHomePage
ensure_logged_in()
home = DriverHomePage(driver, platform=platform)
home.go_to_order_list()
# 验证运单列表可见
# ⚠️ 定位需根据实际 APP UI 调整
order_list = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "order_list")
assert order_list.is_displayed(), "运单列表应可见"
@pytest.mark.p0
@pytest.mark.smoke
def test_tab_switching(self, driver, platform, ensure_logged_in):
"""验证底部 tab 可正常切换。"""
from tests.app.pages.driver_home_page import DriverHomePage
ensure_logged_in()
home = DriverHomePage(driver, platform=platform)
# 点击"我的"
home.go_to_mine()
# ⚠️ mine_page 的 accessibility_id 需根据实际 APP UI 确认
mine_view = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "mine_page")
assert mine_view.is_displayed(), "应展示「我的」页面"
# 切回首页
home.tap_by_id("tab_home")
assert home.is_displayed(), "应切回首页"
# ═══════════════════════════════════════════════════════════════════
# P1 — 登录页验证(仅在 skip_login: false 时有意义)
# ═══════════════════════════════════════════════════════════════════
class TestAppLoginPage:
"""登录页基础验证。"""
@pytest.mark.p1
def test_login_page_elements(self, driver, platform, app_config):
"""验证登录页元素存在(不执行登录)。"""
# ⚠️ 前提: app 当前在登录页(未登录状态)
# 如果已登录,此用例可能找不到登录页元素
from tests.app.conftest import is_logged_in
# 如果已登录,跳过此用例
try:
if is_logged_in:
pytest.skip("APP 已登录,无法验证登录页元素")
except Exception:
pass
# 验证登录页核心元素
try:
phone_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_phone_input")
assert phone_input.is_displayed(), "手机号输入框应可见"
password_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_password_input")
assert password_input.is_displayed(), "密码输入框应可见"
login_btn = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_submit_btn")
assert login_btn.is_displayed(), "登录按钮应可见"
except Exception:
pytest.skip("登录页元素未找到,APP 可能已登录或 UI 已变更")
+5 -1
View File
@@ -6,8 +6,12 @@ Usage:
from tests.config.test_config import load_config from tests.config.test_config import load_config
config = load_config() config = load_config()
web_url = config["web"]["base_url"] admin_url = config["admin"]["base_url"]
admin_phone = config["accounts"]["super_admin"]["phone"] admin_phone = config["accounts"]["super_admin"]["phone"]
各端通过 fixture 直接引用对应 section:
def test_xxx(admin_config):
page.goto(admin_config["base_url"])
""" """
from pathlib import Path from pathlib import Path
+100 -37
View File
@@ -1,9 +1,16 @@
# 运八网络货运平台 — 测试环境配置 # 运八网络货运平台 — 测试环境配置
# 数据来源: knowledge_base/00_project/project_profile.md # 数据来源: knowledge_base/00_project/project_profile.md
# 使用方式: from config.test_config import load_config # 使用方式: from tests.config.test_config import load_config
#
# 覆盖端:
# Web: 管理端 admin / 司机端小程序 mini_program
# App: 司机安卓 driver_android / 货主安卓 shipper_android
# 司机苹果 driver_ios / 货主苹果 shipper_ios
# ── Web 管理端 ── # ═══════════════════════════════════════════════════════════════════
web: # ── 管理端 (Web/Playwright) ──
# ═══════════════════════════════════════════════════════════════════
admin:
base_url: "https://ybxcx.ynyun8.com:8000/admin" base_url: "https://ybxcx.ynyun8.com:8000/admin"
api_base_url: "https://ybxcx.ynyun8.com:8000/" api_base_url: "https://ybxcx.ynyun8.com:8000/"
timeout_seconds: 120 timeout_seconds: 120
@@ -12,71 +19,127 @@ web:
browsers: browsers:
- chromium - chromium
- firefox - firefox
# WebKit 默认关闭(Windows 下问题较多),CI 环境可开启
viewport: viewport:
width: 1920 width: 1920
height: 1080 height: 1080
# 登录态复用(跳过图形验证码 # 登录态复用: python tests/web/admin/save_auth.py 生成后自动跳过验证码
# storage_state 文件保存了浏览器登录后的 cookies + localStorage
# 首次使用需手动登录一次生成,流程:
# 1. 运行: python tests/web/save_auth.py
# 2. 脚本打开浏览器 → 人工完成验证码登录 → 登录态自动保存
# 3. 后续执行用例时自动复用,无需重复登录
#
# skip_login: true → 加载已有的 storage_state,跳过登录步骤
# skip_login: false → 每次用例都通过 UI 执行完整登录流程
skip_login: true skip_login: true
storage_state_dir: "tests/web/auth" # 登录态存储目录 storage_state_dir: "tests/web/auth"
# ── App 移动端 ── # ═══════════════════════════════════════════════════════════════════
app: # ── 司机端小程序 (Web/Playwright,模拟微信 WebView) ──
# ═══════════════════════════════════════════════════════════════════
mini_program:
base_url: "https://ybxcx.ynyun8.com:8000/mini" # ⚠️ 请确认小程序 WebView 实际地址
timeout_seconds: 120
screenshot_on_failure: true
screenshot_on_step: false
browsers:
- chromium
# 模拟微信内置浏览器 UA
user_agent: "Mozilla/5.0 MicroMessenger/8.0.0"
viewport:
width: 375
height: 812
# 小程序本质是 WebView,同样支持 storage_state 复用
skip_login: true
storage_state_dir: "tests/web/auth"
# ═══════════════════════════════════════════════════════════════════
# ── 司机安卓端 (App/Appium) ──
# ═══════════════════════════════════════════════════════════════════
driver_android:
appium_host: "http://localhost:4723" appium_host: "http://localhost:4723"
timeout_seconds: 120 timeout_seconds: 120
screenshot_on_failure: true screenshot_on_failure: true
platforms: platform: android
- android skip_login: true # 手动登录一次后 noReset 自动复用
# - ios # iOS 仅在 macOS CI 环境下开启
# 登录态复用(跳过图形验证码) device_name: "Android Emulator"
# Appium noReset=true 已确保 app 数据不重置,登录态持久保留 app_package: "com.arpa.ynchenggangdriver" # ⚠️ 请确认实际包名
# 使用方式:手动启动 app 登录一次 → 后续用例自动跳过登录 app_activity: "com.arpa.ntocc.MainActivity" # ⚠️ 请确认实际启动 Activity
# no_reset: true
# skip_login: true → 用例直接检查首页是否已登录态,跳过登录步骤 new_command_timeout: 120
# skip_login: false → 每次用例都执行完整 UI 登录流程
# ═══════════════════════════════════════════════════════════════════
# ── 货主安卓端 (App/Appium) ──
# ═══════════════════════════════════════════════════════════════════
shipper_android:
appium_host: "http://localhost:4723"
timeout_seconds: 120
screenshot_on_failure: true
platform: android
skip_login: true skip_login: true
android: device_name: "Android Emulator"
device_name: "Android Emulator" app_package: "com.yunba.shipper" # ⚠️ 请确认实际包名
app_package: "com.yunba.driver" # ⚠️ 请确认实际包名 app_activity: ".MainActivity" # ⚠️ 请确认实际启动 Activity
app_activity: ".MainActivity" # ⚠️ 请确认实际启动 Activity no_reset: true
no_reset: true new_command_timeout: 120
new_command_timeout: 120
ios: # ═══════════════════════════════════════════════════════════════════
device_name: "iPhone 15" # ── 司机苹果端 (App/Appium,需 macOS) ──
bundle_id: "com.yunba.driver" # ⚠️ 请确认实际 Bundle ID # ═══════════════════════════════════════════════════════════════════
no_reset: true driver_ios:
new_command_timeout: 120 appium_host: "http://localhost:4723"
timeout_seconds: 120
screenshot_on_failure: true
platform: ios
skip_login: true
device_name: "iPhone 15"
bundle_id: "com.arpa.ynchenggangdriver" # ⚠️ 请确认实际 Bundle ID
no_reset: true
new_command_timeout: 120
# ═══════════════════════════════════════════════════════════════════
# ── 货主苹果端 (App/Appium,需 macOS) ──
# ═══════════════════════════════════════════════════════════════════
shipper_ios:
appium_host: "http://localhost:4723"
timeout_seconds: 120
screenshot_on_failure: true
platform: ios
skip_login: true
device_name: "iPhone 15"
bundle_id: "com.yunba.shipper" # ⚠️ 请确认实际 Bundle ID
no_reset: true
new_command_timeout: 120
# ═══════════════════════════════════════════════════════════════════
# ── 测试账号 ── # ── 测试账号 ──
# ═══════════════════════════════════════════════════════════════════
accounts: accounts:
super_admin: super_admin:
phone: "super_admin" phone: "super_admin"
password: "951260684NiAn.." # ⚠️ 若密码变更请同步修改 password: "951260684NiAn.."
role: "平台运营人员" role: "平台运营人员"
use_on: [admin]
team_leader: team_leader:
phone: "13113113113" phone: "13113113113"
password: "88888888" password: "88888888"
role: "车队长" role: "车队长"
use_on: [admin, mini_program]
driver: driver:
phone: "15188888888" phone: "15188888888"
password: "88888888" password: "88888888"
role: "司机" role: "司机"
use_on: [mini_program, driver_android, driver_ios]
shipper:
phone: "13800000000" # ⚠️ 请确认实际货主账号
password: "88888888"
role: "货主"
use_on: [shipper_android, shipper_ios]
# ═══════════════════════════════════════════════════════════════════
# ── 报告 ── # ── 报告 ──
# ═══════════════════════════════════════════════════════════════════
report: report:
logs_dir: "output/logs" logs_dir: "output/logs"
screenshots_dir: "output/screenshots" screenshots_dir: "output/screenshots"
+42 -6
View File
@@ -1,27 +1,63 @@
"""根级 conftest — 全局 fixture:配置加载、日志、测试数据清理 """根级 conftest — 全局 fixture:配置加载、日志。
自动应用于 tests/ 下所有测试模块。 Usage:
def test_xxx(admin_config): # 管理端配置
def test_xxx(driver_android_config): # 司机安卓配置
""" """
from pathlib import Path from pathlib import Path
import sys
import pytest import pytest
# 将 tests/ 目录加入 Python path,确保 `from tests.xxx import yyy` 可用
import sys
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def config(): def config():
"""全局配置 fixturesession 级,只加载一次)。""" """全局配置(session 级,只加载一次)。"""
from tests.config.test_config import load_config from tests.config.test_config import load_config
return load_config() return load_config()
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def logger(request): def logger(request):
"""每个测试函数一个 logger,自动以模块名命名""" """每个测试函数 logger。"""
from tests.utils.logger import get_logger from tests.utils.logger import get_logger
module_name = request.module.__name__.replace("tests.", "") module_name = request.module.__name__.replace("tests.", "")
return get_logger(module_name) return get_logger(module_name)
# ═══════════════════════════════════════════════════════════════════
# 各端配置 fixture(从 config 中摘取对应 section
# ═══════════════════════════════════════════════════════════════════
@pytest.fixture(scope="session")
def admin_config(config):
"""管理端 (Playwright) 配置。"""
return config["admin"]
@pytest.fixture(scope="session")
def mini_program_config(config):
"""司机端小程序 (Playwright) 配置。"""
return config["mini_program"]
@pytest.fixture(scope="session")
def driver_android_config(config):
"""司机安卓端 (Appium) 配置。"""
return config["driver_android"]
@pytest.fixture(scope="session")
def shipper_android_config(config):
"""货主安卓端 (Appium) 配置。"""
return config["shipper_android"]
@pytest.fixture(scope="session")
def driver_ios_config(config):
"""司机苹果端 (Appium) 配置。"""
return config["driver_ios"]
@pytest.fixture(scope="session")
def shipper_ios_config(config):
"""货主苹果端 (Appium) 配置。"""
return config["shipper_ios"]
+8 -2
View File
@@ -23,8 +23,14 @@ markers =
p1: P1 主要功能 p1: P1 主要功能
p2: P2 次要/异常流程 p2: P2 次要/异常流程
smoke: 冒烟测试 smoke: 冒烟测试
web: Web UI 测试(Playwright admin: 管理端 (Playwright)
app: App UI 测试(Appium mini_program: 司机端小程序 (Playwright)
driver_android: 司机安卓端 (Appium)
shipper_android: 货主安卓端 (Appium)
driver_ios: 司机苹果端 (Appium)
shipper_ios: 货主苹果端 (Appium)
appium: 所有 Appium 测试
playwright: 所有 Playwright 测试
# 日志格式 # 日志格式
log_cli = true log_cli = true
+126
View File
@@ -0,0 +1,126 @@
"""管理端 fixture — Playwright 浏览器实例 + 登录态复用。
核心机制:
skip_login: true → 加载 storage_state,跳过验证码
skip_login: false → 执行完整 UI 登录
初始化:
python tests/web/admin/save_auth.py
"""
from datetime import datetime
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright, Page
SCREENSHOTS_DIR = Path("output/screenshots")
@pytest.fixture(params=["chromium", "firefox"])
def browser_name(admin_config, request):
"""每个浏览器执行一轮。"""
browsers = admin_config.get("browsers", ["chromium", "firefox"])
request.param = browsers[len(getattr(request, "_index", 0))]
return request.param
@pytest.fixture
def page(admin_config, browser_name, request) -> Page:
"""Playwright Page,加载登录态,失败自动截图。"""
viewport = admin_config.get("viewport", {"width": 1920, "height": 1080})
timeout = admin_config.get("timeout_seconds", 120) * 1000
skip_login = admin_config.get("skip_login", True)
storage_state_dir = Path(admin_config.get("storage_state_dir", "tests/web/auth"))
with sync_playwright() as p:
browser_launcher = getattr(p, browser_name)
browser = browser_launcher.launch(headless=True)
context_kwargs = {
"viewport": {"width": viewport["width"], "height": viewport["height"]},
"locale": "zh-CN",
}
loaded_role = None
if skip_login:
loaded_role = _load_auth_state(context_kwargs, storage_state_dir, browser_name)
context = browser.new_context(**context_kwargs)
page = context.new_page()
page.set_default_timeout(timeout)
page._auth_role = loaded_role
yield page
if 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 ensure_login(admin_config, page, config):
"""确保管理端已登录。"""
def _ensure_login(role: str = "super_admin") -> None:
account = config["accounts"].get(role)
if not account:
raise ValueError(f"未知角色 '{role}',可用: {list(config['accounts'].keys())}")
base_url = admin_config["base_url"]
skip_login = admin_config.get("skip_login", True)
if skip_login:
auth_role = getattr(page, "_auth_role", None)
if auth_role == role:
page.goto(base_url)
page.wait_for_timeout(2000)
if "#/login" not in page.url and "/login" not in page.url:
return
raise RuntimeError(
f"登录态已过期!请运行: python tests/web/admin/save_auth.py --role {role}"
)
if auth_role and auth_role != role:
raise RuntimeError(
f"当前 storage_state 角色为 '{auth_role}',请求 '{role}'\n"
f"请运行: python tests/web/admin/save_auth.py --role {role}"
)
raise RuntimeError(
f"未找到角色 '{role}' 的登录态文件!请运行:\n"
f" python tests/web/admin/save_auth.py --role {role}"
)
# skip_login: false
page.goto(f"{base_url}/#/login")
page.get_by_placeholder("请输入用户名").fill(account["phone"])
page.get_by_placeholder("请输入密码").fill(account["password"])
page.get_by_role("button", name="登录").click()
page.wait_for_timeout(3000)
if "#/login" in page.url or "/login" in page.url:
raise RuntimeError(
"登录失败!可能存在图形验证码。请改为手动登录: python tests/web/admin/save_auth.py"
)
return _ensure_login
def _load_auth_state(context_kwargs: dict, storage_dir: Path, browser: str) -> str | None:
auth_files = list(storage_dir.glob(f"*_{browser}.json")) if storage_dir.exists() else []
if not auth_files:
return None
auth_file = auth_files[0]
context_kwargs["storage_state"] = str(auth_file)
return auth_file.stem.replace(f"_{browser}", "")
@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)
@@ -3,7 +3,7 @@
跨页面复用的顶部/侧边导航操作 跨页面复用的顶部/侧边导航操作
Usage: Usage:
from tests.web.pages.components.navbar import NavBar from tests.web.admin.pages.components.navbar import NavBar
navbar = NavBar(page) navbar = NavBar(page)
navbar.go_to("运输管理") navbar.go_to("运输管理")
@@ -11,7 +11,7 @@ Usage:
from playwright.sync_api import Page, expect from playwright.sync_api import Page, expect
from tests.web.pages.base_page import BasePage from tests.web.admin.pages.base_page import BasePage
class NavBar(BasePage): class NavBar(BasePage):
@@ -1,7 +1,7 @@
"""登录页面 — Page Object。 """登录页面 — Page Object。
Usage: Usage:
from tests.web.pages.login_page import LoginPage from tests.web.admin.pages.login_page import LoginPage
login_page = LoginPage(page) login_page = LoginPage(page)
login_page.login(phone="super_admin", password="951260684NiAn..") login_page.login(phone="super_admin", password="951260684NiAn..")
@@ -9,7 +9,7 @@ Usage:
from playwright.sync_api import Page, expect from playwright.sync_api import Page, expect
from tests.web.pages.base_page import BasePage from tests.web.admin.pages.base_page import BasePage
class LoginPage(BasePage): class LoginPage(BasePage):
@@ -5,7 +5,7 @@
cookies + localStorage 序列化到 JSON 文件后续用例自动复用 cookies + localStorage 序列化到 JSON 文件后续用例自动复用
用法: 用法:
python tests/web/save_auth.py python tests/web/admin/save_auth.py
流程: 流程:
1. 脚本打开浏览器到登录页 1. 脚本打开浏览器到登录页
@@ -44,7 +44,7 @@ def main():
args = parser.parse_args() args = parser.parse_args()
config = load_config() config = load_config()
web_config = config["web"] web_config = config["admin"]
base_url = web_config["base_url"] base_url = web_config["base_url"]
login_url = f"{base_url}/#/login" login_url = f"{base_url}/#/login"
@@ -92,7 +92,7 @@ def main():
browser.close() browser.close()
print(f"\n🎉 现在可以跑了: pytest tests/web/ -m p0") print(f"\n🎉 现在可以跑了: pytest tests/web/admin/ -m p0")
if __name__ == "__main__": if __name__ == "__main__":
+74
View File
@@ -0,0 +1,74 @@
"""管理端登录测试用例。
标记:
pytest -m admin # 仅管理端用例
pytest -m p0 # P0 冒烟
pytest -m smoke # 冒烟
"""
import pytest
from playwright.sync_api import Page
pytestmark = [pytest.mark.admin]
class TestAdminSmoke:
"""管理端冒烟 — 验证预登录态。"""
@pytest.mark.p0
@pytest.mark.smoke
def test_admin_access_with_saved_auth(self, page: Page, admin_config, ensure_login):
"""验证预登录态可直接访问管理后台。"""
ensure_login("super_admin")
page.goto(admin_config["base_url"])
page.wait_for_timeout(2000)
assert "#/login" not in page.url and "/login" not in page.url, (
"预登录态失效!请运行: python tests/web/admin/save_auth.py"
)
@pytest.mark.p0
@pytest.mark.smoke
def test_team_leader_access(self, page: Page, admin_config, ensure_login):
"""验证车队长预登录态有效。"""
ensure_login("team_leader")
page.goto(admin_config["base_url"])
page.wait_for_timeout(2000)
assert "#/login" not in page.url and "/login" not in page.url
@pytest.mark.p0
@pytest.mark.smoke
def test_dashboard_loads(self, page: Page, admin_config, ensure_login):
"""验证首页可正常加载。"""
ensure_login("super_admin")
page.goto(admin_config["base_url"])
assert page.title(), "首页应显示页面标题"
class TestLoginPage:
"""登录页交互验证。"""
@pytest.mark.p1
def test_login_page_accessible(self, page: Page, admin_config):
"""验证登录页可正常访问。"""
page.goto(f"{admin_config['base_url']}/#/login")
assert page.get_by_placeholder("请输入用户名").is_visible()
@pytest.mark.p1
def test_login_page_elements(self, page: Page, admin_config):
"""验证登录页元素完整。"""
page.goto(f"{admin_config['base_url']}/#/login")
assert page.get_by_placeholder("请输入用户名").is_visible()
assert page.get_by_placeholder("请输入密码").is_visible()
assert page.get_by_role("button", name="登录").is_visible()
@pytest.mark.p1
def test_session_redirect(self, page: Page, admin_config, ensure_login):
"""验证已登录用户访问登录页被重定向。"""
ensure_login("super_admin")
page.goto(admin_config["base_url"])
page.wait_for_timeout(2000)
assert "#/login" not in page.url
page.goto(f"{admin_config['base_url']}/#/login")
page.wait_for_timeout(2000)
assert "#/login" not in page.url and "/login" not in page.url
File diff suppressed because one or more lines are too long
-180
View File
@@ -1,180 +0,0 @@
"""
Web 端根级 fixture:浏览器实例 + 登录态复用 + 失败自动截图。
核心机制 — 跳过图形验证码:
配置 web.skip_login: true 时,读取已保存的 storage_statecookies + localStorage),
跳过 UI 登录步骤。首次需要手动登录一次来生成 auth 文件。
首次初始化流程:
1. python tests/web/save_auth.py
2. 打开的浏览器中手动完成验证码登录
3. 登录态自动保存到 tests/web/auth/<role>.json
后续用例执行时自动复用,过期后重新运行 save_auth.py 即可。
提供:
browser_name — 参数化 fixture,每个浏览器执行一轮
page — Playwright Page 对象(已加载登录态)
ensure_login — 确保指定角色的登录态有效,未登录则报错提示
"""
from datetime import datetime
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright, Page, BrowserContext
SCREENSHOTS_DIR = Path("output/screenshots")
AUTH_DIR = Path(__file__).parent / "auth"
@pytest.fixture(scope="session")
def web_config(config):
"""session 级:Web 配置段。"""
return config["web"]
@pytest.fixture(params=["chromium", "firefox"])
def browser_name(web_config):
"""每个浏览器执行一次同一套用例。
可从命令行覆盖: pytest --browser=chromium tests/web/
"""
return request.param
@pytest.fixture
def page(web_config, browser_name, request) -> Page:
"""每个用例独立的 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)
skip_login = web_config.get("skip_login", True)
storage_state_dir = Path(web_config.get("storage_state_dir", "tests/web/auth"))
with sync_playwright() as p:
browser_launcher = getattr(p, browser_name)
browser = browser_launcher.launch(headless=True)
# ── 构建 context ──
context_kwargs = {
"viewport": {"width": viewport["width"], "height": viewport["height"]},
"locale": "zh-CN",
}
# 如果启用跳过登录,尝试加载 storage_state
loaded_role = None
if skip_login:
loaded_role = _load_auth_state(context_kwargs, storage_state_dir, browser_name)
context = browser.new_context(**context_kwargs)
page = context.new_page()
page.set_default_timeout(timeout)
# 将当前 context 的登录角色注入 page(用例中可检查)
page._auth_role = loaded_role
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 ensure_login(web_config, page, config):
"""确保已登录状态,未登录则自动执行 UI 登录(含验证码则报错)。
用法一(推荐 — 配置 skip_login: true:
def test_xxx(page, ensure_login):
ensure_login("super_admin")
# page 已处于登录后的管理后台
用法二(skip_login: false — 完整 UI 登录):
def test_xxx(page, ensure_login):
ensure_login("super_admin") # 执行完整 UI 登录
"""
def _ensure_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"]
skip_login = web_config.get("skip_login", True)
# 如果 storage_state 已加载且角色匹配,直接验证
if skip_login:
auth_role = getattr(page, "_auth_role", None)
if auth_role == role:
# 验证登录态是否仍然有效
page.goto(base_url)
# Hash 路由模式下判断是否重定向到登录页
page.wait_for_timeout(2000) # 等 hash 路由完成
if "/#/login" not in page.url and "/login" not in page.url:
return # 登录态有效
# 登录态过期,给出明确指引
raise RuntimeError(
f"登录态已过期或未生成!请运行以下命令重新生成:\n"
f" python tests/web/save_auth.py --role {role} --browser {_get_browser_name(page)}"
)
if auth_role and auth_role != role:
raise RuntimeError(
f"当前 storage_state 角色为 '{auth_role}',请求为 '{role}'\n"
f"请运行: python tests/web/save_auth.py --role {role}"
)
# 没有加载任何 auth,报错
raise RuntimeError(
f"未找到角色 '{role}' 的登录态文件!请运行:\n"
f" python tests/web/save_auth.py --role {role}"
)
# ── skip_login: false,执行 UI 登录(可能有验证码,会失败) ──
page.goto(f"{base_url}/#/login")
page.get_by_placeholder("请输入用户名").fill(account["phone"])
page.get_by_placeholder("请输入密码").fill(account["password"])
page.get_by_role("button", name="登录").click()
# 等几秒让页面处理路由跳转
page.wait_for_timeout(3000)
if "/#/login" in page.url or "/login" in page.url:
raise RuntimeError(
"登录失败!可能存在图形验证码。\n"
"请改为手动登录生成 auth 文件: python tests/web/save_auth.py --role {role}"
)
def _load_auth_state(context_kwargs: dict, storage_dir: Path, browser: str) -> str | None:
"""尝试加载登录态,返回角色名或 None。"""
auth_files = list(storage_dir.glob(f"*_{browser}.json")) if storage_dir.exists() else []
if not auth_files:
return None
auth_file = auth_files[0] # 加载第一个匹配的 auth 文件
context_kwargs["storage_state"] = str(auth_file)
# 从文件名提取角色名(格式: <role>_<browser>.json
role = auth_file.stem.replace(f"_{browser}", "")
return role
def _get_browser_name(page: Page) -> str:
"""从 page 推测浏览器名(用于错误提示)。"""
return "chromium" # 默认提示 chromium
# ── 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)
+52
View File
@@ -0,0 +1,52 @@
"""司机端小程序 fixture — Playwright(模拟微信 WebView)。
核心机制:
模拟微信内置浏览器 UA,微信 WebView 仍需登录态。
首次使用 python tests/web/admin/save_auth.py 生成后用在此端也可以。
"""
from datetime import datetime
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright, Page
SCREENSHOTS_DIR = Path("output/screenshots")
@pytest.fixture
def page(mini_program_config, request) -> Page:
"""微信 WebView 模拟 Page。"""
viewport = mini_program_config.get("viewport", {"width": 375, "height": 812})
timeout = mini_program_config.get("timeout_seconds", 120) * 1000
user_agent = mini_program_config.get("user_agent", "Mozilla/5.0 MicroMessenger/8.0.0")
base_url = mini_program_config["base_url"]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": viewport["width"], "height": viewport["height"]},
user_agent=user_agent,
locale="zh-CN",
)
page = context.new_page()
page.set_default_timeout(timeout)
yield page
if 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_mini_{ts}.png")
page.screenshot(path=path)
context.close()
browser.close()
@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)
+31
View File
@@ -0,0 +1,31 @@
"""小程序 Page Object 基类。"""
from datetime import datetime
from pathlib import Path
from playwright.sync_api import Page, Locator, expect
SCREENSHOTS_DIR = Path("output/screenshots")
class MiniProgramBasePage:
"""小程序 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: int | None = None) -> None:
expect(locator).to_be_visible(timeout=timeout_ms or self.timeout)
def click_when_ready(self, locator: Locator) -> None:
self.wait_visible(locator)
locator.click()
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}_{ts}.png")
self.page.screenshot(path=path)
return path
@@ -0,0 +1,24 @@
"""小程序首页 — Page Object。
Usage:
from tests.web.mini_program.pages import MiniProgramBasePage
"""
from playwright.sync_api import Page
from tests.web.mini_program.pages import MiniProgramBasePage
class MiniHomePage(MiniProgramBasePage):
"""司机端小程序首页。"""
def __init__(self, page: Page):
super().__init__(page)
# ⚠️ 定位器需根据实际小程序 UI 确认
self.order_list_tab = page.get_by_text("运单")
self.mine_tab = page.get_by_text("我的")
def go_to_order_list(self) -> None:
self.click_when_ready(self.order_list_tab)
def go_to_mine(self) -> None:
self.click_when_ready(self.mine_tab)
@@ -0,0 +1,16 @@
"""小程序冒烟测试。"""
import pytest
pytestmark = [pytest.mark.mini_program]
class TestMiniProgramSmoke:
"""小程序基础验证。"""
@pytest.mark.p0
@pytest.mark.smoke
def test_mini_program_accessible(self, page, mini_program_config):
"""验证小程序首页可正常加载。"""
page.goto(mini_program_config["base_url"])
assert page.title(), "小程序首页应显示页面标题"
-111
View File
@@ -1,111 +0,0 @@
"""Web 登录功能测试用例。
覆盖:
- (skip_login: true) 验证预登录态有效,不经过 UI 登录页
- (skip_login: false) 完整 UI 登录流程 + 表单校验
标记:
pytest -m web # 仅执行 Web 用例
pytest -m p0 # 仅执行 P0 冒烟
pytest -m smoke # 仅执行冒烟
"""
import pytest
from playwright.sync_api import Page
pytestmark = [pytest.mark.web]
# ═══════════════════════════════════════════════════════════════════
# P0 — 冒烟测试(依赖预登录态,不触发 UI 登录)
# ═══════════════════════════════════════════════════════════════════
class TestLoginSmoke:
"""验证预登录态有效 + 后台可正常访问。"""
@pytest.mark.p0
@pytest.mark.smoke
def test_admin_access_with_saved_auth(self, page: Page, web_config, ensure_login):
"""验证加载预登录态后可直接访问管理后台,无需重新登录。"""
ensure_login("super_admin")
page.goto(web_config["base_url"])
# 等 hash 路由完成
page.wait_for_timeout(2000)
# 已登录状态下不应重定向到登录页
assert "#/login" not in page.url and "/login" not in page.url, (
f"预登录态失效!仍然重定向到登录页。请运行: python tests/web/save_auth.py"
)
@pytest.mark.p0
@pytest.mark.smoke
def test_team_leader_access_with_saved_auth(self, page: Page, web_config, ensure_login):
"""验证车队长预登录态有效。"""
ensure_login("team_leader")
page.goto(web_config["base_url"])
page.wait_for_timeout(2000)
assert "#/login" not in page.url and "/login" not in page.url
@pytest.mark.p0
@pytest.mark.smoke
def test_dashboard_loads(self, page: Page, web_config, ensure_login):
"""验证登录后首页能正常加载。"""
ensure_login("super_admin")
page.goto(web_config["base_url"])
# 页面标题应包含"运八"或类似标识
assert page.title(), "首页应显示页面标题"
# ═══════════════════════════════════════════════════════════════════
# P1 — 登录页交互(仅在 skip_login: false 时才有意义)
# ═══════════════════════════════════════════════════════════════════
class TestLoginPageDirect:
"""直接访问登录页相关用例(不依赖账号)。"""
@pytest.mark.p1
def test_login_page_accessible(self, page: Page, web_config):
"""验证登录页可直接访问并正常渲染。"""
page.goto(f"{web_config['base_url']}/#/login")
# 登录页应存在用户名输入框
assert page.get_by_placeholder("请输入用户名").is_visible()
@pytest.mark.p1
def test_login_page_elements(self, page: Page, web_config):
"""验证登录页所有关键元素存在。"""
page.goto(f"{web_config['base_url']}/#/login")
assert page.get_by_placeholder("请输入用户名").is_visible()
assert page.get_by_placeholder("请输入密码").is_visible()
assert page.get_by_role("button", name="登录").is_visible()
@pytest.mark.p1
def test_double_login_session_redirect(self, page: Page, web_config, ensure_login):
"""验证已登录用户访问登录页自动重定向。"""
ensure_login("super_admin")
page.goto(web_config["base_url"])
page.wait_for_timeout(2000)
assert "#/login" not in page.url and "/login" not in page.url
# 已登录状态再次访问登录页
page.goto(f"{web_config['base_url']}/#/login")
page.wait_for_timeout(2000)
# 应被重定向到后台
assert "#/login" not in page.url and "/login" not in page.url, (
f"已登录用户访问 /login 应被重定向,当前: {page.url}"
)
# ═══════════════════════════════════════════════════════════════════
# P2 — UI 登录流程(仅 skip_login: false 时执行,有验证码时预期跳过)
# ═══════════════════════════════════════════════════════════════════
@pytest.mark.p2
@pytest.mark.skip_login_required # 自定义 marker:仅在 skip_login: false 时生效
class TestLoginUI:
"""完整 UI 登录(需要 skip_login: false)。"""
def test_full_ui_login_super_admin(self, page: Page, web_config, ensure_login):
"""通过 UI 完成完整登录(需要手动关闭 skip_login)。"""
ensure_login("super_admin")
page.goto(web_config["base_url"])
page.wait_for_timeout(2000)
assert "#/login" not in page.url and "/login" not in page.url