Appium实战-如何实现多设备并行
背景
兼容性测试也是测试的一大重点。单设备运行实现方式很简单,也不需要考虑实现方案之间的冲突,如果自动化一个一个运行也不是不可以,但是会导致时间成本的增加,那如何才能实现多设备并行执行呢?
需求
多设备并行:多个设备同时运行,注意:不是串行,是并行。
测试用例保存:使用yaml文件,方便维护
设备数据保存:使用yaml文件,方便维护。
回归测试:整个流程测试。因为加载yaml文件,如果是列表,会直接顺序执行,不需要进行排序
方案
之前已经写好了单设备的测试,用的是appium+pytest+数据驱动,想在此基础上进行改造,网上搜索最多的pytest-xdist,但是有很多限制:用例之间不能有依赖,用例随机执行,不适合我的需求。后来又尝试很多方法,无法做到既用pytest,又用数据驱动,所以舍弃了pytest,直接用代码实现。
为每个设备创建一个线程,然后在该线程下创建驱动,每个设备只能执行自己的测试用例,设备-线程-驱动-用例,每个设备独立运行,互不干扰。为什么不同设备不能执行同一用例呢?如果是Android原生方便定位的可以,但是如果是跨平台有好多元素无法检测到,就要用到各种定位方法。例如,坐标定位就不能适配所有设备。如果要做适配就只能修改代码,维护起来比较麻烦,而且之后如果再修改还要一步一步进行适配,修改起来比较麻烦,所以,每个设备维护自己的用例。
代码实现
1.启动多线程,为每台设备启动一个线程
2.检查appium是否已经启动,如果端口被占用,杀死进程
3.启动appium
4.创建driver
5.执行用例
devices.yaml
devices:
- deviceName: "979c8d6"
udid: "979c8d6"
automationName: "uiautomator2"
platformName: "Android"
appPackage: "应用包名"
appActivity: "启动页的activity"
enableMultiWindows: True
ensureWebviewsHavePages: True
enableWebviewDetailsCollection: True
appium_port: 4723
system_port: 8200
noReset: true # 避免重复安装
caseFile: "case_data_1.yaml"
# - deviceName: "4londmijjnzhkng6" # 4londmijjnzhkng6
...
case_data_1.yaml
# case_data_1.yaml
test_cases:
- name: "1协议框"
element: ["uiautomator", 'new UiSelector().resourceId("io.dcloud.UNI0F29818.dis:id/btn_custom_privacy_sure")']
action: "click"
- name: "2请输入账号"
element: ["xpath", '//*[@text="请输入账号"]/following-sibling::*[1]']
action: "send_keys"
text: "18298987676"
- name: "2.3勾选协议"
element: ["xpath", '//*[@text="获取验证码"]/following-sibling::*[1]']
action: "click"
- name: "2.1点击获取验证码"
element: ["uiautomator", 'new UiSelector().text("获取验证码")']
action: "click"
- name: "2.2输入验证码"
element: ["xpath", '//*[@text="请输入验证码"]/following-sibling::*']
action: "send_keys"
text: "123456"
multi_device_runner.py
import subprocess import time import psutil import yaml from appium import webdriver from appium.options.android import UiAutomator2Options from testCleaning.multi_device.case_executor import execute_cases from concurrent.futures import ThreadPoolExecutor from testCleaning.utils.kill_process import kill_process_by_port def device_runner(device_config): # 启动服务 start_appium_server(device_config['appium_port']) # 执行测试 run_test(device_config) def load_config(): with open('devices.yaml') as f: return yaml.safe_load(f) processes = [] def start_appium_server(port): kill_process_by_port(port) time.sleep(10) cmd = f"appium -p {port} --log-level warn" proc = subprocess.Popen(cmd, shell=True) processes.append(proc) return processes def run_test(device_config): caps = { "platformName": device_config.get("platformName", "Android"), "automationName": device_config.get("automationName", "uiautomator2"), "udid": device_config.get("udid"), "appPackage": device_config.get('appPackage'), "appActivity": device_config.get('appActivity'), "systemPort": device_config['system_port'], "newCommandTimeout": 300, "platformVersion": device_config.get("platformVersion"), "enableMultiWindows": device_config.get("enableMultiWindows", True), "ensureWebviewsHavePages": device_config.get("ensureWebviewsHavePages", True), "enableWebviewDetailsCollection": device_config.get("enableWebviewDetailsCollection", True), "unicodeKeyboard": True, "resetKeyboard": True } time.sleep(20) try: driver = webdriver.Remote( f"http://127.0.0.1:{device_config['appium_port']}", # 修正了这里的语法错误 options=UiAutomator2Options().load_capabilities(caps) ) # 执行对应yaml文件中的测试用例 execute_cases(device_config['caseFile'], driver) except Exception as e: print(f"设备{device_config['udid']}测试失败: {str(e)}") finally: driver.quit() if 'driver' in locals() else None if __name__ == '__main__': config = load_config() with ThreadPoolExecutor(max_workers=len(config['devices'])) as executor: executor.map(device_runner, config['devices'])
case_executor.py
import time import yaml from selenium.common import InvalidElementStateException from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from testCleaning.test_Cleaning import LOCATOR_TYPE_MAP from testCleaning.utils.key import KeyCodeActions def parse_locator(element_data): locator_type_str, locator_value = element_data return (LOCATOR_TYPE_MAP[locator_type_str], locator_value) def execute_cases(case_file, driver): with open(case_file) as f: cases = yaml.safe_load(f) for case in cases['test_cases']: print("开始执行:"+case['name']) element = None if case.get('element'): locator = parse_locator(case['element']) element = WebDriverWait(driver, 15).until( EC.element_to_be_clickable(locator) ) # 根据action类型执行对应操作 action = case.get('action') if action == 'click': element.click() elif action == "send_keys": text = case.get('text', '') try: element.send_keys(text) except InvalidElementStateException as e: element.click() key_actions = KeyCodeActions(driver) for i in text: key_actions.press_key(i) # 注意:Android13以上send_keys()方法会报错,只能使用物理按键 time.sleep(1) # 休眠1s,防止按键过快 elif action == "get_text": return element.text elif action == "enter": driver.press_keycode(66) elif action == "back": driver.press_keycode(4) elif action == "swipe": time.sleep(2) swipe_up(driver) elif action == "swipe_small": time.sleep(2) swipe_up_small(driver) elif action == "swipe_down": time.sleep(2) swipe_down(driver) elif action == 'tap': time.sleep(2) driver.tap(case.get("coordinates", []), duration=100) else: raise ValueError(f"不支持的操作类型: {action}") def swipe_up(driver, duration=200): size = driver.get_window_size() start_x = size['width'] // 2 start_y = size['height'] * 0.8 # 起始点 Y 坐标为屏幕高度 80% end_y = size['height'] * 0.2 # 终点 Y 坐标为屏幕高度 20% driver.swipe(start_x, start_y, start_x, end_y, duration) def swipe_up_small(driver, duration=200): size = driver.get_window_size() start_x = size['width'] // 2 start_y = size['height'] * 0.8 # 起始点 Y 坐标为屏幕高度 80% end_y = size['height'] * 0.6 # 终点 Y 坐标为屏幕高度 20% driver.swipe(start_x, start_y, start_x, end_y, duration) def swipe_down(driver, duration=200): size = driver.get_window_size() start_x = size['width'] // 2 start_y = size['height'] * 0.2 # 起始点 Y 坐标为屏幕高度 80% end_y = size['height'] * 0.8 # 终点 Y 坐标为屏幕高度 20% driver.swipe(start_x, start_y, start_x, end_y, duration)
key.py
class KeyCodeActions: def __init__(self, driver): self.driver = driver # 定义常见按键的 keycode 映射 self.keycodes = { # 常用硬件键 "home": 3, "back": 4, "volume_up": 24, "volume_down": 25, "enter": 66, "menu": 82, "search": 84, "camera": 27, "power": 26, # 数字键 0-9 "0": 7, "1": 8, "2": 9, "3": 10, "4": 11, "5": 12, "6": 13, "7": 14, "8": 15, "9": 16, # 字母键 A-Z "a": 29, "b": 30, "c": 31, "d": 32, "e": 33, "f": 34, "g": 35, "h": 36, "i": 37, "j": 38, "k": 39, "l": 40, "m": 41, "n": 42, "o": 43, "p": 44, "q": 45, "r": 46, "s": 47, "t": 48, "u": 49, "v": 50, "w": 51, "x": 52, "y": 53, "z": 54, } def press_key(self, key_name): """根据按键名称按下对应的键""" if key_name in self.keycodes: self.driver.press_keycode(self.keycodes[key_name]) else: print(f"按键 '{key_name}' 不存在!")
kill_process.py
import psutil def kill_process_by_port(port): """兼容各psutil版本的端口进程终止方法""" try: for proc in psutil.process_iter(['pid']): try: # 兼容新旧版本属性名 conn_attr = getattr(proc, 'connections', None) or getattr(proc, '_connections', None) if conn_attr and any(conn.laddr.port == port for conn in conn_attr()): if proc.pid != 0: proc.kill() except (psutil.NoSuchProcess, psutil.AccessDenied): continue except Exception as e: print(f"Process kill error: {str(e)}")

浙公网安备 33010602011771号