@@ -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` — 完成定义