feat: 基于 UI 自动化最佳实践的测试框架骨架搭建

- tests/config: 环境配置 (.yaml) + 配置加载器
- tests/utils: 日志工具 + 测试数据生成器
- tests/web: Playwright conftest + Page Object 基类 + 登录页 + 导航栏组件 + 登录用例(6条)
- tests/app: Appium conftest + Page Object 基类 + 司机端首页 + 登录用例(3条)
- pytest.ini: markers(p0/p1/p2/smoke/web/app) + 运行配置
- 所有用例按最佳实践采用 Page Object 模式 + data-testid 优先定位 + 失败自动截图
This commit is contained in:
xst
2026-07-15 10:24:22 +08:00
parent 26ef99f7e7
commit aade9e39d0
17 changed files with 1034 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
"""
App 端根级 fixtureAppium 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)
+97
View File
@@ -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
+47
View File
@@ -0,0 +1,47 @@
"""司机端首页 — 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"
+83
View File
@@ -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
+42
View File
@@ -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]
+63
View File
@@ -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"
+27
View File
@@ -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)
+33
View File
@@ -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
View File
+41
View File
@@ -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}"
+47
View File
@@ -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)
+109
View File
@@ -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)
+76
View File
@@ -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")
+53
View File
@@ -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()
+52
View File
@@ -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
+101
View File
@@ -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