如何多设备并行执行UI自动化的测试

346 阅读13分钟

在自动化测试的过程中,UI 自动化可以模拟人进行界面操作,并且非常接近人类的真实行为。但是,它最大的问题是执行速度相对较慢,在用例很多的情况下,执行时间过长,如果 App 发版频繁,UI 自动化是无法满足时间要求的。比如,所有的回归用例加起来有 200 条,全部执行完需要一个半小时,但每半小时 App 就打一次包,等用例执行完,可能已经打了三四个新包了。

那怎么办呢?

其实,我们很容易想到的方案,也是目前实践中应用比较多的方案,是多设备并行测试,通过线性扩展来缩短耗时。显然,这个思路确实可以提高执行速度,但在真正实践时,总会遇到各种问题。比如,在配置了多设备并且开始运行之后,稳定性很差,用例经常执行中断;再比如,一个设备在执行时,其他设备处于等待状态。

那么,如何让多设备稳定地并行执行测试呢?这就是我接下来要跟你分享的内容。

一、多设备并行的工作原理

首先,我们结合着 Appium 这个自动测试框架,来学习一下多设备并行的工作原理。

为什么要结合着 Appium 来学习呢?因为 Appium 是一个开源、跨平台的自动化测试框架,它的优点是稳定性高、社区强大,并且同时支持 Android、iOS、混合和移动 Web 等平台,使用非常广泛。今天我们要学习的多设备并行 UI 自动化测试方法,其实就是基于 Appium 的二次封装。

你可能了解过 Android 的自动化框架 UIAutomator,谷歌将 UIAutomator 从版本 1 升级到版本 2 之后,修复了很多问题,也增加了很多功能,但这次升级最重要的是实现了与 Android 系统更新的分离。所以,下面,我就以 Appium+Android+UIAutomator2 为例,给你解释多设备并行的工作原理。

我们看下 Appium UIAutomator2 的工作原理。

在 PC 端,你启动一个 Appium Server( http://localhost:4723/wd/hub) ,测试脚本通过 4723 端口与 PC 端的 Appium Server 通信,手机端会自动安装一个 io.appium.uiautomator2.server.apk,之后启动手机端 Server,Appium Server 利用 SystemPort(默认是 8200)与手机端通信,包括发送操作指令(如点击、滑动)和获得指令执行的响应结果。

基于这个原理,在多设备并行时,PC 端就需要有多个 Appium Server(比如 http://localhost:4724/wd/hub,http://localhost:4725/wd/hub) ,和多个 SystemPort(比如 8201、8202),并且每增加一个设备就要增加一个 Appium Server 和 SystemPort。设计原理可以参照这张图::

你可能会好奇,上面这个多设备并行的思路,具体要怎么实现呢?其实,它的具体实现方式是用 Python 多线程按照手机的设备 ID 给每个设备分配测试用例、Appium Server 和 SystemPort。

二、多设备测试实践

通过上面的介绍,你应该已经了解了多设备并行测试的工作原理,接下来我会从多设备的数据传递、多设备并行测试时 Appium Driver 配置、自动启动多个 Appium Server、多线程运行用例以及如何分配多个用例给多个设备这五方面,来向你介绍具体怎么实现多设备并行测试。

多设备的数据传递

在多设备并行执行测试时,首先要着重考虑的一点就是数据传递问题,而使用不同的测试框架,数据传递思路也是不同的。

目前主流的测试框架有两种:

  • 第一种:UnitTest+HTMLTestRunner
  • 第二种:Pytest+Allure

对比下这两种测试框架,UnitTest 的用例格式复杂,无兼容性,插件少;相比之下 Pytest 更加方便快捷、用例格式简单、插件丰富,可以执行 Unittest 风格的测试用例,且无须修改 UnitTest 用例的任何代码,有较好的兼容性。综合来看,在多设备并行执行测试的时候,Pytest+Allure 的效率更高,测试报告更详尽。

所以,下面我就以 Pytest+Allure 为例,介绍如何在多设备并行执行时进行数据传递。

在使用 Pytest+Allure 时,因为要在每个子线程中使用 os.system(f"Pytest -p no:warning {case} --alluredir {report_path}") 的方式来运行用例,相当于在每个子线程中都启动了一个进程,那么 device_id、SystemPort 和 Appium Server 这三个参数必须传递给测试用例,否则测试用例不知道自己需要在哪个设备上执行。

那么在这种情况下,如何把 device_id、SystemPort 和 Appium Server 这三个参数传递给测试用例呢?

我们可以使用 Redis 来达到我们的目的,这张图就是引入 Redis 的方案示意图:

在图上左边程序入口,将这个 all_devices_info 包含的测试用例、Appium Server、

设备 ID、SystemPort,都一一对应信息解析并以测试用例名作为 key 存入 Redis。

#all_devices_info 中'Test.py'是测试用例,'4723'是 Appium Server 的 Port,'679ec5d9'是设备 id,'8201'是 SystemPort
all_devices_info = [
[['Test1.py','Test2.py',], '4723', 'device:679ec5d9', '8201'],
[['Test1.py', 'Test3.py', ], '4727', 'device:a87186e5', '8203'],
[['Test1.py', 'Test4.py', ], '4729', 'device:7b22831', '8204'],
]

存入之后,Redis 中存储展示是这样的:

这张图是 Test1.py:

这张图是 Test2.py:

信息存入 Redis 后,启动多线程,每个子线程启动一个或者多个 Pytest 进程,进程中运行测试用例,测试用例的 setupClass() 使用测试用例名作为 key 去 Redis 中读取对应设备和端口信息。这样就知道哪个测试用例占用哪些端口、运行在哪个设备中了。

这里有一点需要注意,同一个测试用例在多个设备上运行时,需要考虑多个进程同时来 Redis 取数据,下面我举一个具体的例子来说明。

以上图中 Redis key 是 Test1.py 为例,如果多个设备都需要执行同一个测试用例 Test1.py,就需要注意了:并发启动多线程及进程后,Test1.py 可能在多个进程中都开始运行,进程 1 读取 Redis Test1.py 的 list 中的第一条设备端口信息['4729', 'device:7b22831', '8204']后,必须立即删除,防止进程 2 也来读取第一条信息。

为了解决这个问题,我们可以使用 Redis 的 LPOP 命令。LPOP 命令的作用是,移除并返回列表的第一个元素,由于 Redis 单线程的特性,这种方式完全满足了这个需求,不需要自己再写程序实现。

#该方法读取的同时并删除
already_used_value = redis_conn.lpop(case_key)

这样就解决了数据传递和读取问题。

多设备时 Appium Driver 配置

前面介绍多设备并行原理的时候提到过,Appium Server 是连接测试脚本和设备的通道,启动 Appium Server 的前提是配置好 Appium Driver,那么具体如何配置呢?

首先,你需要在启动 Appium Driver 时指定启动参数:

#伪代码,启动 appium driver 时必须配置的一些启动参数
def set_up_driver(appium_port, device_id, system_port):
    desired_caps = {
    #这里省略了其他的配置项
    ......
    desired_caps['udid'] = device_id
    desired_caps['automationName'] = 'UiAutomator2'
    desired_caps['systemPort'] = system_port
    driver = webdriver.Remote('http://localhost:'+appium_port
                                     +'/wd/hub', desired_caps)
    return driver

自动启动多个 Appium Server

指定完 Appium Driver 的启动参数之后,就可以启动 Appium Server 了。

PC 端 Appium Server 有两种启动方式:桌面版和命令行版。桌面版需要手动启动,所以多设备自动执行时最好选用命令行版。具体的实现是:先判断端口是否被占用,如果被占用就杀掉占用这个端口的进程,否则就直接命令行启动 Appium Server。

#判断端口是否被占用
def get_occupy(self,port):
    if windows:
        command_get_port_occupy = f'netstat -aon|findstr {port}'
    else:
        command_get_port_occupy = f'lsof -i tcp:{port}'
    p = os.popen(command_get_port_occupy)
    p_lower = p.read().strip().lower()
   return p_lower
#杀掉占用该端口的进程
if windows and 'listening' in p0:
    pid_num = p0.split('listening')[1].strip().
                     split(' ')[0].replace('\n','').replace('\r','')
    command_kill = f'taskkill /F /PID {pid_num}'
elif 'listening' not in p0:
    print('输出中没有 listening!! 不做任何事')
else:
    #非 windows 系统
    pid_num = p0.split('appium')[1].strip().
                        split(' ')[0].replace('\n','').replace('\r','')
    command_kill = f'kill {pid_num}'
os.popen(command_kill)
#启动 Appium Server
def start_appium_server(self,port):
    self.stop_appium_server(port)
    bp_port = int(port)+1
if windows:
    command = f"start appium -a 127.0.0.1 -p {port} -bp {bp_port} --                       session-override --log-timestamp --local-timezone"
else:
    command = f"appium -a 127.0.0.1 -p {port} -bp {bp_port} --session-                 override"
os.system(command)

多线程运行用例

讲到这里,我们继续再看这张思路图,数据存储进了 Redis,Appium Server 也启动了,接着就是多线程运行用例了,如何实现多线程呢,这里用到的是 Python 的线程池,一个子线程对应一个设备:

#伪代码
class MyThread(object):
    #线程池
    executor = ThreadPoolExecutor(max_workers=20)
    def __init__(self, appium_port, device_id, system_port):
        self.appium_port = appium_port
        self.device_name = device_id
        self.system_port = system_port
    def task(self):
        #运行用例
        AllureReport().run(self.different_case)
    def execute(self, different_case):
        startAppium = StartAppium()
        # 启动 Appium Server
        startAppium.start_appium_server(self.appium_port)
        #测试用例
        self.different_case = different_case
        #执行线程
        MyThread.executor.submit(self.task)
#设备 1
MyThread(appium_port1, device_id1, system_port1).execute(diffrent_case1)
#设备 2
MyThread(appium_port2, device_id2, system_port2).execute(diffrent_case2)
......

分配测试用例给多个设备

在执行用例时,假如按 App 功能分测试用例,分了 20 个组,一共有 5 个设备,那肯定就涉及到怎么把这 20 个测试用例组分配到 5 个设备上来执行。

分配用例的方式有两种。

第一种方式,手动指定测试用例到多个设备。这个方式的优势是可以自己根据测试用例的执行时间,来选择把哪些用例放在一个设备上,这样每个设备的运行结束时间不会相差太多,总体结束较快。实现方式是这样的:

#all_devices_info 中'Test.py'是测试用例,'4723'是 appium port,'679ec5d9'是设备 id,'8201'是 system port
all_devices_info = [
[['Test1.py','Test2.py',], '4723', 'device:679ec5d9', '8201'],
[['Test2.py', 'Test3.py', ], '4727', 'device:a87186e5', '8202'],
]
for i in range(all_devices_info.__len__()):
    device_info = all_devices_info[i]
    diffrent_case,port,device_name,system_port=
                                device_info[0], device_info[1],
                                device_info[2], device_info[3]
    #用解析到的设备及端口信息启动线程
    MyThread(port, device_name, system_port).execute(diffrent_case)

第二种方式是选择自动平均分配。比如,有 3 个设备,10 条测试用例,设备 A 分配到 4 个测试用例,设备 B 和设备 C 都分配到 3 个测试用例,这是平均分配的方式:

# 平均分配测试用例到多个设备上,传入两个参数:设备 list、测试用例 list
def distribute_cases(self, device_selected,run_cases):
    distribute_case_device_info = []
    appium_port = ['4723','4725','4727','4729','4731','4733',...]
    system_port = ['8201','8202','8203','8204','8205','8206',...]
    #整数部分
    integer_part = math.floor(len(run_cases)/len(device_selected))
    #余数部分
    remainder = len(run_cases)%len(device_selected)
    for m in range(int(integer_part)):
        for i in range(len(device_selected)):
            part_info = [[run_cases[len(device_selected) * m + i]],                                appium_port[i],f'device:{device_selected[i]}',
                        system_port[i]]
            distribute_case_device_info.append(part_info)
    for r in range(remainder):
        part_info = [[run_cases[len(device_selected)*integer_part+r]],
                     appium_port[r],f'device:{device_selected[r]}',
                     system_port[r]]
        distribute_case_device_info.append(part_info)
#该返回字段包含端口、设备和用例信息
   return distribute_case_device_info

第一种方式执行的速度最快,更适合在回归测试中使用,大家可以根据自己的意愿来定制不同的测试计划,缺点是需要人工维护脚本。第二种方式则更加灵活,不需要人工干预去分配任务,可以实现即插即用,测试用例随机分配,更适合在 Devops 中使用,缺点是执行时间不可控。

三、多设备并行测试过程中的注意事项

好,讲到这里,多设备并行的原理和实践,相信你都了解了。最后我再跟你分享三点注意事项,如果你在工作中遇到这几点问题,那到时候你就知道该怎么解决了。

线程安全:如单例类的线程安全

提到多线程,肯定就会涉及线程安全,这是我们要注意的第一个问题。而框架中使用到的工具类一般都是单例类,比如 Log、断言、读写 Redis 等,所以都需要使用线程安全的单例模式,实现是这样的:

# 线程锁 装饰器
def synchronized(func):
    func.__lock__ = threading.Lock()
    def lock_func(*args, **kwargs):
        with func.__lock__:
            return func(*args, **kwargs)
    return lock_func
# 断言类,单例
class Assertion(object):
    # 单例对象
    assertion = None
    # 初始化标记
    init_flag = False
    # 线程安全装饰器
    @synchronized
    def __new__(cls, *args, **kwargs):
        # 单例对象存在,不再进行创建
        if cls.assertion is None:
            cls.assertion = super().__new__(cls)
        return cls.assertion
    # 初始化方法
    def __init__(self):
        # 通过 init_flag 属性使初始化只进行一次
        if Assertion.init_flag == False:
            Assertion.init_flag = True

多设备时的过程产物(Log 和截图、视频)

第二个注意事项,是关于多设备并行的过程产物。

前面用例执行完,为了方便分析结果,会有很多过程产物,其中 Log、截图和视频是必不可少的。那么,在多设备运行时,如果某条用例出错,怎么知道这条用例是在哪个设备上运行时出错的呢?

这时候就需要在命名和记录时都带上 device_id,用例失败后,方便很快找到对应出错设备,比如你看这张图,Log 中每一行都包含 device_id:

视频命名也包含 device_id:

679ec5d9_test09_CircleSquare_subject_add_20201023164218.mp4

多设备时执行中断问题

第三个注意事项,也是我们经常遇到的问题,就是多设备并行的时候,经常会出现执行中断,常见的报错 log 信息有如下几种:

  • “error: Unhandled”
  • “error: read ECONNRESET”
  • “error: A new session could not be created”

其实,这一般都是因为端口分配的问题。比如,因为多线程数据传递问题导致执行错乱或多个设备共用一个端口,或者因为 new 了一个 appium_driver 之后,再 new 下一个 appium_driver 之前没有执行 driver.quit(),都会导致某条用例执行中断。

总的来说,我们使用多设备并行测试的目的,就是提高测试效率,降低人力资源成本,在数据传输上可以引用 Redis 或者 MySQL 来帮助存储和读取,这样做可以提高数据传输的灵活性和多样性。在启动多个 Appium Server 时,建议使用命令行的方式来提高效率减少人为干预。多线程运行用例时,一个子线程对应一个设备,不要一对多或者多对一。分配测试用例到具体设备的时候,要根据目的和场景选择最优的方式,最终目的只有一个,那就是提高产品稳定性,满足用户的需求。