feat: 框架增加登录态复用机制,绕过图形验证码
Web 端 (storage_state): - 新增 tests/web/save_auth.py: 手动登录后保存 cookies+localStorage - conftest.py: skip_login:true 时自动加载 storage_state,跳过 UI 登录 - ensure_login fixture: 登录态有效则直接通过,过期则给出明确指引 App 端 (noReset): - conftest.py: ensure_logged_in fixture,手动登录后无需重新登录 - noReset=true 持久化登录态,未登录自动检测并给出明确引导 配置文件: - web.skip_login: true + storage_state_dir - app.skip_login: true + 手动登录说明
This commit is contained in:
+110
-10
@@ -1,14 +1,23 @@
|
|||||||
"""
|
"""
|
||||||
App 端根级 fixture:Appium Driver 实例 + 失败自动截图。
|
App 端根级 fixture:Appium Driver 实例 + 登录态复用 + 失败自动截图。
|
||||||
|
|
||||||
|
核心机制 — 手动登录后复用:
|
||||||
|
Appium noReset=true 确保 app 数据不重置,登录态持久保留在设备上。
|
||||||
|
只需手动在设备上登录一次,后续用例通过 ensure_logged_in 检查首页是否已登录态。
|
||||||
|
如果检测到未登录(在登录页),给出明确指引让用户先去设备上手动登录。
|
||||||
|
|
||||||
|
配置 app.skip_login: true 时:
|
||||||
|
- 用例通过 ensure_logged_in() 检查是否已登录
|
||||||
|
- 未登录则抛出明确错误,引导用户先去设备上手动登录
|
||||||
|
- 已登录则直接开始执行
|
||||||
|
|
||||||
|
配置 app.skip_login: false 时:
|
||||||
|
- 每次用例尝试完整 UI 登录(有验证码时会失败)
|
||||||
|
|
||||||
提供:
|
提供:
|
||||||
app_config — session 级 App 配置
|
app_config — session 级 App 配置
|
||||||
driver — Appium WebDriver(每个用例独立)
|
driver — Appium WebDriver(每个用例独立,失败自动截图)
|
||||||
platform — 当前平台 android / ios
|
platform — 当前平台
|
||||||
|
|
||||||
Usage:
|
|
||||||
def test_app_login(driver, platform):
|
|
||||||
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_btn").click()
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -27,7 +36,7 @@ def app_config(config):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def platform(app_config) -> str:
|
def platform(app_config) -> str:
|
||||||
"""当前执行的平台(默认 android,后续可参数化)。"""
|
"""当前执行的平台。"""
|
||||||
return app_config.get("platforms", ["android"])[0]
|
return app_config.get("platforms", ["android"])[0]
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +44,8 @@ def platform(app_config) -> str:
|
|||||||
def driver(app_config, platform, request):
|
def driver(app_config, platform, request):
|
||||||
"""每个用例独立的 Appium driver,失败自动截图。
|
"""每个用例独立的 Appium driver,失败自动截图。
|
||||||
|
|
||||||
注意:运行前需确保 Appium Server 已启动(appium --log-level info)。
|
前提: Appium Server 已启动(appium --log-level info)。
|
||||||
|
noReset=true → app 不重置 → 登录态持久保留。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from appium import webdriver
|
from appium import webdriver
|
||||||
@@ -89,6 +99,96 @@ def driver(app_config, platform, request):
|
|||||||
driver.quit()
|
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 报告 hook ──
|
||||||
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||||
def pytest_runtest_makereport(item, call):
|
def pytest_runtest_makereport(item, call):
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
"""App 登录功能测试用例。
|
"""App 登录功能测试用例。
|
||||||
|
|
||||||
覆盖正常登录、异常登录。
|
覆盖:
|
||||||
按 ui_automation_best_practices.md §6.4 的范本结构编写。
|
- (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 app # 仅执行 App 用例
|
||||||
@@ -16,68 +23,86 @@ pytestmark = [pytest.mark.app]
|
|||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
# P0 — 冒烟测试
|
# P0 — 冒烟测试(依赖手动登录后的持久化登录态)
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class TestAppLoginSuccess:
|
class TestAppSmoke:
|
||||||
"""App 正常登录流程。"""
|
"""验证 App 登录态有效,核心页面可正常访问。"""
|
||||||
|
|
||||||
@pytest.mark.p0
|
@pytest.mark.p0
|
||||||
@pytest.mark.smoke
|
@pytest.mark.smoke
|
||||||
def test_driver_login(self, driver, platform, app_config, config):
|
def test_already_logged_in(self, driver, platform, ensure_logged_in):
|
||||||
"""验证司机端 APP 正常登录成功。"""
|
"""验证 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
|
from tests.app.pages.driver_home_page import DriverHomePage
|
||||||
|
|
||||||
account = config["accounts"]["driver"]
|
ensure_logged_in()
|
||||||
|
|
||||||
# ⚠️ 以下定位需根据实际 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)
|
home = DriverHomePage(driver, platform=platform)
|
||||||
assert home.is_displayed(), "登录后应展示司机端首页"
|
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 — 异常与校验
|
# P1 — 登录页验证(仅在 skip_login: false 时有意义)
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class TestAppLoginValidation:
|
class TestAppLoginPage:
|
||||||
"""App 登录异常校验。"""
|
"""登录页基础验证。"""
|
||||||
|
|
||||||
@pytest.mark.p1
|
@pytest.mark.p1
|
||||||
@pytest.mark.parametrize("phone,password,expected_toast", [
|
def test_login_page_elements(self, driver, platform, app_config):
|
||||||
pytest.param("", "88888888", "请输入手机号", id="empty_phone"),
|
"""验证登录页元素存在(不执行登录)。"""
|
||||||
pytest.param("15188888888", "", "请输入密码", id="empty_password"),
|
# ⚠️ 前提: app 当前在登录页(未登录状态)
|
||||||
])
|
# 如果已登录,此用例可能找不到登录页元素
|
||||||
def test_login_validation(self, driver, platform, phone, password, expected_toast):
|
from tests.app.conftest import is_logged_in
|
||||||
"""验证登录字段校验。"""
|
|
||||||
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:
|
try:
|
||||||
wait = WebDriverWait(driver, timeout=5)
|
if is_logged_in:
|
||||||
toast_xpath = f"//*[contains(@text,'{expected_toast}')]"
|
pytest.skip("APP 已登录,无法验证登录页元素")
|
||||||
wait.until(EC.visibility_of_element_located((AppiumBy.XPATH, toast_xpath)))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Toast 可能已自动消失,这里不做硬断言,以实际调试为准
|
|
||||||
pass
|
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 已变更")
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ web:
|
|||||||
width: 1920
|
width: 1920
|
||||||
height: 1080
|
height: 1080
|
||||||
|
|
||||||
|
# 登录态复用(跳过图形验证码)
|
||||||
|
# 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
|
||||||
|
storage_state_dir: "tests/web/auth" # 登录态存储目录
|
||||||
|
|
||||||
# ── App 移动端 ──
|
# ── App 移动端 ──
|
||||||
app:
|
app:
|
||||||
appium_host: "http://localhost:4723"
|
appium_host: "http://localhost:4723"
|
||||||
@@ -26,6 +38,14 @@ app:
|
|||||||
- android
|
- android
|
||||||
# - ios # iOS 仅在 macOS CI 环境下开启
|
# - ios # iOS 仅在 macOS CI 环境下开启
|
||||||
|
|
||||||
|
# 登录态复用(跳过图形验证码)
|
||||||
|
# Appium noReset=true 已确保 app 数据不重置,登录态持久保留
|
||||||
|
# 使用方式:手动启动 app 登录一次 → 后续用例自动跳过登录
|
||||||
|
#
|
||||||
|
# skip_login: true → 用例直接检查首页是否已登录态,跳过登录步骤
|
||||||
|
# skip_login: false → 每次用例都执行完整 UI 登录流程
|
||||||
|
skip_login: true
|
||||||
|
|
||||||
android:
|
android:
|
||||||
device_name: "Android Emulator"
|
device_name: "Android Emulator"
|
||||||
app_package: "com.yunba.driver" # ⚠️ 请确认实际包名
|
app_package: "com.yunba.driver" # ⚠️ 请确认实际包名
|
||||||
|
|||||||
+110
-38
@@ -1,29 +1,31 @@
|
|||||||
"""
|
"""
|
||||||
Web 端根级 fixture:浏览器实例 + 失败自动截图。
|
Web 端根级 fixture:浏览器实例 + 登录态复用 + 失败自动截图。
|
||||||
|
|
||||||
|
核心机制 — 跳过图形验证码:
|
||||||
|
配置 web.skip_login: true 时,读取已保存的 storage_state(cookies + localStorage),
|
||||||
|
跳过 UI 登录步骤。首次需要手动登录一次来生成 auth 文件。
|
||||||
|
|
||||||
|
首次初始化流程:
|
||||||
|
1. python tests/web/save_auth.py
|
||||||
|
2. 打开的浏览器中手动完成验证码登录
|
||||||
|
3. 登录态自动保存到 tests/web/auth/<role>.json
|
||||||
|
|
||||||
|
后续用例执行时自动复用,过期后重新运行 save_auth.py 即可。
|
||||||
|
|
||||||
提供:
|
提供:
|
||||||
browser_name — 参数化 fixture,每个浏览器执行一轮
|
browser_name — 参数化 fixture,每个浏览器执行一轮
|
||||||
page — Playwright Page 对象(每个用例独立)
|
page — Playwright Page 对象(已加载登录态)
|
||||||
login — 可复用的登录 helper
|
ensure_login — 确保指定角色的登录态有效,未登录则报错提示
|
||||||
|
|
||||||
Usage:
|
|
||||||
def test_something(page, browser_name):
|
|
||||||
page.goto("/admin")
|
|
||||||
...
|
|
||||||
|
|
||||||
def test_admin_flow(page, login):
|
|
||||||
login("super_admin") # 以管理员身份登录
|
|
||||||
...
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright, Page, BrowserContext
|
||||||
|
|
||||||
# 截图根目录
|
|
||||||
SCREENSHOTS_DIR = Path("output/screenshots")
|
SCREENSHOTS_DIR = Path("output/screenshots")
|
||||||
|
AUTH_DIR = Path(__file__).parent / "auth"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -33,28 +35,45 @@ def web_config(config):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=["chromium", "firefox"])
|
@pytest.fixture(params=["chromium", "firefox"])
|
||||||
def browser_name(request):
|
def browser_name(web_config):
|
||||||
"""每个浏览器执行一次同一套用例。"""
|
"""每个浏览器执行一次同一套用例。
|
||||||
|
|
||||||
|
可从命令行覆盖: pytest --browser=chromium tests/web/
|
||||||
|
"""
|
||||||
return request.param
|
return request.param
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def page(web_config, browser_name, request):
|
def page(web_config, browser_name, request) -> Page:
|
||||||
"""每个用例独立的 Playwright Page,失败自动截图。"""
|
"""每个用例独立的 Playwright Page,自动加载登录态,失败自动截图。"""
|
||||||
viewport = web_config.get("viewport", {"width": 1920, "height": 1080})
|
viewport = web_config.get("viewport", {"width": 1920, "height": 1080})
|
||||||
timeout = web_config.get("timeout_seconds", 120) * 1000
|
timeout = web_config.get("timeout_seconds", 120) * 1000
|
||||||
screenshot_on_failure = web_config.get("screenshot_on_failure", True)
|
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:
|
with sync_playwright() as p:
|
||||||
browser_launcher = getattr(p, browser_name)
|
browser_launcher = getattr(p, browser_name)
|
||||||
browser = browser_launcher.launch(headless=True)
|
browser = browser_launcher.launch(headless=True)
|
||||||
context = browser.new_context(
|
|
||||||
viewport={"width": viewport["width"], "height": viewport["height"]},
|
# ── 构建 context ──
|
||||||
locale="zh-CN",
|
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 = context.new_page()
|
||||||
page.set_default_timeout(timeout)
|
page.set_default_timeout(timeout)
|
||||||
|
|
||||||
|
# 将当前 context 的登录角色注入 page(用例中可检查)
|
||||||
|
page._auth_role = loaded_role
|
||||||
|
|
||||||
yield page
|
yield page
|
||||||
|
|
||||||
# ── 失败自动截图 ──
|
# ── 失败自动截图 ──
|
||||||
@@ -70,40 +89,93 @@ def page(web_config, browser_name, request):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def login(page, web_config, config):
|
def ensure_login(web_config, page, config):
|
||||||
"""返回一个登录 helper 函数,按角色名自动填入账号并等待登录完成。
|
"""确保已登录状态,未登录则自动执行 UI 登录(含验证码则报错)。
|
||||||
|
|
||||||
Usage:
|
用法一(推荐 — 配置 skip_login: true):
|
||||||
def test_admin(page, login):
|
def test_xxx(page, ensure_login):
|
||||||
login("super_admin")
|
ensure_login("super_admin")
|
||||||
page.getByTestId("nav-dashboard").click()
|
# page 已处于登录后的管理后台
|
||||||
|
|
||||||
|
用法二(skip_login: false — 完整 UI 登录):
|
||||||
|
def test_xxx(page, ensure_login):
|
||||||
|
ensure_login("super_admin") # 执行完整 UI 登录
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _login(role: str = "super_admin") -> None:
|
def _ensure_login(role: str = "super_admin") -> None:
|
||||||
account = config["accounts"].get(role)
|
account = config["accounts"].get(role)
|
||||||
if not account:
|
if not account:
|
||||||
raise ValueError(f"未知角色 '{role}',可用: {list(config['accounts'].keys())}")
|
raise ValueError(f"未知角色 '{role}',可用: {list(config['accounts'].keys())}")
|
||||||
|
|
||||||
base_url = web_config["base_url"]
|
base_url = web_config["base_url"]
|
||||||
# 如果已经打开某个页面且未过期,直接返回
|
skip_login = web_config.get("skip_login", True)
|
||||||
if page.url and not page.url.startswith("about:") and "/login" not in page.url:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
# 如果 storage_state 已加载且角色匹配,直接验证
|
||||||
|
if skip_login:
|
||||||
|
auth_role = getattr(page, "_auth_role", None)
|
||||||
|
if auth_role == role:
|
||||||
|
# 验证登录态是否仍然有效
|
||||||
|
page.goto(base_url)
|
||||||
|
if "/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.goto(f"{base_url}/login")
|
||||||
page.getByPlaceholder("请输入用户名").fill(account["phone"])
|
page.getByPlaceholder("请输入用户名").fill(account["phone"])
|
||||||
page.getByPlaceholder("请输入密码").fill(account["password"])
|
page.getByPlaceholder("请输入密码").fill(account["password"])
|
||||||
page.getByRole("button", name="登录").click()
|
page.getByRole("button", name="登录").click()
|
||||||
|
|
||||||
# 等待登录成功跳转(10 秒超时)
|
# 如果有图形验证码,这里会超时
|
||||||
page.wait_for_url(f"{base_url}/**", timeout=10_000)
|
try:
|
||||||
|
page.wait_for_url(f"{base_url}/**", timeout=10_000)
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError(
|
||||||
|
"登录失败!可能存在图形验证码。\n"
|
||||||
|
"请改为手动登录生成 auth 文件: python tests/web/save_auth.py --role {role}"
|
||||||
|
)
|
||||||
|
|
||||||
return _login
|
return _ensure_login
|
||||||
|
|
||||||
|
|
||||||
# ── pytest 报告 hook:记录每个用例的失败状态 ──
|
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)
|
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||||
def pytest_runtest_makereport(item, call):
|
def pytest_runtest_makereport(item, call):
|
||||||
"""在每个用例执行后记录结果,供 fixture 中的失败截图逻辑使用。"""
|
"""记录用例执行结果,供失败截图使用。"""
|
||||||
outcome = yield
|
outcome = yield
|
||||||
rep = outcome.get_result()
|
rep = outcome.get_result()
|
||||||
setattr(item, f"rep_{rep.when}", rep)
|
setattr(item, f"rep_{rep.when}", rep)
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""手动登录并保存浏览器登录态(cookies + localStorage)。
|
||||||
|
|
||||||
|
目的: 绕过图形验证码——人工完成一次验证码登录后,
|
||||||
|
cookies + localStorage 序列化到 JSON 文件,后续用例自动复用。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
# 保存超级管理员的登录态(最常用)
|
||||||
|
python tests/web/save_auth.py
|
||||||
|
|
||||||
|
# 保存指定角色的登录态
|
||||||
|
python tests/web/save_auth.py --role team_leader
|
||||||
|
|
||||||
|
# 指定浏览器
|
||||||
|
python tests/web/save_auth.py --browser firefox
|
||||||
|
|
||||||
|
# 查看帮助
|
||||||
|
python tests/web/save_auth.py --help
|
||||||
|
|
||||||
|
生成的 auth 文件:
|
||||||
|
tests/web/auth/super_admin_chromium.json
|
||||||
|
tests/web/auth/team_leader_firefox.json
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 确保 tests/ 在 Python path 中
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
from tests.config.test_config import load_config
|
||||||
|
|
||||||
|
|
||||||
|
# 登录页的 URL 后缀
|
||||||
|
LOGIN_PATH = "/login"
|
||||||
|
|
||||||
|
# 登录后验证成功的 URL 关键字
|
||||||
|
SUCCESS_URL_KEYWORD = "/admin"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="手动登录并保存浏览器登录态(绕过图形验证码)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--role",
|
||||||
|
default="super_admin",
|
||||||
|
choices=["super_admin", "team_leader", "driver"],
|
||||||
|
help="登录角色(默认: super_admin)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--browser",
|
||||||
|
default="chromium",
|
||||||
|
choices=["chromium", "firefox", "webkit"],
|
||||||
|
help="浏览器类型(默认: chromium)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--headless",
|
||||||
|
action="store_true",
|
||||||
|
help="无头模式(开启后将无法手动输入验证码,仅用于测试已保存的登录态)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
web_config = config["web"]
|
||||||
|
account = config["accounts"].get(args.role)
|
||||||
|
if not account:
|
||||||
|
print(f"❌ 未知角色 '{args.role}',可用: {list(config['accounts'].keys())}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
base_url = web_config["base_url"]
|
||||||
|
login_url = f"{base_url}/login"
|
||||||
|
|
||||||
|
# ── 打开浏览器 ──
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser_launcher = getattr(p, args.browser)
|
||||||
|
browser = browser_launcher.launch(headless=args.headless)
|
||||||
|
context = browser.new_context(
|
||||||
|
viewport={"width": 1920, "height": 1080},
|
||||||
|
locale="zh-CN",
|
||||||
|
)
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
# ── 导航到登录页 ──
|
||||||
|
print(f"\n🔗 打开登录页: {login_url}")
|
||||||
|
page.goto(login_url)
|
||||||
|
|
||||||
|
# ── 填入账号密码 ──
|
||||||
|
page.getByPlaceholder("请输入用户名").fill(account["phone"])
|
||||||
|
page.getByPlaceholder("请输入密码").fill(account["password"])
|
||||||
|
print(f"👤 已填入账号: {account['phone']}(角色: {args.role})")
|
||||||
|
print("📝 请在浏览器中手动完成验证码并点击登录...")
|
||||||
|
print(" (登录成功后脚本会自动检测并保存)\n")
|
||||||
|
|
||||||
|
# ── 等待用户手动完成验证码 + 登录 ──
|
||||||
|
try:
|
||||||
|
page.wait_for_url(f"{base_url}/**", timeout=300_000) # 5 分钟超时
|
||||||
|
except Exception:
|
||||||
|
print("\n⚠️ 等待超时(5 分钟)。可能原因:")
|
||||||
|
print(" 1. 未在浏览器中完成验证码登录")
|
||||||
|
print(" 2. 账号密码错误")
|
||||||
|
print(" 3. 网络不可达")
|
||||||
|
print(" 请检查后重试。")
|
||||||
|
browser.close()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ── 验证登录成功 ──
|
||||||
|
if "/login" in page.url:
|
||||||
|
print("\n❌ 登录失败,仍然停留在登录页。请检查账号密码。")
|
||||||
|
browser.close()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"✅ 登录成功!当前页面: {page.url}")
|
||||||
|
|
||||||
|
# ── 保存登录态 ──
|
||||||
|
auth_dir = Path(web_config.get("storage_state_dir", "tests/web/auth"))
|
||||||
|
auth_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
auth_file = auth_dir / f"{args.role}_{args.browser}.json"
|
||||||
|
|
||||||
|
context.storage_state(path=str(auth_file))
|
||||||
|
print(f"💾 登录态已保存到: {auth_file}")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
print("\n🎉 完成!现在可以运行用例了:")
|
||||||
|
print(f" pytest tests/web/ -m p0")
|
||||||
|
print(f"\n登录态过期后重新运行即可:")
|
||||||
|
print(f" python tests/web/save_auth.py --role {args.role}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"""登录功能测试用例。
|
"""Web 登录功能测试用例。
|
||||||
|
|
||||||
覆盖正常登录、异常登录、表单校验等场景。
|
覆盖:
|
||||||
按 ui_automation_best_practices.md §6.3 的范本结构编写。
|
- (skip_login: true) 验证预登录态有效,不经过 UI 登录页
|
||||||
|
- (skip_login: false) 完整 UI 登录流程 + 表单校验
|
||||||
|
|
||||||
标记:
|
标记:
|
||||||
pytest -m web # 仅执行 Web 用例
|
pytest -m web # 仅执行 Web 用例
|
||||||
@@ -12,90 +13,93 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from playwright.sync_api import Page
|
from playwright.sync_api import Page
|
||||||
|
|
||||||
from tests.web.pages.login_page import LoginPage
|
|
||||||
|
|
||||||
pytestmark = [pytest.mark.web]
|
pytestmark = [pytest.mark.web]
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
# P0 — 冒烟测试
|
# P0 — 冒烟测试(依赖预登录态,不触发 UI 登录)
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class TestLoginSuccess:
|
class TestLoginSmoke:
|
||||||
"""正常登录流程。"""
|
"""验证预登录态有效 + 后台可正常访问。"""
|
||||||
|
|
||||||
@pytest.mark.p0
|
@pytest.mark.p0
|
||||||
@pytest.mark.smoke
|
@pytest.mark.smoke
|
||||||
def test_super_admin_login(self, page: Page, web_config, config):
|
def test_admin_access_with_saved_auth(self, page: Page, web_config, ensure_login):
|
||||||
"""验证超级管理员登录成功并跳转管理后台首页。"""
|
"""验证加载预登录态后可直接访问管理后台,无需重新登录。"""
|
||||||
account = config["accounts"]["super_admin"]
|
ensure_login("super_admin")
|
||||||
login_page = LoginPage(page)
|
|
||||||
page.goto(web_config["base_url"])
|
page.goto(web_config["base_url"])
|
||||||
login_page.login(phone=account["phone"], password=account["password"])
|
# 已登录状态下不应重定向到登录页
|
||||||
# 验证跳转到后台
|
assert "/login" not in page.url, (
|
||||||
assert "/admin" in page.url, f"登录后应跳转到后台,实际 URL: {page.url}"
|
f"预登录态失效!仍然重定向到登录页。请运行: python tests/web/save_auth.py"
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.p0
|
@pytest.mark.p0
|
||||||
@pytest.mark.smoke
|
@pytest.mark.smoke
|
||||||
def test_team_leader_login(self, page: Page, web_config, config):
|
def test_team_leader_access_with_saved_auth(self, page: Page, web_config, ensure_login):
|
||||||
"""验证车队长账号登录成功。"""
|
"""验证车队长预登录态有效。"""
|
||||||
account = config["accounts"]["team_leader"]
|
ensure_login("team_leader")
|
||||||
login_page = LoginPage(page)
|
|
||||||
page.goto(web_config["base_url"])
|
page.goto(web_config["base_url"])
|
||||||
login_page.login(phone=account["phone"], password=account["password"])
|
assert "/login" not in page.url
|
||||||
assert "/admin" 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 — 异常与校验
|
# P1 — 登录页交互(仅在 skip_login: false 时才有意义)
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class TestLoginValidation:
|
class TestLoginPageDirect:
|
||||||
"""登录表单校验。"""
|
"""直接访问登录页相关用例(不依赖账号)。"""
|
||||||
|
|
||||||
@pytest.mark.p1
|
@pytest.mark.p1
|
||||||
@pytest.mark.parametrize("phone,password,expected_text", [
|
def test_login_page_accessible(self, page: Page, web_config):
|
||||||
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")
|
page.goto(f"{web_config['base_url']}/login")
|
||||||
login_page = LoginPage(page)
|
# 登录页应存在用户名输入框
|
||||||
assert login_page.is_on_login_page()
|
assert page.getByPlaceholder("请输入用户名").is_visible()
|
||||||
|
|
||||||
@pytest.mark.p1
|
@pytest.mark.p1
|
||||||
def test_double_login_redirect(self, page: Page, web_config, config):
|
def test_login_page_elements(self, page: Page, web_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")
|
page.goto(f"{web_config['base_url']}/login")
|
||||||
# 应自动重定向到后台
|
assert page.getByPlaceholder("请输入用户名").is_visible()
|
||||||
assert "/admin" in page.url
|
assert page.getByPlaceholder("请输入密码").is_visible()
|
||||||
|
assert page.getByRole("button", name="登录").is_visible()
|
||||||
|
|
||||||
|
@pytest.mark.p1
|
||||||
|
def test_double_login_session_redirect(self, page: Page, web_config, ensure_login):
|
||||||
|
"""验证已登录用户访问 /login 页自动重定向。"""
|
||||||
|
ensure_login("super_admin")
|
||||||
|
page.goto(web_config["base_url"])
|
||||||
|
assert "/login" not in page.url
|
||||||
|
|
||||||
|
# 已登录状态再次访问 /login
|
||||||
|
page.goto(f"{web_config['base_url']}/login")
|
||||||
|
# 应被重定向到后台
|
||||||
|
assert "/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"])
|
||||||
|
assert "/login" not in page.url
|
||||||
|
|||||||
Reference in New Issue
Block a user