From 26ef99f7e7b9ce22a1c2a76398268996df19f7b5 Mon Sep 17 00:00:00 2001 From: xst Date: Wed, 15 Jul 2026 09:38:01 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20Web=20+=20App=20UI=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8C=96=E6=B5=8B=E8=AF=95=E6=9C=80=E4=BD=B3=E5=AE=9E=E8=B7=B5?= =?UTF-8?q?=20v1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui_automation_best_practices.md | 835 ++++++++++++++++++ 1 file changed, 835 insertions(+) create mode 100644 knowledge_base/03_best_practices/ui_automation_best_practices.md diff --git a/knowledge_base/03_best_practices/ui_automation_best_practices.md b/knowledge_base/03_best_practices/ui_automation_best_practices.md new file mode 100644 index 0000000..0239e97 --- /dev/null +++ b/knowledge_base/03_best_practices/ui_automation_best_practices.md @@ -0,0 +1,835 @@ +# Web + App UI 自动化测试最佳实践 + +> **版本**: v1.0 | **日期**: 2026-07-15 | **维护者**: QE Fleet Team +> +> **定位**: 面向测试工程师和 AI Agent 的统一 UI 自动化实践指南。人类工程师可按章查阅、逐条落地;AI Agent(`web-executor`、`mobile-executor`)可将本文档规则作为脚本生成依据。 +> +> **前置阅读**: 建议先阅读 `knowledge_base/01_standards/test_case_template.md`(用例规范)和 `definition_of_done.md`(完成定义)。 + +--- + +## 目录 + +1. [总则与适用范围](#1-总则与适用范围) +2. [技术栈与框架选型](#2-技术栈与框架选型) +3. [Web UI 自动化最佳实践](#3-web-ui-自动化最佳实践) +4. [App UI 自动化最佳实践](#4-app-ui-自动化最佳实践) +5. [通用工程实践](#5-通用工程实践) +6. [UI 自动化用例范例](#6-ui-自动化用例范例) +7. [附录](#7-附录) + +--- + +## 1. 总则与适用范围 + +### 1.1 适用场景 + +| 场景 | Web (Playwright) | App (Appium) | 说明 | +| :--- | :---: | :---: | :--- | +| PC Web 管理后台 | ✅ | — | 平台端、商家端等中后台系统 | +| 移动 H5 页面 | ✅ | — | 微信 H5、手机浏览器页面 | +| Android 原生 APP | — | ✅ | UiAutomator2 驱动 | +| iOS 原生 APP | — | ✅ | XCUITest 驱动 | +| 微信小程序 | ✅ | — | 小程序本质是 WebView,用 Playwright 操作 | +| 混合场景(Web + App 联动) | ✅ | ✅ | 如后台发起操作 → APP 端验证结果 | + +### 1.2 与项目文档体系的关系 + +```text +本文档(最佳实践) + ├── 引用 agents/execute/web_executor.md(Web 执行规则) + ├── 引用 agents/execute/mobile_executor.md(App 执行规则) + ├── 符合 knowledge_base/01_standards/test_case_template.md(用例规范) + ├── 符合 knowledge_base/01_standards/definition_of_done.md(完成定义) + └── 补充 knowledge_base/03_best_practices/(本目录)的自动化工程视角 +``` + +### 1.3 基础原则 + +1. **自动化不是目标,质量才是。** 只自动化那些需要频繁回归、手工执行成本高的用例。 +2. **稳定性 > 覆盖率。** 100 个可靠的用例比 500 个频繁误报的用例有价值。 +3. **可维护性优先。** 定位策略、数据准备、断言逻辑必须清晰可读。 +4. **失败必须可追溯。** 每次失败必须产出截图 + 日志 + 操作序列,让接手者无需复现即可定位。 + +--- + +## 2. 技术栈与框架选型 + +### 2.1 框架选择决策表 + +| 被测端 | 推荐框架 | 当前版本 | 驱动/引擎 | +| :--- | :--- | :--- | :--- | +| PC Web | **Playwright** (Python) | ≥1.40 | Chromium / Firefox / WebKit | +| 移动 H5 | **Playwright** (Python) | ≥1.40 | Chromium (移动视口模拟) | +| Android APP | **Appium** (Python) | ≥3.0 | UiAutomator2 | +| iOS APP | **Appium** (Python) | ≥3.0 | XCUITest | +| 微信小程序 | **Playwright** (Python) | ≥1.40 | Chromium (特定 User-Agent) | + +### 2.2 为什么选 Playwright 而非 Selenium? + +| 维度 | Playwright | Selenium | +| :--- | :--- | :--- | +| 自动等待 | 内置(actionability checks) | 需手动 `WebDriverWait` | +| 网络拦截 | `page.route()` 原生支持 | 需 CDP 或代理 | +| 多浏览器 | 单 API,同代码 | 各浏览器 driver 独立 | +| 调试工具 | `--debug`、Trace Viewer、Codegen | 较弱的原生日志 | +| 执行速度 | 快(单一 WebSocket 连接) | 慢(HTTP 往返) | + +> **团队决策**: 本项目统一使用 Playwright。旧 Selenium 用例应逐步迁移。 + +### 2.3 为什么选 Appium 而非专用框架? + +| 维度 | Appium | Espresso / XCUITest 原生 | +| :--- | :--- | :--- | +| 跨平台复用 | 高(一套 API 双平台) | 低(两套独立代码) | +| 学习成本 | 低(WebDriver 协议) | 高(平台特有 API) | +| CI 集成 | 成熟(Server-Client 架构) | 需额外工具链 | +| 脚本语言 | Python / Java / JS 等 | Java/Kotlin (Android) / Swift (iOS) | + +> **团队决策**: 本项目统一使用 Appium (Python Client)。除非有性能压测或白盒测试需求,不引入原生框架。 + +--- + +## 3. Web UI 自动化最佳实践 + +### 3.1 定位策略(Element Locator) + +**优先级从高到低:** + +| 优先级 | 策略 | 示例 | 稳定性 | +| :---: | :--- | :--- | :---: | +| 1 | `data-testid` | `page.getByTestId('submit-btn')` | ⭐⭐⭐⭐⭐ | +| 2 | `role + name` | `page.getByRole('button', {name: '提交'})` | ⭐⭐⭐⭐ | +| 3 | `text` | `page.getByText('确认删除')` | ⭐⭐⭐ | +| 4 | `placeholder` | `page.getByPlaceholder('请输入手机号')` | ⭐⭐⭐ | +| 5 | `label` | `page.getByLabel('用户名')` | ⭐⭐⭐ | +| 6 | CSS selector | `page.locator('.form .submit-btn')` | ⭐⭐ | +| 7 | XPath | `page.locator('//div[@class="foo"]//button')` | ⭐ | + +**原则**: + +- ✅ **永远优先使用语义化定位**(`data-testid`、`role`、`text`)。它们不随 UI 重构而断裂。 +- ❌ **避免使用 CSS class**(尤其是 `_hash_abc123` 这类编译期生成的 class)。 +- ❌ **避免 XPath 长链**(`/html/body/div[3]/div[2]/span[1]`),改一个嵌套层级就全挂。 +- ⚠️ **不确定的 selector 用注释标注 `# ⚠️ 需确认 selector`**,不要捏造。 + +**反例 vs 正例:** + +```python +# ❌ 反例:CSS class 不稳定,UI 改动就失效 +page.locator('.ant-btn.ant-btn-primary.btn-submit-form-v2').click() + +# ❌ 反例:深层 XPath,嵌套改动就挂 +page.locator('/html/body/div[3]/div[2]/div[1]/form/div[4]/button[1]').click() + +# ✅ 正例:data-testid,与视觉样式解耦 +page.getByTestId('order-submit-btn').click() + +# ✅ 正例:语义化 role + name +page.getByRole('button', name='提交订单').click() + +# ✅ 正例:文本匹配 +page.getByText('确认收货').click() +``` + +### 3.2 等待策略 + +Playwright 内置自动等待(actionability checks),大多数场景**不需要**手动等待。只有在以下情况才显式加等待: + +| 场景 | 推荐写法 | 说明 | +| :--- | :--- | :--- | +| 页面跳转后等待元素 | `page.waitForSelector('[data-testid="result"]')` | 等待关键元素出现 | +| 等待网络请求完成 | `page.waitForResponse(lambda r: '/api/order' in r.url)` | 等待 API 返回 | +| 等待特定状态文本 | `expect(page.getByText('提交成功')).toBeVisible()` | 等待 UI 状态变化 | +| 等待元素消失 | `expect(page.locator('.loading')).toBeHidden()` | 等待 Loading 结束 | + +**反例 vs 正例:** + +```python +# ❌ 反例:硬编码 sleep——浪费时间且不可靠 +import time +page.click('button') +time.sleep(3) # 网速慢时 3 秒不够,快时浪费 +assert page.getByText('成功').is_visible() + +# ✅ 正例:使用 Playwright 自动等待 + 显式等待结合 +page.getByTestId('submit-btn').click() +expect(page.getByText('提交成功')).toBeVisible(timeout=10000) # 最多等 10 秒 +``` + +### 3.3 Page Object 模式 + +将页面元素定位和操作封装为独立的 Page Object 类,实现**用例逻辑与页面结构的分离**。 + +**结构约定**: + +```python +# pages/order_page.py +from playwright.sync_api import Page, expect + +class OrderPage: + """订单确认页""" + + def __init__(self, page: Page): + self.page = page + # 所有定位器集中定义在 __init__ + self.submit_btn = page.getByTestId('order-submit-btn') + self.amount_text = page.getByTestId('order-total-amount') + self.success_tip = page.getByText('下单成功') + + def get_total_amount(self) -> str: + """获取订单总金额""" + return self.amount_text.inner_text() + + def submit(self) -> None: + """提交订单并等待成功提示""" + self.submit_btn.click() + expect(self.success_tip).toBeVisible(timeout=10000) + + def verify_amount(self, expected: str) -> None: + """验证订单金额""" + expect(self.amount_text).toContainText(expected) +``` + +**原则**: + +- 每个 Page Object 对应**一个页面或一个功能区域**(弹窗、抽屉可独立)。 +- 定位器集中声明在 `__init__()`,方法只调用已声明的定位器。 +- 断言尽可能封装在 Page Object 方法内(如 `submit()` 自带成功断言)。 +- 跨页面复用操作封装为**公共组件**(如 `LoginComponent`、`NavBar`),而不是在每个 Page Object 中重复。 + +### 3.4 截图策略 + +本项目的截图策略与 `agents/execute/web_executor.md` 对齐: + +| 策略 | 配置参数 | 说明 | +| :--- | :--- | :--- | +| **失败截图**(默认开启) | `screenshot_on_failure: true` | 断言失败/异常时自动截图 | +| **每步截图**(默认关闭) | `screenshot_on_step: false` | 量大,仅在调试阶段开启 | +| **关键节点截图** | 始终开启 | P0 用例的关键验证点 | + +```python +# ✅ 正例:失败自动截图(conftest.py 全局 fixture) +@pytest.fixture(autouse=True) +def screenshot_on_failure(request, page): + yield + if request.node.rep_call.failed: + path = f"output/screenshots/{request.node.name}_{datetime.now():%Y%m%d_%H%M%S}.png" + page.screenshot(path=path, full_page=True) + +# ✅ 正例:关键节点显式截图 +page.getByTestId('pay-btn').click() +page.screenshot(path=f"output/screenshots/after_payment_{order_id}.png") +``` + +截图命名规范: `{用例编号}_{操作描述}_{时间戳}.png`,如 `ORDER_001_click_submit_20260715_143020.png`。 + +### 3.5 重试与容错 + +| 配置项 | 推荐值 | 说明 | +| :--- | :--- | :--- | +| 失败用例重试 | 1 次 | `retry_on_failure: 1` | +| 操作超时 | 30 秒 | 单次操作(点击/输入)的超时 | +| 断言超时 | 10 秒 | `expect().toBeVisible()` 的等待上限 | +| 页面加载超时 | 60 秒 | `page.goto()` 的导航超时 | + +```python +# ✅ 正例:关键操作加重试(仅在网络相关失败时) +from playwright.sync_api import TimeoutError as PlaywrightTimeout + +def safe_click(locator, max_retries=2): + for attempt in range(max_retries): + try: + locator.click(timeout=30000) + return + except PlaywrightTimeout: + if attempt == max_retries - 1: + raise + time.sleep(2) +``` + +> ⚠️ **注意**: 重试仅适用于网络抖动、临时渲染问题。断言失败(业务逻辑错误)不应重试,直接标记失败。 + +### 3.6 多浏览器覆盖 + +```python +# conftest.py — 按 fleet_config.yml 配置的 browsers 参数化 +import pytest +from playwright.sync_api import sync_playwright + +BROWSERS = ["chromium", "firefox", "webkit"] # 或从 fleet_config.yml 读取 + +@pytest.fixture(params=BROWSERS) +def browser_context(request): + with sync_playwright() as p: + browser = getattr(p, request.param).launch(headless=True) + context = browser.new_context(viewport={"width": 1920, "height": 1080}) + yield context + context.close() + browser.close() +``` + +> **建议**: P0 用例覆盖 Chromium + 至少一个额外浏览器;P1 用例默认仅 Chromium。 + +--- + +## 4. App UI 自动化最佳实践 + +### 4.1 定位策略(Element Locator) + +**优先级从高到低:** + +| 优先级 | 策略 | Android 示例 | iOS 示例 | 稳定性 | +| :---: | :--- | :--- | :--- | :---: | +| 1 | `accessibility_id` | `driver.find_element(AppiumBy.ACCESSIBILITY_ID, "submit")` | 同 | ⭐⭐⭐⭐⭐ | +| 2 | `id` | `driver.find_element(AppiumBy.ID, "com.app:id/btn")` | `driver.find_element(AppiumBy.ID, "submitBtn")` | ⭐⭐⭐⭐ | +| 3 | `class_name + index` | `driver.find_elements(AppiumBy.CLASS_NAME, "android.widget.Button")[0]` | 同 | ⭐⭐ | +| 4 | XPath | `driver.find_element(AppiumBy.XPATH, "//*[@text='确认']")` | 同 | ⭐ | + +**原则**: + +- ✅ 优先让开发在关键元素上添加 `content-desc`(Android)或 `accessibilityIdentifier`(iOS),对应 `accessibility_id` 定位。 +- ❌ 避免 XPath,移动端 UI 层级深且频繁变动。 +- ❌ 避免坐标点击(`tap([x, y])`),不同分辨率/设备上位置完全不同。 +- ⚠️ 不确定的定位方式用注释标注 `# ⚠️ 需确认定位方式`。 + +**反例 vs 正例:** + +```python +# ❌ 反例:坐标点击——换一台设备就挂 +driver.tap([(300, 500)]) + +# ❌ 反例:深层 XPath +driver.find_element(AppiumBy.XPATH, "//android.view.View[3]/android.widget.Button[2]") + +# ✅ 正例:accessibility_id 最稳定 +driver.find_element(AppiumBy.ACCESSIBILITY_ID, "submit_order_btn").click() + +# ✅ 正例:id 定位(Android 资源 ID) +driver.find_element(AppiumBy.ID, "com.example.app:id/btn_pay").click() +``` + +### 4.2 Android vs iOS 双平台差异 + +```python +# ✅ 正例:平台感知的 Driver Factory +import platform +from appium import webdriver +from appium.options.android import UiAutomator2Options +from appium.options.ios import XCUITestOptions + +def create_driver(): + if platform.system() == "Android": + options = UiAutomator2Options() + options.device_name = "Android Emulator" + options.app_package = "com.example.app" # ⚠️ 必改 + options.app_activity = ".MainActivity" # ⚠️ 必改 + options.no_reset = True + options.automation_name = "UiAutomator2" + return webdriver.Remote("http://localhost:4723", options=options) + else: + options = XCUITestOptions() + options.device_name = "iPhone 15" + options.bundle_id = "com.example.app" # ⚠️ 必改 + options.no_reset = True + options.automation_name = "XCUITest" + return webdriver.Remote("http://localhost:4723", options=options) +``` + +| 差异点 | Android | iOS | +| :--- | :--- | :--- | +| 驱动 | UiAutomator2 | XCUITest | +| 应用标识 | `appPackage` + `appActivity` | `bundleId` | +| 等待策略 | `WebDriverWait` + `expected_conditions` | 同,但部分 condition 支持有限 | +| 手势 API | `driver.swipe()` | `driver.swipe()`(API 一致) | +| 截图 | `driver.save_screenshot(path)` | 同 | + +### 4.3 手势操作 + +```python +# 滑动(从下往上) +def swipe_up(driver, duration_ms=500): + size = driver.get_window_size() + driver.swipe( + start_x=size['width'] // 2, + start_y=int(size['height'] * 0.8), + end_x=size['width'] // 2, + end_y=int(size['height'] * 0.2), + duration=duration_ms + ) + +# 长按 +from appium.webdriver.common.appiumby import AppiumBy +from appium.webdriver.common.touch_action import TouchAction + +def long_press(driver, element, duration_ms=1000): + TouchAction(driver).long_press(element, duration=duration_ms).release().perform() + +# 双指缩放(地图/图片) +def pinch(driver, scale=0.5): + # ⚠️ 推荐使用 MultiAction API,因设备差异较大,需实际调试 + pass +``` + +### 4.4 等待策略 + +移动端网络不稳定,等待策略比 Web 更关键: + +```python +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from appium.webdriver.common.appiumby import AppiumBy + +# ✅ 正例:显式等待元素可见后再操作 +wait = WebDriverWait(driver, timeout=30) + +element = wait.until( + EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, "order_status")) +) +assert element.text == "已完成" + +# ❌ 反例:硬编码 sleep +import time +time.sleep(5) # 网络差时 5 秒不够 +driver.find_element(AppiumBy.ACCESSIBILITY_ID, "order_status") +``` + +### 4.5 截图策略 + +| 策略 | 说明 | +| :--- | :--- | +| **失败截图**(默认开启) | 断言失败/异常时立即截图 | +| **关键页面截图** | 登录后首页、核心操作页、结果页 | +| **状态变化截图** | 状态流转前后各截一张 | + +```python +def take_screenshot(driver, name: str): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + path = f"output/screenshots/{name}_{timestamp}.png" + driver.save_screenshot(path) + return path + +# 失败自动截图(conftest.py) +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + if outcome.get_result().failed and "driver" in item.funcargs: + driver = item.funcargs["driver"] + take_screenshot(driver, f"FAIL_{item.name}") +``` + +--- + +## 5. 通用工程实践 + +以下实践同时适用于 Web 和 App 自动化。 + +### 5.1 用例独立性 + +每个测试用例必须是**独立且可任意顺序执行**的。 + +```python +# ✅ 正例:每个用例独立准备数据 + 独立清理 +class TestOrderSubmit: + + @pytest.fixture(autouse=True) + def setup_and_teardown(self): + self.test_data = create_test_order() # 每个用例创建独立数据 + yield + cleanup_test_order(self.test_data['id']) # 用例结束后清理 + + def test_normal_submit(self): + result = submit_order(self.test_data['id']) + assert result.status == "SUCCESS" + + def test_duplicate_submit(self): + submit_order(self.test_data['id']) # 第一次提交 + result = submit_order(self.test_data['id']) # 重复提交 + assert result.status == "ALREADY_SUBMITTED" + +# ❌ 反例:用例之间存在数据依赖 +def test_a_create_order(): + global order_id # 全局变量,换执行顺序就挂 + order_id = create_order() + +def test_b_pay_order(): + pay_order(order_id) # 依赖 test_a 先执行,且 order_id 不为空 +``` + +### 5.2 数据驱动 + +将测试数据与用例逻辑分离,同一套用例覆盖多组数据: + +```python +# ✅ 正例:参数化测试数据 +import pytest + +TEST_DATA = [ + # (支付方式, 金额, 预期结果) + ("支付宝", 100, "支付成功"), + ("微信支付", 50, "支付成功"), + ("银行卡", 1000, "支付成功"), + ("支付宝", 0, "金额不能为0"), + ("支付宝", -10, "金额不能为负"), +] + +class TestPayment: + @pytest.mark.parametrize("pay_method,amount,expected", TEST_DATA) + def test_payment(self, pay_method, amount, expected): + result = pay(method=pay_method, amount=amount) + assert result.message == expected +``` + +### 5.3 环境配置管理 + +所有环境相关信息统一管理,不硬编码在测试脚本中: + +```python +# config/test_config.yaml +base_url: "https://test.example.com" +timeout_seconds: 120 +accounts: + admin: { phone: "13800138000", password: "Test123456" } + user: { phone: "13800138001", password: "Test123456" } +browsers: ["chromium", "firefox"] +``` + +```python +# ✅ 正例:从配置读取 +import yaml +from pathlib import Path + +def load_config(): + with open(Path(__file__).parent / "config/test_config.yaml", encoding="utf-8") as f: + return yaml.safe_load(f) + +config = load_config() +page.goto(config["base_url"]) +page.getByPlaceholder("手机号").fill(config["accounts"]["admin"]["phone"]) + +# ❌ 反例:硬编码 +BASE_URL = "https://test.example.com" # 换环境要改代码 +ADMIN_PHONE = "13800138000" # 换账号要改代码 +``` + +### 5.4 报告与日志 + +```python +import logging +from datetime import datetime + +# 日志配置 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler(f"output/logs/test_{datetime.now():%Y%m%d_%H%M%S}.log"), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +class TestOrder: + def test_submit(self): + logger.info(f"开始执行: 验证正常下单") + try: + # ... + logger.info(f"✅ 通过: 验证正常下单") + except Exception as e: + logger.error(f"❌ 失败: 验证正常下单 — {e}") + raise +``` + +**报告输出要求**: + +- 每个用例执行后产出: 通过/失败/跳过 + 耗时 + 失败原因(如有)+ 截图路径 +- 批量运行结束后产出汇总: `通过 X / 失败 Y / 跳过 Z`,列出所有失败用例的截图链接 + +### 5.5 CI 集成 + +```yaml +# CI 配置示例 (GitHub Actions / Gitea Actions) +jobs: + web-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: pip install -r requirements.txt + - run: playwright install chromium + - run: pytest tests/web/ --screenshot on_failure --junitxml=report.xml + + app-tests: + runs-on: macos-latest # iOS 测试必须 macOS + steps: + - uses: actions/checkout@v4 + - run: | + # 启动 Appium Server + appium --log-level info --log appium.log & + sleep 5 + - run: pytest tests/app/ --platform android --junitxml=report.xml +``` + +--- + +## 6. UI 自动化用例范例 + +> 以下范例直接从 `knowledge_base/03_best_practices/` 下的业务用例中提取自动化实现,展示"好的手工用例"如何映射为"可运行的自动化代码"。 + +### 6.1 Web — 支付流程 (Playwright) + +**来源用例**: `payment_flow_cases.md` — `PAY_01_001`(验证正常支付流程成功) + +```python +# tests/web/test_payment.py +import pytest +from playwright.sync_api import Page, expect + +class TestPaymentFlow: + """支付流程自动化测试""" + + @pytest.fixture(autouse=True) + def setup(self, page: Page): + """每个用例前登录 + 准备可售商品""" + self.page = page + login(page, user="user_A") # 封装好的登录函数 + self.item_id = add_item_to_cart(page, sku="SKU-001") # 添加商品到购物车 + yield + cancel_remaining_orders(page) # 清理未支付订单 + + def test_normal_payment_alipay(self, page: Page): + """PAY_01_001: 验证正常支付流程成功""" + # Step 1-2: 进入收银台,选择支付宝 + page.getByTestId("checkout-btn").click() + expect(page.getByTestId("cashier-page")).toBeVisible() + page.getByTestId("pay-method-alipay").click() + + # Step 3: 完成支付 + page.getByTestId("confirm-pay-btn").click() + + # 预期 1: 页面跳转支付成功页 + expect(page.getByText("支付成功")).toBeVisible(timeout=10000) + + # 预期 2: 订单状态变更为"待发货" + page.getByTestId("nav-orders").click() + order_status = page.getByTestId(f"order-{self.item_id}-status") + expect(order_status).toHaveText("待发货") + + # 预期 3: 支付流水金额与订单实付一致 + page.getByTestId(f"order-{self.item_id}-detail").click() + pay_amount = page.getByTestId("pay-record-amount").inner_text() + order_amount = page.getByTestId("order-actual-amount").inner_text() + assert pay_amount == order_amount, f"支付金额 {pay_amount} ≠ 订单金额 {order_amount}" +``` + +### 6.2 Web — 异常流程 (Playwright) + +```python + def test_payment_balance_insufficient_switch_method(self, page: Page): + """PAY_01_003: 验证支付渠道返回余额不足时可切换支付方式""" + page.getByTestId("checkout-btn").click() + + # 选择银行卡支付 + page.getByTestId("pay-method-bankcard").click() + + # 模拟支付渠道返回余额不足 + page.getByTestId("confirm-pay-btn").click() + expect(page.getByText("余额不足")).toBeVisible(timeout=10000) + + # 订单仍保持待支付状态 + page.getByTestId("nav-orders").click() + order_status = page.getByTestId(f"order-{self.item_id}-status") + expect(order_status).toHaveText("待支付") + + # 可切换支付方式继续支付 + page.getByTestId(f"order-{self.item_id}-pay").click() + page.getByTestId("pay-method-alipay").click() + page.getByTestId("confirm-pay-btn").click() + expect(page.getByText("支付成功")).toBeVisible(timeout=10000) +``` + +### 6.3 Web — 表单验证 (Playwright) + +```python +# tests/web/test_login.py +class TestLogin: + """登录功能自动化测试""" + + @pytest.mark.parametrize("phone,password,expected_error", [ + ("", "Test123456", "请输入手机号"), + ("13800138000", "", "请输入密码"), + ("13800138000", "wrong", "密码错误"), + ("13800000000", "Test123456", "账号不存在"), + ]) + def test_login_validation(self, page: Page, phone, password, expected_error): + """验证登录表单校验""" + page.goto("/login") + + if phone: + page.getByPlaceholder("请输入手机号").fill(phone) + if password: + page.getByPlaceholder("请输入密码").fill(password) + page.getByRole("button", name="登录").click() + + expect(page.getByText(expected_error)).toBeVisible() +``` + +### 6.4 App — 登录流程 (Appium) + +```python +# tests/app/test_login.py +import pytest +from appium.webdriver.common.appiumby import AppiumBy +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + +class TestAppLogin: + """APP 登录自动化测试""" + + def test_login_success(self, driver): + """验证正常登录成功""" + wait = WebDriverWait(driver, timeout=30) + + # 输入手机号 + phone_input = wait.until( + EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, "login_phone_input")) + ) + phone_input.send_keys("13800138000") + + # 输入密码 + driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_password_input").send_keys("Test123456") + + # 点击登录 + driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_submit_btn").click() + + # 验证跳转主页 + home_tab = wait.until( + EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, "main_home_tab")) + ) + assert home_tab.is_displayed() +``` + +### 6.5 App — 订单列表滑动与加载 (Appium) + +```python +# tests/app/test_order_list.py +class TestOrderList: + """APP 订单列表测试""" + + def test_pull_to_refresh(self, driver): + """验证下拉刷新订单列表""" + wait = WebDriverWait(driver, timeout=30) + + # 等待列表加载 + wait.until( + EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, "order_list")) + ) + + # 下拉刷新 + size = driver.get_window_size() + driver.swipe( + start_x=size['width'] // 2, + start_y=int(size['height'] * 0.3), + end_x=size['width'] // 2, + end_y=int(size['height'] * 0.7), + duration=500 + ) + + # 验证刷新成功提示 + toast = wait.until( + EC.visibility_of_element_located((AppiumBy.XPATH, "//*[contains(@text,'刷新成功')]")) + ) + assert toast is not None + + def test_scroll_load_more(self, driver): + """验证上滑加载更多""" + wait = WebDriverWait(driver, timeout=30) + + # 获取当前列表项数量 + initial_items = len(driver.find_elements(AppiumBy.ACCESSIBILITY_ID, "order_card")) + + # 滑动到底部 + size = driver.get_window_size() + driver.swipe( + start_x=size['width'] // 2, + start_y=int(size['height'] * 0.8), + end_x=size['width'] // 2, + end_y=int(size['height'] * 0.2), + duration=500 + ) + + # 验证加载更多 + time.sleep(1) # 等待渲染完成 + after_items = len(driver.find_elements(AppiumBy.ACCESSIBILITY_ID, "order_card")) + assert after_items >= initial_items, f"加载更多后应不少于加载前: {after_items} < {initial_items}" +``` + +--- + +## 7. 附录 + +### 7.1 Selector 速查表 + +#### Playwright (Web) + +| 场景 | 写法 | +| :--- | :--- | +| 按 data-testid 点击 | `page.getByTestId('my-btn').click()` | +| 按文本点击 | `page.getByText('确 定').click()` | +| 按角色+名称点击 | `page.getByRole('button', name='提交').click()` | +| 按 placeholder 填写 | `page.getByPlaceholder('请输入').fill('hello')` | +| 按 label 填写 | `page.getByLabel('手机号').fill('13800138000')` | +| 等待可见 | `expect(page.getByText('成功')).toBeVisible()` | +| 等待隐藏 | `expect(page.locator('.loading')).toBeHidden()` | +| 下拉框选择 | `page.selectOption('#type', 'value1')` | +| 文件上传 | `page.setInputFiles('input[type=file]', 'path/to/file')` | +| 确认弹窗 | `page.on('dialog', lambda d: d.accept())` | +| iframe 内操作 | `page.frameLocator('#iframe').getByTestId('btn')` | + +#### Appium (App) + +| 场景 | Android 写法 | iOS 写法 | +| :--- | :--- | :--- | +| 按 accessibility_id | `driver.find_element(AppiumBy.ACCESSIBILITY_ID, "xxx")` | 同 | +| 按 id | `driver.find_element(AppiumBy.ID, "com.app:id/xxx")` | `driver.find_element(AppiumBy.ID, "xxx")` | +| 按文本 | `driver.find_element(AppiumBy.XPATH, "//*[@text='xxx']")` | `driver.find_element(AppiumBy.XPATH, "//*[@label='xxx']")` | +| 滑动 | `driver.swipe(x1,y1,x2,y2,duration)` | 同 | +| 截图 | `driver.save_screenshot(path)` | 同 | +| 上下文切换 | `driver.switch_to.context('WEBVIEW')` | 同 | +| 后台运行 | `driver.background_app(5)` | 同 | + +### 7.2 常见坑与解决方案 + +| 坑 | 现象 | 解决方案 | +| :--- | :--- | :--- | +| CSS Module hash | `locator('.btn_abc123')` 找不到 | 用 `getByTestId` 或 `getByText` | +| iframe 跨域 | `page.locator()` 在 iframe 内失效 | 用 `page.frameLocator('#iframe')` | +| 非固定等待时间 | `time.sleep(3)` 不够或浪费 | 用 `expect(...).toBeVisible(timeout=N)` | +| 元素被遮挡 | `click()` 报 Element not clickable | `locator.click(force=True)` 或先滚动到可见区域 | +| 新标签页 | 点击后在新 tab 打开,操作还在旧 tab | `page.context().pages[1]` 获取新 tab | +| Appium 连接超时 | `Connection refused` | 检查 Appium Server 是否启动: `appium --log-level info` | +| iOS 签名过期 | XCUITest 启动失败 | 检查开发者证书有效期;模拟器无需签名 | +| 设备屏幕尺寸差异 | 坐标点击错位 | 用相对坐标: `size['width'] // 2` | +| Toast 消息难定位 | 一闪而过,无法截图 | 用 `//*[contains(@text,'关键字')]` 或加短延时后截图 | + +### 7.3 与 Agent 执行规则的对应关系 + +| 本文档章节 | 对应 Agent 文件 | 关系 | +| :--- | :--- | :--- | +| §3 Web UI 自动化 | `agents/execute/web_executor.md` | 本文档是其执行规则的详细实践扩展 | +| §4 App UI 自动化 | `agents/execute/mobile_executor.md` | 同上 | +| §5.1 用例独立性 | `definition_of_done.md` §3 | 本文档给出代码层面的实现方式 | +| §3.4 / §4.5 截图策略 | `web_executor.md` / `mobile_executor.md` 截图节 | 本文档与 Agent 配置参数对齐 | +| §6 范例 | `knowledge_base/03_best_practices/` | 本文档补充了自动化代码视角 | + +### 7.4 参考资源 + +- [Playwright Python API](https://playwright.dev/python/docs/intro) +- [Appium Python Client](https://github.com/appium/python-client) +- [Appium UiAutomator2 Driver](https://github.com/appium/appium-uiautomator2-driver) +- [Appium XCUITest Driver](https://github.com/appium/appium-xcuitest-driver) +- 本项目: `docs/EXECUTE_SETUP_GUIDE.md` — 执行环境搭建指南 +- 本项目: `knowledge_base/01_standards/test_case_template.md` — 用例编写规范 +- 本项目: `knowledge_base/01_standards/definition_of_done.md` — 完成定义