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)}")

 

posted @ 2025-05-30 15:11  山重水复疑无路  阅读(124)  评论(0)    收藏  举报