Python+Appium多设备并发自动化测试框架设计与实战
1. 项目概述:为什么我们需要一个多设备自动化测试框架?
如果你在移动端测试领域摸爬滚打过一段时间,一定会对下面这个场景深恶痛绝:新版本上线前,测试团队手忙脚乱,测试工程师面前摆着五六台不同品牌、不同型号、不同系统版本的手机,像流水线工人一样,一遍又一遍地重复着点击、滑动、输入的操作。不仅效率低下,而且人工操作极易出错,更别提那些需要覆盖的边界条件和异常场景了。这种重复、枯燥且高强度的劳动,正是自动化测试要解放的生产力。
而“多设备”这个需求,在移动互联网时代变得尤为突出。用户的设备碎片化程度远超想象,一款App在华为Mate 60 Pro上运行流畅,到了某款千元机上可能就卡顿甚至闪退。兼容性测试是保障用户体验和产品口碑的生命线。因此,一个能够同时驱动多台真实设备或模拟器,并行执行测试用例的自动化框架,就不再是“锦上添花”,而是“雪中送炭”的刚需。
Python + Appium的组合,几乎是当前移动端自动化测试领域的事实标准。Python以其简洁的语法和丰富的生态库,极大地降低了自动化脚本的编写和维护成本。Appium则基于WebDriver协议,实现了对iOS、Android两大平台原生、混合及Web应用的统一自动化接口,做到了“一次编写,多处运行”。我们这个项目的核心目标,就是在这对黄金搭档的基础上,搭建一个稳定、高效、易扩展的多设备并发自动化测试框架。它不仅要能跑起来,更要跑得稳、跑得快、跑得聪明,能够无缝集成到CI/CD流水线中,成为研发流程中可靠的质量守门员。
2. 框架核心设计与架构拆解
一个健壮的多设备测试框架,其设计必须围绕“并发管理”、“任务调度”、“结果收集”和“异常隔离”这几个核心问题展开。拍脑袋写一个用多线程同时启动多个 driver 的脚本很容易,但要让它在复杂的生产环境中稳定运行,就需要更系统的设计。
2.1 核心架构模式:生产者-消费者模型
经过多次实践迭代,我认为“生产者-消费者”模型是最适合此类场景的架构。在这个模型里:
- 生产者 :负责解析测试任务。它读取测试用例集(例如,从
pytest的测试列表、一个Excel文件或一个JSON配置中),并将每个待执行的测试用例封装成一个标准的“任务单元”。 - 消费者 :即工作线程(或进程),每个消费者线程绑定一台独立的移动设备。它从任务队列中获取任务,在自己的设备上执行对应的测试用例,并将执行结果(成功、失败、错误日志、截图等)放入结果队列。
- 中央调度器 :这是框架的大脑。它维护着一个设备池(Device Pool),管理所有可用设备的生命周期(连接、分配、释放)。同时,它创建并管理任务队列和结果队列,启动消费者线程,并监控整个执行过程的状态。
这种架构的好处显而易见: 解耦 和 弹性 。任务生成与设备执行分离,我们可以灵活地调整生产速度(例如,按模块或优先级生成任务)和消费者数量(根据在线设备数动态调整)。某台设备突然断线或发生异常,只会影响绑定它的那个消费者线程,而不会导致整个测试任务崩溃。
2.2 关键组件选型与考量
-
Appium Server管理 :每个设备需要一个独立的Appium Server实例,监听不同的端口(如4723, 4725, 4727...)。手动启动和管理这些端口是噩梦。我们的框架必须能自动完成这件事。这里有两种主流方案:
- 方案A:使用
appium的Python客户端库node_modules。我们可以通过Python的subprocess模块,动态地启动和停止Appium Server进程。优点是控制精细,可以自定义所有参数。缺点是需要本地安装Node.js和Appium,并且进程管理稍显复杂。 - 方案B:使用Docker运行Appium Server 。这是更推荐的方式,尤其是在CI/CD环境中。我们可以为每台设备启动一个独立的Appium Docker容器,映射不同的端口。这样做环境隔离彻底,部署和清理极其方便。框架需要集成Docker SDK来管理这些容器的生命周期。
实操心得 :在团队协作和CI环境中,强烈推荐Docker方案。它能保证所有执行节点的Appium环境完全一致,避免了“在我机器上是好的”这类经典问题。你可以准备一个包含所需Appium版本和基础依赖的Docker镜像。
- 方案A:使用
-
设备管理模块 :这是框架的基石。它需要实现以下功能:
- 设备发现 :通过
adb devices(Android)或instruments -s devices(iOS)自动发现所有已连接的设备,并获取其UDID、系统版本、屏幕分辨率等关键信息。 - 设备状态维护 :维护一个“设备池”,记录每台设备的当前状态(空闲、忙碌、离线、异常)。
- 设备分配策略 :当有测试任务到来时,采用何种策略分配设备?最简单的就是“轮询”或“随机”。更高级的可以根据设备型号、系统版本、甚至当前电量(通过adb查询)进行智能匹配,例如将高耗能测试用例优先分配给正在充电的设备。
- 设备发现 :通过
-
测试运行器 :我们选择
pytest作为测试运行器,而不是原生的unittest。原因在于pytest的插件生态极其丰富(如pytest-html生成报告,pytest-xdist用于分布式执行),其夹具(fixture)系统能非常优雅地管理测试资源(如driver的初始化和清理)。我们的框架将深度集成pytest,利用其钩子(hook)函数来介入测试收集和执行过程,实现多设备分发。 -
日志与报告系统 :多设备并发执行时,日志混在一起就是灾难。框架必须为每个设备(或每个测试线程)生成独立的日志文件。同时,需要有一个聚合报告,清晰地展示所有设备上的测试通过率、失败用例、错误截图和日志链接。
Allure报告是一个非常好的选择,它能以时间线的方式展示并发执行过程,并且支持丰富的附件(截图、录屏、日志文件)。
3. 核心模块实现与代码详解
接下来,我们深入到代码层面,看看各个核心模块如何具体实现。我会以一个基于 pytest + Docker + 线程池 的框架为例进行讲解。
3.1 设备发现与池化管理
首先,我们实现一个 DeviceManager 类。
import subprocess
import json
import threading
from dataclasses import dataclass
from typing import Optional, List
from enum import Enum
class DeviceStatus(Enum):
IDLE = "idle"
BUSY = "busy"
OFFLINE = "offline"
ERROR = "error"
@dataclass
class DeviceInfo:
udid: str
platform: str # 'Android' or 'iOS'
platform_version: str
device_name: str
status: DeviceStatus = DeviceStatus.IDLE
appium_port: Optional[int] = None
wda_port: Optional[int] = None # 仅iOS需要
class DeviceManager:
def __init__(self):
self._devices = {} # key: udid, value: DeviceInfo
self._lock = threading.Lock() # 用于线程安全地更新设备状态
def discover_android_devices(self) -> List[DeviceInfo]:
"""通过adb发现Android设备"""
try:
result = subprocess.run(['adb', 'devices'], capture_output=True, text=True, timeout=10)
lines = result.stdout.strip().split('\n')[1:] # 跳过第一行标题
devices = []
for line in lines:
if line.strip() and 'device' in line:
udid = line.split('\t')[0]
# 获取设备型号和系统版本
model = subprocess.run(['adb', '-s', udid, 'shell', 'getprop', 'ro.product.model'],
capture_output=True, text=True).stdout.strip()
version = subprocess.run(['adb', '-s', udid, 'shell', 'getprop', 'ro.build.version.release'],
capture_output=True, text=True).stdout.strip()
device_info = DeviceInfo(
udid=udid,
platform='Android',
platform_version=version,
device_name=model
)
devices.append(device_info)
return devices
except subprocess.TimeoutExpired:
print("ADB设备发现超时")
return []
def update_device_pool(self):
"""更新设备池,将新发现的设备加入,标记离线的设备"""
with self._lock:
current_android_devices = {d.udid: d for d in self.discover_android_devices()}
# 更新现有设备状态
for udid, device in self._devices.items():
if udid in current_android_devices:
# 设备仍在线上,更新信息(如系统版本可能变了?通常不变)
current_device = current_android_devices[udid]
device.device_name = current_device.device_name
device.platform_version = current_device.platform_version
if device.status == DeviceStatus.OFFLINE:
device.status = DeviceStatus.IDLE # 重新上线
else:
# 设备离线
device.status = DeviceStatus.OFFLINE
# 添加新设备
for udid, new_device in current_android_devices.items():
if udid not in self._devices:
new_device.status = DeviceStatus.IDLE
self._devices[udid] = new_device
def acquire_device(self, desired_caps: dict = None) -> Optional[DeviceInfo]:
"""根据期望能力获取一台空闲设备"""
with self._lock:
for device in self._devices.values():
if device.status == DeviceStatus.IDLE:
# 这里可以添加更复杂的匹配逻辑,比如匹配系统版本
if desired_caps:
if desired_caps.get('platformVersion'):
if not device.platform_version.startswith(desired_caps['platformVersion']):
continue
device.status = DeviceStatus.BUSY
return device
return None
def release_device(self, udid: str):
"""释放设备,将其状态置为空闲"""
with self._lock:
if udid in self._devices:
self._devices[udid].status = DeviceStatus.IDLE
注意事项 :
adb devices命令有时会返回unauthorized状态的设备,框架需要处理这种情况,可以尝试通过adb kill-server和adb start-server重置连接,或者提示用户手动在设备上点击“允许USB调试”。
3.2 Appium Docker容器的动态管理
我们使用 docker Python SDK来管理容器。
import docker
import time
class AppiumDockerManager:
def __init__(self):
self.client = docker.from_env()
self.base_image = "appium/appium:latest" # 或指定版本,如 `appium/appium:2.0`
self.container_map = {} # 映射: device_udid -> container_id
def start_appium_for_device(self, device_info: DeviceInfo, port_mapping: int = 4723) -> str:
"""为指定设备启动一个Appium容器"""
# 计算端口映射:主机端口 -> 容器内端口(4723)
host_port = port_mapping
container_port = 4723
# 构建容器启动参数
# 对于Android,需要将设备挂载到容器内;对于iOS(在macOS上),情况更复杂,通常直接在宿主机运行。
# 这里以Android为例。
device_volume = f"/dev/bus/usb:/dev/bus/usb" # 挂载USB设备,让容器内能访问到手机
# 注意:在Linux上可能需要额外的权限配置;在Mac/Windows的Docker Desktop上,USB穿透支持有限,可能需要其他方案。
# 更通用的方式是使用`-v /dev:/dev`并配合`--privileged`,但这有安全风险,仅限测试环境。
container = self.client.containers.run(
image=self.base_image,
command=f"--relaxed-security --allow-insecure chromedriver_autodownload --base-path /wd/hub",
detach=True,
ports={f'{container_port}/tcp': host_port},
volumes={'/dev': {'bind': '/dev', 'mode': 'rw'}}, # 挂载设备文件
privileged=True, # 赋予特权模式,以便访问设备
environment={
'ANDROID_HOME': '/opt/android', # 假设镜像内已设置,或通过卷挂载宿主机SDK
'TZ': 'Asia/Shanghai'
},
name=f"appium_{device_info.udid[:8]}_{host_port}", # 给容器一个易识别的名字
remove=True # 容器停止后自动删除
)
device_info.appium_port = host_port
self.container_map[device_info.udid] = container.id
# 等待Appium Server在容器内启动完成
time.sleep(10) # 简单等待,生产环境应改为检测日志输出中是否出现“Appium REST http interface listener started”
return f"http://localhost:{host_port}/wd/hub"
def stop_appium_for_device(self, device_info: DeviceInfo):
"""停止并移除指定设备的Appium容器"""
container_id = self.container_map.get(device_info.udid)
if container_id:
try:
container = self.client.containers.get(container_id)
container.stop()
print(f"已停止Appium容器 for device {device_info.udid}")
except docker.errors.NotFound:
print(f"容器 {container_id} 未找到")
finally:
self.container_map.pop(device_info.udid, None)
踩坑实录 :在Docker中运行Appium并连接USB真机是跨平台的一个难点。在Linux上相对直接(挂载
/dev/bus/usb)。在macOS上,Docker Desktop默认不支持USB设备穿透,社区有一些解决方案(如usbmuxd),但都不完美。Windows同理。因此,对于真机测试,一个更稳定的方案是: 在宿主机上运行Appium Server,框架只负责管理端口和进程 。上面的Docker方案更适合连接模拟器/虚拟机,或者使用基于云的设备农场(如AWS Device Farm, BrowserStack)时,他们的节点本身就是容器化的。
3.3 集成pytest:实现多设备并发的Fixture
这是框架的灵魂所在。我们将创建一个关键的pytest fixture,它负责为每个测试用例获取设备、初始化Appium Driver。
# conftest.py
import pytest
import threading
from appium import webdriver
from .device_manager import DeviceManager
from .appium_docker_manager import AppiumDockerManager
# 全局管理器和线程锁
device_manager = DeviceManager()
appium_manager = AppiumDockerManager()
driver_cache = threading.local() # 线程局部存储,确保每个线程有自己的driver
def pytest_addoption(parser):
"""添加自定义命令行选项"""
parser.addoption("--app-path", action="store", default="./app.apk", help="被测应用的路径")
parser.addoption("--platform-version", action="store", default=None, help="期望的设备系统版本")
@pytest.fixture(scope="function")
def appium_driver(request):
"""核心Fixture:为每个测试函数提供独立的Appium Driver"""
# 1. 从命令行或配置中获取测试所需的能力
app_path = request.config.getoption("--app-path")
desired_platform_version = request.config.getoption("--platform-version")
desired_caps = {
"platformName": "Android",
"platformVersion": desired_platform_version, # 可能为None,由设备管理器匹配
"app": app_path,
"automationName": "UiAutomator2", # Android推荐使用UiAutomator2
"noReset": False, # 测试前后是否重置应用状态
"newCommandTimeout": 300,
"udid": None # 稍后由设备管理器填充
}
# 2. 从设备管理器申请一台空闲设备
device = device_manager.acquire_device({'platformVersion': desired_platform_version})
if not device:
pytest.skip("没有符合条件的可用设备")
# 3. 为该设备启动/分配Appium服务,并获取服务地址
# 这里简化处理,假设appium_manager能返回正确的服务URL
appium_service_url = appium_manager.get_service_url(device) # 这个方法需要实现,可能返回本地或远程URL
# 4. 将设备UDID填入Desired Capabilities
desired_caps['udid'] = device.udid
# 如果设备管理器能获取更多信息,也可以填入
desired_caps['deviceName'] = device.device_name
if device.platform_version:
desired_caps['platformVersion'] = device.platform_version
# 5. 创建Appium Driver
driver = webdriver.Remote(appium_service_url, desired_caps)
# 将driver存储在线程局部变量中,方便其他fixture或函数访问
driver_cache.driver = driver
driver_cache.device_info = device
# 6. 测试执行完成后,清理资源 (yield fixture模式)
yield driver
# 7. 测试结束后,退出driver并释放设备
driver.quit()
device_manager.release_device(device.udid)
# 注意:通常不会在每条用例后都停止Appium容器,而是所有用例跑完后统一清理,以节省启动时间。
@pytest.fixture(scope="function")
def driver(appium_driver):
"""一个更简短的别名fixture,方便在测试用例中使用"""
return driver_cache.driver
现在,我们的测试用例可以写得非常简洁:
# test_login.py
class TestLogin:
def test_login_success(self, driver):
"""测试成功登录"""
# 使用driver进行元素定位和操作
driver.find_element_by_id("com.example.app:id/username").send_keys("testuser")
driver.find_element_by_id("com.example.app:id/password").send_keys("password123")
driver.find_element_by_id("com.example.app:id/login_btn").click()
# 断言登录后的页面元素
welcome_text = driver.find_element_by_id("com.example.app:id/welcome_text").text
assert "testuser" in welcome_text
def test_login_failed_with_wrong_password(self, driver):
"""测试密码错误登录失败"""
driver.find_element_by_id("com.example.app:id/username").send_keys("testuser")
driver.find_element_by_id("com.example.app:id/password").send_keys("wrong")
driver.find_element_by_id("com.example.app:id/login_btn").click()
error_msg = driver.find_element_by_id("com.example.app:id/error_toast").text
assert "密码错误" in error_msg
当使用 pytest -n auto (配合 pytest-xdist )运行这些测试时, pytest-xdist 会创建多个工作进程。每个工作进程在执行测试函数时,都会请求 appium_driver 这个fixture。由于fixture是 function 作用域,每个测试函数都会触发一次设备申请和Driver创建。而我们的 DeviceManager 和线程锁保证了设备不会被重复分配,从而实现了多设备并发执行不同的测试用例。
4. 任务调度与并发执行引擎
有了设备管理和pytest集成,我们还需要一个顶层的调度引擎来协调整个测试流程。这个引擎负责:
- 加载测试用例(生产者)。
- 根据可用设备数量,创建消费者线程池。
- 将测试用例分发给空闲的消费者线程执行。
- 收集并汇总所有测试结果。
我们可以基于Python的 concurrent.futures 中的 ThreadPoolExecutor 来实现。
import concurrent.futures
import queue
import pytest
from typing import Callable, List
import json
class MultiDeviceTestRunner:
def __init__(self, device_manager: DeviceManager, max_workers: int = None):
self.device_manager = device_manager
# 最大工作线程数默认为当前空闲设备数
self.max_workers = max_workers or len([d for d in device_manager.get_idle_devices()])
self.task_queue = queue.Queue()
self.result_queue = queue.Queue()
def load_tests(self, test_path: str):
"""使用pytest的收集功能,获取所有测试项"""
# 这里使用pytest的内部API,生产环境需谨慎
# 更稳健的方式是使用 `pytest.main(['--collect-only', '-q', test_path])` 并解析输出
from _pytest.main import Session
from _pytest.config import Config
# 简化示例:假设我们有一个测试用例列表
# 实际项目中,可以从pytest的测试集合中动态获取
self.test_items = self._collect_test_items(test_path)
def _collect_test_items(self, test_path) -> List[str]:
"""收集指定路径下的所有测试用例节点ID"""
collector = []
pytest.main([test_path, '--collect-only', '-q'], plugins=[self._collector_plugin(collector)])
return collector
class _collector_plugin:
"""一个简单的pytest插件用于收集测试节点ID"""
def __init__(self, collector):
self.collector = collector
def pytest_collection_modifyitems(self, items):
for item in items:
self.collector.append(item.nodeid)
def worker(self, worker_id: int):
"""消费者线程的工作函数"""
while True:
try:
test_nodeid = self.task_queue.get(timeout=5) # 超时退出
except queue.Empty:
print(f"Worker-{worker_id}: 任务队列已空,退出。")
break
print(f"Worker-{worker_id} 开始执行测试: {test_nodeid}")
# 这里需要为每个worker创建一个独立的pytest运行进程/会话
# 因为pytest的fixture、插件等是有状态的,跨线程共享容易出问题。
# 我们通过子进程来运行单个测试用例。
import subprocess
result = subprocess.run(
['pytest', test_nodeid, '-v', '--tb=short', f'--html=report_worker_{worker_id}.html'],
capture_output=True,
text=True
)
# 解析结果,存入结果队列
test_result = {
'worker': worker_id,
'test': test_nodeid,
'returncode': result.returncode,
'stdout': result.stdout,
'stderr': result.stderr,
'passed': result.returncode == 0
}
self.result_queue.put(test_result)
self.task_queue.task_done()
def run(self):
"""启动测试执行"""
# 1. 填充任务队列
for test in self.test_items:
self.task_queue.put(test)
# 2. 创建并启动工作线程池
with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures = [executor.submit(self.worker, i) for i in range(self.max_workers)]
# 等待所有任务完成
self.task_queue.join()
# 等待所有工作线程结束
concurrent.futures.wait(futures)
# 3. 收集并生成最终报告
self.generate_summary_report()
def generate_summary_report(self):
"""从结果队列中生成汇总报告"""
results = []
while not self.result_queue.empty():
results.append(self.result_queue.get_nowait())
total = len(results)
passed = sum(1 for r in results if r['passed'])
failed = total - passed
report = {
"summary": {
"total": total,
"passed": passed,
"failed": failed,
"pass_rate": (passed / total * 100) if total > 0 else 0
},
"details": results
}
with open('final_report.json', 'w') as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(f"测试完成!通过率:{report['summary']['pass_rate']:.2f}%")
重要提示 :上面的
worker函数使用subprocess调用pytest执行单个测试用例,这是一种“进程级隔离”的方案,非常稳健,避免了多线程环境下pytest内部状态混乱的问题。但它的开销也相对较大(每个用例都启动一个pytest进程)。另一种更高效但更复杂的方式是,每个工作线程维护一个独立的pytest会话(Session)对象,在其内部循环执行不同的测试用例。这需要对pytest的内部API有更深的理解。
5. 增强功能与最佳实践
一个基础的框架搭建完成后,我们需要考虑如何让它更强大、更易用。
5.1 测试数据驱动与参数化
多设备测试常常需要验证同一功能在不同数据下的表现。我们可以利用 pytest 的 @pytest.mark.parametrize 装饰器,轻松实现数据驱动。
import pytest
class TestSearch:
@pytest.mark.parametrize("keyword, expected_count", [
("Appium", ">100"),
("一个不存在的关键词", "0"),
("", "请输入关键词"), # 空值测试
])
def test_search_function(self, driver, keyword, expected_count):
"""搜索功能测试,不同关键词预期不同结果"""
search_box = driver.find_element_by_id("com.example.app:id/search_box")
search_box.clear()
search_box.send_keys(keyword)
driver.find_element_by_id("com.example.app:id/search_btn").click()
if ">" in expected_count:
# 断言结果数大于某个值
result_text = driver.find_element_by_id("com.example.app:id/result_count").text
actual_count = int(result_text.split()[0])
min_count = int(expected_count.replace('>', ''))
assert actual_count > min_count, f"结果数{actual_count}未大于{min_count}"
elif expected_count == "0":
# 断言无结果提示
no_result = driver.find_element_by_id("com.example.app:id/no_result_tip")
assert no_result.is_displayed()
else:
# 断言提示信息
toast_text = driver.find_element_by_xpath("//android.widget.Toast[1]").text
assert expected_count in toast_text
当这个测试类在多设备上并发执行时,每个设备会分配到不同的参数组合进行测试,极大地提高了测试覆盖率和效率。
5.2 失败重试与截图归档
移动端测试不稳定是常态(网络波动、弹窗干扰、应用卡顿)。为关键用例添加失败重试机制能有效减少误报。我们可以使用 pytest-rerunfailures 插件。
# 安装插件
pip install pytest-rerunfailures
在 conftest.py 中或命令行中配置:
# conftest.py 中通过hook函数添加自动截图
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""在测试报告生成时,如果测试失败,自动截图"""
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
# 只有测试执行阶段失败才截图
try:
# 从线程局部存储中获取driver
driver = getattr(driver_cache, 'driver', None)
if driver:
screenshot_path = f"./screenshots/failure_{item.name}_{report.nodeid.replace('::', '_')}.png"
driver.save_screenshot(screenshot_path)
# 将截图路径附加到测试报告中,供Allure等报告工具使用
if hasattr(report, 'extra'):
from pytest_html import extras
report.extra.append(extras.image(screenshot_path))
print(f"测试失败,截图已保存至:{screenshot_path}")
except Exception as e:
print(f"截图失败:{e}")
然后在运行测试时,添加重试参数:
pytest --reruns 2 --reruns-delay 3 # 失败后重试2次,每次间隔3秒
5.3 集成Allure生成精美报告
pytest-allure 插件可以生成非常直观的测试报告,尤其适合展示并发测试的时间线和环境信息。
- 安装Allure命令行工具和pytest插件。
- 在测试运行时添加
--alluredir参数指定报告原始数据目录。 - 在框架的顶层调度器中,确保每个工作线程(或进程)生成的Allure数据都在独立的子目录中,最后再合并。
# 在MultiDeviceTestRunner的worker函数中修改pytest命令
result = subprocess.run([
'pytest', test_nodeid, '-v',
'--alluredir', f'./allure_raw_data/worker_{worker_id}', # 每个worker独立目录
'--clean-alluredir' # 每次运行清理旧数据
], capture_output=True, text=True)
所有测试执行完毕后,使用Allure命令行工具合并并生成报告:
allure generate ./allure_raw_data/ --clean -o ./allure_report/
allure open ./allure_report/
生成的报告会清晰展示每个测试用例在哪个设备上执行、耗时多久、是否通过,并附上失败时的截图和日志,一目了然。
5.4 框架配置化
一个好的框架应该尽可能通过配置文件来驱动,而不是硬编码在代码里。我们可以使用 config.yaml 或 config.json 来管理。
# config.yaml
appium:
server_mode: "docker" # 或 "local"
docker_image: "appium/appium:2.0"
local_server_path: "/usr/local/bin/appium"
devices:
android:
- udid: "emulator-5554"
alias: "Pixel_4_API_30"
- udid: "RF8M80XXXXX"
alias: "小米10"
ios: [] # iOS设备列表
test:
app_path: "./app/build/outputs/apk/debug/app-debug.apk"
test_dir: "./testcases"
report_dir: "./reports"
max_workers: 4
desired_capabilities:
platformName: "Android"
automationName: "UiAutomator2"
noReset: false
fullReset: false
框架启动时读取这个配置文件,动态构建测试环境。
6. 常见问题排查与实战技巧
在实际使用中,你一定会遇到各种各样的问题。这里记录一些高频问题的排查思路和技巧。
6.1 设备连接与识别问题
- 问题 :
adb devices列表为空,或设备状态为unauthorized。 - 排查 :
- 检查USB线是否连接稳定,尝试更换线缆或USB接口。
- 在设备上确认“USB调试”已开启。对于Android 11及以上,还需在连接时在设备上点选“允许”进行授权。
- 运行
adb kill-server && adb start-server重启ADB服务。 - 检查PC端是否有其他程序(如手机助手、其他IDE)占用了ADB。
- 技巧 :在框架的设备发现模块中,加入重试机制和状态检查。如果发现
unauthorized设备,可以尝试通过adb命令触发授权对话框(但无法自动点击“允许”)。
6.2 Appium Session创建失败
- 问题 :
WebDriverException: Cannot create new session because... - 排查 :
- 检查Desired Capabilities :这是最常见的原因。确保
appPackage和appActivity(对于Android)或bundleId(对于iOS)绝对正确。可以使用adb shell dumpsys window | grep mCurrentFocus(Android)查看当前前台Activity。 - 检查Appium Server日志 :这是最重要的调试信息。日志会明确告诉你为什么session创建失败,比如“应用未安装”、“activity不存在”、“权限被拒绝”等。确保你的框架能方便地查看每个Appium实例的日志。
- 检查端口冲突 :确保为每个设备分配的Appium端口没有被其他程序占用。
- 检查应用签名 (Android):如果测试包和已安装的应用签名不一致,可能无法覆盖安装。在
Desired Capabilities中设置"noReset": true有时能绕过,但最好统一签名。
- 检查Desired Capabilities :这是最常见的原因。确保
6.3 元素定位失败与等待策略
- 问题 :测试脚本在某个设备上能运行,在另一个设备上报“元素找不到”(
NoSuchElementException)。 - 排查与技巧 :
- 屏幕分辨率与缩放 :不同设备分辨率不同,可能导致元素位置偏移或根本不在屏幕内。 绝对避免使用基于坐标的定位(如
tap) 。始终坚持使用resource-id,accessibility-id,xpath等与位置无关的属性定位。 - 动态内容与等待 :网络加载慢的设备更容易出现元素还未加载就进行定位的情况。使用 显式等待(WebDriverWait) ,而不是
time.sleep()。from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from appium.webdriver.common.appiumby import AppiumBy # 不好的做法 time.sleep(5) element = driver.find_element_by_id("some_id") # 好的做法 wait = WebDriverWait(driver, 10) # 最多等10秒 element = wait.until(EC.presence_of_element_located((AppiumBy.ID, "some_id"))) - 使用相对定位和滚动查找 :对于列表中的元素,可以先定位到列表,再在其中查找。使用
UiScrollable(Android)或mobile: scroll(跨平台)命令来滚动查找元素。 - 启用
autoGrantPermissions:在Capabilities中设置"autoGrantPermissions": true,让Appium自动处理应用权限弹窗,避免弹窗遮挡元素。
- 屏幕分辨率与缩放 :不同设备分辨率不同,可能导致元素位置偏移或根本不在屏幕内。 绝对避免使用基于坐标的定位(如
6.4 并发执行下的资源竞争与稳定性
- 问题 :多个测试同时操作同一台设备(误分配)、日志混杂、测试报告混乱。
- 解决 :
- 严格的设备锁 :确保
DeviceManager.acquire_device()方法是线程安全的,使用threading.Lock。 - 独立的日志流 :为每个设备或每个测试线程配置独立的日志文件和
Appium服务输出。可以使用Python的logging模块,为每个线程设置不同的FileHandler。 - 测试用例独立性 :这是自动化测试的基本原则。确保每个测试用例都能独立运行,不依赖其他用例的状态。善用
setup和teardown(或pytest的fixture)来准备和清理测试环境。 - 引入随机等待 :在关键操作之间添加微小的随机等待时间(如
time.sleep(random.uniform(0.5, 1.5))),可以降低多个设备同时进行密集操作时对宿主机资源(CPU、ADB Server)造成的压力,减少因资源竞争导致的超时失败。
- 严格的设备锁 :确保
6.5 在CI/CD流水线中集成
框架的最终归宿是集成到CI/CD(如Jenkins, GitLab CI, GitHub Actions)中,实现无人值守的自动化测试。
- 环境准备 :在CI节点上预装好Docker、Python环境、Android SDK命令行工具。对于真机,可以考虑使用常插在节点上的测试机,或者连接基于云的设备农场。
- 脚本化启动 :将框架的启动、执行、报告生成步骤编写成一个Shell脚本(如
run_tests.sh)。 - 结果通知 :在CI脚本中,解析最终的测试报告(如JSON或Allure报告),根据通过率决定本次构建的状态(成功/失败)。并将结果通过邮件、钉钉、企业微信等渠道通知团队。
- 测试机管理 :在Jenkins上可以使用插件来管理设备的在线状态。更高级的做法是,框架提供一个RESTful API,CI流水线在测试开始时调用API来预约设备,测试结束后释放设备。
搭建一个成熟稳定的多设备自动化测试框架是一个系统工程,需要不断地迭代和优化。从最简单的多线程脚本开始,逐步完善设备管理、异常处理、报告系统和CI集成。这个过程中积累的经验和教训,远比框架本身的代码更有价值。记住,框架的目标是提升效率和质量,而不是增加复杂度。始终保持代码的清晰和可维护性,让团队的其他成员也能轻松上手和贡献,这才是框架能够长久生存的关键。
更多推荐
所有评论(0)