Files
QaAutomationHub/knowledge_base/03_best_practices/ui_automation_best_practices.md
T

30 KiB
Raw Blame History

Web + App UI 自动化测试最佳实践

版本: v1.0 | 日期: 2026-07-15 | 维护者: QE Fleet Team

定位: 面向测试工程师和 AI Agent 的统一 UI 自动化实践指南。人类工程师可按章查阅、逐条落地;AI Agent(web-executormobile-executor)可将本文档规则作为脚本生成依据。

前置阅读: 建议先阅读 knowledge_base/01_standards/test_case_template.md(用例规范)和 definition_of_done.md(完成定义)。


目录

  1. 总则与适用范围
  2. 技术栈与框架选型
  3. Web UI 自动化最佳实践
  4. App UI 自动化最佳实践
  5. 通用工程实践
  6. UI 自动化用例范例
  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 与项目文档体系的关系

本文档(最佳实践)
  ├── 引用 agents/execute/web_executor.mdWeb 执行规则)
  ├── 引用 agents/execute/mobile_executor.mdApp 执行规则)
  ├── 符合 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-testidroletext)。它们不随 UI 重构而断裂。
  • 避免使用 CSS class(尤其是 _hash_abc123 这类编译期生成的 class)。
  • 避免 XPath 长链/html/body/div[3]/div[2]/span[1]),改一个嵌套层级就全挂。
  • ⚠️ 不确定的 selector 用注释标注 # ⚠️ 需确认 selector,不要捏造。

反例 vs 正例:

# ❌ 反例: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 正例:

# ❌ 反例:硬编码 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 类,实现用例逻辑与页面结构的分离

结构约定:

# 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() 自带成功断言)。
  • 跨页面复用操作封装为公共组件(如 LoginComponentNavBar),而不是在每个 Page Object 中重复。

3.4 截图策略

本项目的截图策略与 agents/execute/web_executor.md 对齐:

策略 配置参数 说明
失败截图(默认开启) screenshot_on_failure: true 断言失败/异常时自动截图
每步截图(默认关闭) screenshot_on_step: false 量大,仅在调试阶段开启
关键节点截图 始终开启 P0 用例的关键验证点
# ✅ 正例:失败自动截图(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() 的导航超时
# ✅ 正例:关键操作加重试(仅在网络相关失败时)
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 多浏览器覆盖

# 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-descAndroid)或 accessibilityIdentifieriOS),对应 accessibility_id 定位。
  • 避免 XPath,移动端 UI 层级深且频繁变动。
  • 避免坐标点击(tap([x, y])),不同分辨率/设备上位置完全不同。
  • ⚠️ 不确定的定位方式用注释标注 # ⚠️ 需确认定位方式

反例 vs 正例:

# ❌ 反例:坐标点击——换一台设备就挂
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 双平台差异

# ✅ 正例:平台感知的 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 手势操作

# 滑动(从下往上)
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 更关键:

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 截图策略

策略 说明
失败截图(默认开启) 断言失败/异常时立即截图
关键页面截图 登录后首页、核心操作页、结果页
状态变化截图 状态流转前后各截一张
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 用例独立性

每个测试用例必须是独立且可任意顺序执行的。

# ✅ 正例:每个用例独立准备数据 + 独立清理
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 数据驱动

将测试数据与用例逻辑分离,同一套用例覆盖多组数据:

# ✅ 正例:参数化测试数据
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 环境配置管理

所有环境相关信息统一管理,不硬编码在测试脚本中:

# 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"]
# ✅ 正例:从配置读取
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 报告与日志

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 集成

# 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.mdPAY_01_001(验证正常支付流程成功)

# 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)

    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)

# 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)

# 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)

# 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') 找不到 getByTestIdgetByText
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 参考资源