1. 项目概述:为什么我们需要一个可复用的 iOS 自动化测试 Skill?

在 iOS 应用开发与测试的日常工作中,一个反复出现的场景是:每次新版本迭代,测试同学都需要在真机上重复执行那些“点击登录、滑动浏览、填写表单、检查弹窗”的回归测试用例。手动操作不仅耗时耗力,而且容易因疲劳导致漏测。Appium、XCUITest 等框架虽然强大,但搭建环境、编写和维护脚本对于非专职自动化测试的开发者或中小团队来说,依然存在一定的门槛和重复劳动。

于是,一个想法诞生了:能不能把那些固定的、高频的测试操作,封装成一个独立的、可复用的“技能包”(Skill)?就像给测试手机安装一个“外挂”,需要测试哪个场景,就激活对应的 Skill,让它自动在真机上跑起来。这不仅仅是写一段脚本,而是构建一个完整的、从脚本编写、环境封装到真机部署的解决方案。今天分享的,就是我基于这个思路,从零开始构建一个可复用的 iOS 自动化测试 Skill 的完整过程,并附上在 iPhone 真机上运行的演示效果。这个 Skill 的核心目标是: 一次封装,多处复用;降低自动化门槛,提升回归测试效率。

2. 整体设计与技术选型思路

构建一个可复用的自动化测试 Skill,关键在于“封装”和“集成”。我们需要一个核心驱动引擎来操控手机,一个清晰的架构来组织测试逻辑,以及一种便捷的方式来打包和分发这个“技能包”。

2.1 核心驱动引擎:为什么选择 WebDriverAgent 而非纯 Appium?

操控 iOS 设备,业内主流是 Appium。但 Appium 本身是一个服务端,它底层依赖苹果的 XCUITest 框架和 Facebook 的 WebDriverAgent(WDA)来真正与设备通信。对于打造一个轻量级、可独立分发的 Skill 来说,直接基于 WDA 客户端进行封装是更直接、依赖更少的方案。

WDA 是一个实现了 WebDriver 协议的 iOS 应用,安装到手机后,我们可以通过 HTTP 请求直接发送指令(如点击、滑动、获取元素)来控制手机。这避免了启动完整的 Appium 服务带来的资源消耗和配置复杂度。我们的 Skill 将封装这些 HTTP 请求,提供更友好的 API。

技术栈确定如下:

  • 设备操控层: WebDriverAgent (WDA)。负责与 iOS 系统交互,执行底层 UI 操作。
  • 脚本逻辑层: Python。语法简洁,生态丰富,非常适合快速开发测试逻辑和集成各种工具库。
  • 通信协议: HTTP + JSON。WDA 使用标准的 WebDriver 协议,通过 RESTful API 接收指令并返回结果。
  • Skill 封装形式: 一个独立的 Python 包,内部封装了连接 WDA、查找元素、执行操作、断言验证等一系列方法,并预留了测试用例的编写入口。

2.2 技能包(Skill)的架构设计

一个可复用的 Skill 不能只是一堆散落的脚本。我将其设计为三层结构,确保清晰度和可维护性。

iOS_Auto_Skill/
├── core/               # 核心驱动层
│   ├── wda_client.py   # 封装WDA HTTP客户端,提供基础操作API
│   └── element.py      # 元素定位与操作的封装类
├── skills/             # 技能库层
│   ├── __init__.py
│   ├── login_skill.py  # 例如:登录技能
│   ├── browse_skill.py # 例如:浏览商品技能
│   └── payment_skill.py# 例如:支付流程技能
├── cases/              # 测试用例层
│   └── test_smoke.py   # 组合各种Skill,形成冒烟测试用例
├── config.yaml         # 配置文件(设备UDID、WDA服务地址、应用包名等)
└── requirements.txt    # Python依赖列表
  • 核心驱动层(core): 这是与 WDA 交互的“翻译官”。它将“点击登录按钮”这样的高级指令,翻译成具体的 HTTP POST 请求发送给手机上的 WDA 服务。这里需要处理连接管理、异常重试、响应解析等通用问题。
  • 技能库层(skills): 这是可复用能力的集合。每个 Skill 都是一个 Python 类,对应一个完整的业务场景操作流。例如, LoginSkill 类里就封装了“输入用户名”、“输入密码”、“点击登录”、“验证登录成功”等一系列步骤。测试同学只需要关心调用 skill.login(username, password) 即可。
  • 测试用例层(cases): 这是使用技能的“剧本”。在这里,我们可以像搭积木一样,组合不同的 Skill 来组成完整的测试用例。例如,冒烟测试用例可能就是依次执行 LoginSkill -> BrowseSkill -> LogoutSkill

注意: 将操作(Skill)和用例(Case)分离是提升复用性的关键。一个“登录Skill”既可以用在冒烟测试,也可以用在性能测试前的准备阶段。

2.3 真机环境准备要点

在编码之前,必须在真机上准备好 WDA 环境。这是整个项目的基础,也是最容易踩坑的地方。

  1. 证书与描述文件: 你需要一个有效的苹果开发者账号(个人或公司)。在 Xcode 中,将你的 iPhone 设备添加到账号下,并为 WebDriverAgent 项目配置正确的 Signing Team 和 Bundle Identifier。确保 Xcode 能成功将 WDA 编译并安装到你的手机上。
  2. 启动 WDA 服务: 安装成功后,在手机上手动启动一次 WDA 应用(图标通常是一个灰色的“WebDriverAgent”)。然后,在 Mac 的终端里,通过 iproxy 命令将手机端的服务端口(默认8100)映射到本地。
    # 假设设备UDID为 xxxxx, 将设备的8100端口映射到本机的8100端口
    iproxy 8100 8100 xxxxx
    
  3. 验证连接: 打开浏览器,访问 http://localhost:8100/status 。如果看到返回一个包含设备信息的 JSON,说明 WDA 服务运行正常,可以接受外部指令了。

实操心得: 真机调试时,经常遇到 WDA 突然断开连接的情况。除了检查数据线是否松动,更稳妥的做法是在核心驱动层( wda_client.py )中加入心跳检测和自动重连机制。例如,每次发送指令前先检查会话是否活跃,如果失效则尝试重新初始化会话。

3. 核心模块实现与代码解析

接下来,我们深入核心模块,看看如何用代码实现上述设计。

3.1 封装 WDA 客户端(core/wda_client.py)

这个模块是整个 Skill 的基石,它直接与 WDA 的 HTTP API 对话。

import requests
import json
from typing import Optional, Dict, Any

class WDAClient:
    def __init__(self, server_url: str = 'http://localhost:8100'):
        self.session = requests.Session()
        self.base_url = server_url.rstrip('/')
        self._session_id: Optional[str] = None # 保存当前会话ID

    def start_session(self, bundle_id: str) -> str:
        """启动一个针对特定App的会话"""
        payload = {
            "capabilities": {
                "firstMatch": [{}],
                "alwaysMatch": {
                    "platformName": "iOS",
                    "bundleId": bundle_id,
                    "automationName": "XCuiTest"
                }
            }
        }
        resp = self.session.post(f'{self.base_url}/session', json=payload)
        resp.raise_for_status()
        data = resp.json()
        self._session_id = data['value']['sessionId']
        return self._session_id

    def find_element(self, using: str, value: str) -> Dict[str, Any]:
        """查找元素,支持 accessibility_id, xpath, class_name 等策略"""
        if not self._session_id:
            raise RuntimeError("Session not started. Call start_session first.")
        payload = {"using": using, "value": value}
        resp = self.session.post(f'{self.base_url}/session/{self._session_id}/element', json=payload)
        # 这里可以加入重试逻辑,因为元素可能尚未加载完成
        return resp.json()

    def tap(self, element_id: str) -> None:
        """点击元素"""
        url = f'{self.base_url}/session/{self._session_id}/element/{element_id}/click'
        self.session.post(url)

    def send_keys(self, element_id: str, text: str) -> None:
        """向元素(如输入框)输入文本"""
        url = f'{self.base_url}/session/{self._session_id}/element/{element_id}/value'
        payload = {"value": list(text)} # WDA协议要求文本拆分为字符列表
        self.session.post(url, json=payload)

    # 还可以封装 swipe, get_attribute, get_text, screenshot 等方法
    def swipe(self, start_x: int, start_y: int, end_x: int, end_y: int) -> None:
        """滑动操作"""
        url = f'{self.base_url}/session/{self._session_id}/wda/touch/perform'
        payload = {
            "actions": [
                {"action": "press", "options": {"x": start_x, "y": start_y}},
                {"action": "wait", "options": {"ms": 500}},
                {"action": "moveTo", "options": {"x": end_x, "y": end_y}},
                {"action": "release"}
            ]
        }
        self.session.post(url, json=payload)

关键点解析:

  • 会话管理: start_session 方法至关重要,它告诉 WDA 我们要测试哪个 App(通过 bundleId )。一个会话对应一个 App 的生命周期。
  • 元素定位: find_element 是自动化测试的灵魂。 using 参数支持 accessibility id (推荐,最稳定)、 xpath (灵活但可能慢)、 class name 等。在实际封装中,我会为每种定位方式创建更便捷的方法,如 find_by_accessibility_id(label)
  • 协议细节: send_keys 中需要将字符串转为字符列表,这是 WebDriver 协议的规定,直接使用容易出错,封装起来能极大提升易用性。

3.2 构建登录技能(skills/login_skill.py)

有了强大的客户端,我们就可以构建具体的业务技能了。以登录技能为例。

from core.wda_client import WDAClient
import time

class LoginSkill:
    def __init__(self, client: WDAClient):
        self.client = client
        # 将元素定位信息提取为类属性,便于管理和修改
        self.username_field_id = "usernameTextField" # 假设这是accessibility id
        self.password_field_id = "passwordTextField"
        self.login_button_id = "loginButton"
        self.welcome_text_id = "welcomeLabel"

    def login(self, username: str, password: str, timeout: int = 10) -> bool:
        """
        执行登录流程
        :return: 登录是否成功
        """
        try:
            # 1. 输入用户名
            user_elem = self.client.find_element('accessibility id', self.username_field_id)
            self.client.send_keys(user_elem['value']['ELEMENT'], username)
            time.sleep(0.5) # 简单等待,生产环境建议用显式等待

            # 2. 输入密码
            pwd_elem = self.client.find_element('accessibility id', self.password_field_id)
            self.client.send_keys(pwd_elem['value']['ELEMENT'], password)
            time.sleep(0.5)

            # 3. 点击登录按钮
            login_elem = self.client.find_element('accessibility id', self.login_button_id)
            self.client.tap(login_elem['value']['ELEMENT'])

            # 4. 验证登录成功(等待并检查欢迎元素)
            start_time = time.time()
            while time.time() - start_time < timeout:
                try:
                    welcome_elem = self.client.find_element('accessibility id', self.welcome_text_id)
                    if welcome_elem.get('value'):
                        print(f"登录成功!欢迎文本:{self.client.get_text(welcome_elem['value']['ELEMENT'])}")
                        return True
                except:
                    pass
                time.sleep(1)
            print("登录超时或失败。")
            return False
        except Exception as e:
            print(f"登录过程中发生异常:{e}")
            # 这里可以截图,保存日志
            self.client.screenshot(f"login_error_{int(time.time())}.png")
            return False

设计亮点:

  • 依赖注入: LoginSkill 接收一个 WDAClient 实例,而不是自己内部创建。这使得 Skill 与具体的客户端解耦,方便后续替换驱动或进行单元测试。
  • 配置化元素标识: 将 UI 元素的定位标识(如 accessibility id )作为类属性,当 App UI 变更时,只需修改一处即可。
  • 包含验证逻辑: 一个完整的 Skill 不仅要执行操作,还要验证操作结果。这里的 login 方法返回布尔值,明确告知调用者成功与否。

3.3 编写组合测试用例(cases/test_smoke.py)

现在,我们可以像搭积木一样使用这些 Skill 来编写测试用例了。

import yaml
from core.wda_client import WDAClient
from skills.login_skill import LoginSkill
from skills.browse_skill import BrowseSkill

def load_config():
    with open('config.yaml', 'r') as f:
        return yaml.safe_load(f)

def test_smoke():
    """冒烟测试用例:登录 -> 浏览首页 -> 退出登录"""
    config = load_config()
    
    # 1. 初始化客户端并启动会话
    client = WDAClient(server_url=config['wda_server'])
    session_id = client.start_session(config['app_bundle_id'])
    print(f"会话已启动: {session_id}")
    
    # 2. 初始化技能
    login_skill = LoginSkill(client)
    browse_skill = BrowseSkill(client) # 假设已实现
    
    # 3. 执行测试流
    # 场景一:登录
    if not login_skill.login(config['test_user'], config['test_password']):
        print("冒烟测试失败:登录步骤未通过。")
        return
    
    # 场景二:浏览首页
    if not browse_skill.scroll_and_check_items():
        print("冒烟测试失败:浏览首页步骤未通过。")
        return
    
    # 场景三:可以继续组合其他技能...
    # logout_skill.logout()
    
    print("冒烟测试通过!")
    client.quit_session() # 在WDAClient中实现会话关闭方法

if __name__ == '__main__':
    test_smoke()

这个用例清晰地展示了 Skill 的复用价值。任何需要“登录后操作”的测试场景,都可以直接复用 LoginSkill ,而无需重写登录逻辑。

4. 真机演示效果与部署流程

理论说再多,不如实际跑一遍。下面是我的真机演示步骤和效果描述。

4.1 演示环境与前置条件

  • 硬件: MacBook Pro (开发机), iPhone 13 (测试机,系统 iOS 16+)
  • 软件: Xcode 14+, Python 3.9+, 已安装并运行 WDA 的 iPhone。
  • 目标App: 一个演示用的待测 App(可以是任何你自己开发或有权测试的 App)。

4.2 部署与执行步骤

  1. 代码准备: 将上述模块代码整理到项目文件夹 ios_auto_skill 中。
  2. 安装依赖: 创建 requirements.txt ,至少包含 requests , pyyaml ,然后执行 pip install -r requirements.txt
  3. 配置信息: 编辑 config.yaml 文件,填入你的具体信息。
    wda_server: "http://localhost:8100"
    app_bundle_id: "com.yourcompany.demoapp"
    test_user: "autotest@example.com"
    test_password: "yourPassword123"
    
  4. 启动代理: 在终端确保 iproxy 8100 8100 <your_device_udid> 正在运行。
  5. 运行测试: 在项目根目录下,执行命令 python -m cases.test_smoke

4.3 演示效果描述

执行命令后,你将在终端看到清晰的日志输出:

正在启动会话...
会话已启动: 8A72C310-1B6C-4A5D-9E1F-3D4B5C6A7D8E
开始执行登录技能...
输入用户名: autotest@example.com
输入密码: ******
点击登录按钮。
检测到欢迎元素,登录成功!
开始执行首页浏览技能...
向下滑动...
检查商品项1...存在。
检查商品项2...存在。
...
浏览技能执行完毕。
冒烟测试通过!

与此同时,你的 iPhone 屏幕将“自动”运行起来:App 被自动打开,光标自动跳转到用户名输入框并输入文本,随后跳转到密码框输入密码,自动点击登录按钮。登录成功后,屏幕开始自动上下滑动,模拟用户浏览首页商品列表的过程。整个过程无需人工触碰手机,完全由脚本驱动,流畅且可重复。

实操心得: 真机演示时,建议将操作间隔( time.sleep )适当调大,比如从0.5秒增加到1秒或1.5秒,这样观察者能更清楚地看到每一步的执行效果,也更贴近真实用户操作的速度感。同时,可以在关键步骤(如登录成功)后,让脚本调用 client.screenshot() 保存截图,作为测试报告的依据。

5. 进阶优化与常见问题排查

一个基础的 Skill 跑起来后,要投入实际项目,还需要考虑健壮性、可维护性和效率。

5.1 稳定性优化:显式等待与重试机制

上面的示例使用了 time.sleep ,这是“隐式等待”,效率低且不可靠。生产环境必须使用“显式等待”。

# 在core/wda_client.py中增加显式等待方法
def wait_for_element(self, using, value, timeout=30, interval=1):
    """等待元素出现,并返回元素"""
    start = time.time()
    while time.time() - start < timeout:
        try:
            elem = self.find_element(using, value)
            if elem.get('value'):
                return elem
        except:
            pass
        time.sleep(interval)
    raise TimeoutError(f"元素[{using}={value}]在{timeout}秒内未找到。")

然后在 LoginSkill 中,将 find_element 替换为 wait_for_element 。这样脚本会积极地、周期性地查找元素,直到找到或超时,大大提升了脚本在网络波动或页面加载慢情况下的稳定性。

5.2 可维护性优化:页面对象模型(Page Object)融合

当 Skill 越来越复杂时,可以直接将经典的页面对象模型(Page Object Model, POM)思想融入进来。每个 Skill 类本质上就是一个“页面对象”或“组件对象”的增强版,它不仅定义了元素,还定义了在该页面/组件上可执行的操作流。

5.3 常见问题排查表

在真机自动化过程中,你会频繁遇到以下问题。这里提供一个速查表:

问题现象 可能原因 排查步骤与解决方案
连接被拒绝 1. iproxy 未运行或断开。
2. 手机端 WDA 应用未启动或崩溃。
1. 检查终端 iproxy 进程,重启它。
2. 在手机上查看 WDA 应用是否在前台,尝试重启它。检查 Xcode 控制台是否有崩溃日志。
找不到元素 1. 元素 accessibility id 设置错误或未设置。
2. 页面未加载完成。
3. 当前页面不对(如还在上一页)。
1. 使用 Xcode 的 Accessibility Inspector 或 Appium Desktop 确认元素标识。
2. 添加显式等待(见5.1)。
3. 在操作前增加页面状态判断或截图。
输入文本失败 1. 元素不是可输入类型(如 TextView)。
2. 键盘未弹出。
1. 确认元素属性。
2. 可以先点击输入框再 send_keys 。对于某些安全输入框,可能需要使用 wda 特有的 set_text 方法。
脚本执行速度慢 1. 使用了大量固定 time.sleep
2. 元素查找策略效率低(如复杂 XPath)。
1. 用显式等待替代固定等待。
2. 优先使用 accessibility id predicate string 定位。
会话意外断开 1. App 崩溃。
2. 系统弹窗(如网络权限)中断了自动化。
1. 在客户端增加异常捕获和会话恢复逻辑。
2. 编写处理常见系统弹窗的“拦截技能”。

5.4 扩展方向:技能仓库与 CI/CD 集成

当团队内有多个这样的 Skill 时,可以建立一个内部的“技能仓库”(Skill Repository)。将通用的 Skill(如登录、支付、拍照上传)打包成 Python 库,通过内部 PyPI 服务器进行版本管理。各个业务线的测试项目,只需要像引入普通库一样 pip install team-login-skill ,即可使用标准化、经过充分测试的自动化能力。

更进一步,可以将这些 Skill 驱动的测试用例集成到 CI/CD 流水线中(如 Jenkins, GitLab CI)。每次代码提交后,自动在专用的真机集群上执行冒烟测试或回归测试套件,快速反馈版本质量。

构建这个可复用的 iOS 自动化测试 Skill 的过程,本质上是一个将松散脚本工程化、模块化的过程。它最大的价值不在于用了多高深的技术,而在于通过良好的设计,将自动化能力沉淀为团队资产。从一次性的脚本,到可复用的 Skill,再到集成的流水线,每一步都让测试效率提升一个台阶。真机演示的效果是最有说服力的,当你看到手机自动完成一系列复杂操作时,你就会确信,为前期设计和封装所投入的时间,将在未来无数次的回归测试中被加倍偿还。

更多推荐