python和Appium的移动端多设备自动化测试框架


Posted in Python onApril 26, 2022

前言:

本篇文章主要介绍基于pytest和Appium框架,支持Android和iOS功能自动化的测试框架。同时该框架支持多设备测试,并利用allure库,生成可视化测试报告。本框架主要涉及的内容包括:python3、pytest、appium、allure等,此处已假设你具备相应的基础知识,同时已有可以随时运行的测试环境(iOS设备的测试只能在Mac系统中执行,没有Mac的朋友们,可以看看不执行)

一、流程图

本部分内容先从自动化测试的整体流程开始介绍,目的是希望大家在开始动手去实现框架之前,对测试过程做到清晰明了,这样在实现过程中,才能帮助我们无论何时,都不会迷茫和不知所措。才能让我们知道从何开始,如何优化以及拓展。

那么我们先来看下面这张流程图: 

python和Appium的移动端多设备自动化测试框架

以上是本文所介绍框架的核心流程图,上图已经展现了框架的核心流程,所以在接下来的讲述中,大家可以参考该图进行理解和优化。

二、appium服务

在开始我们的测试之前,还有很多的工作需要我们去处理,这其中最重要,也是我们开始的第一步,就是开启appium的本地服务。关于appium的实现原理,本文不作过多的讲解,小编会抽空进行补充,届时也希望大家能及时关注。心急的小伙伴也可以自行百度哦~这里仅介绍启动服务的方法。

根据appium官方的介绍,我们可以通过下面的方式来启动appium服务:

/usr/local/bin/appium -a ip -p port

也就是我们在启动appium时,指定ip和端口,一般来说,本地ip使用127.0.0.1即可,官方默认端口为4723,我们也可以修改成自己想要的端口,只要保证使用的端口没有被其他服务占用即可。(小技巧:如果你不知道自己appium安装路径,可通过which appium来帮你找到)

启动服务之后,一般我们可以通过访问这个连接来验证服务是否正常:http://127.0.0.1:4723/wd/hub/status。可正常访问并返回json格式数据时,则说明服务已正常启动。

但事实上,并不是每次启动都可以顺利进行,总会有一些意外的情况发生。比如说端口被占用。遇到这种情况我们也不必惊慌,做好应对即可。那么今天我们就上述的过程结合python,把它实现出来。

上面的过程,用python来实现,其实很简单,我们这里选择使用python中的subprocess库来执行命令,从而达到我们预期。

代码片段如下:

import subprocess
import abc
import socket
class Driver:
	__metaclass__ = abc.ABCMeta
	self._host = '127.0.0.1'
	@abc.abstractmethod
	def connect_appium(self, port, n)
		"""
		待实现的连接设备方法
		"""
		return
	def start_appium(self, port):
		server = self.get_local_server_path()
        host = readConfig.ReadConfig().get_commend("host")
        log_path = root_path + '/result/log'
        cmd = "%s -a %s -p %s" % (server, host, str(port))
        if self.check_port(int(port)):
            subprocess.Popen(cmd, shell=True, stdout=open('%s/AppiumServer%s.log' % (log_path, port), 'w'))
            log.logger.info('%s/AppiumServer%s.log' % (log_path, port))
        else:
            log.logger.info("关闭被占用的端口号:%s" % str(port))
            self.kill_appium()
            log.logger.info("端口释放完毕!启动Appium-server,端口号:%s" % str(port))
            subprocess.Popen(cmd, shell=True, stdout=open('%s/AppiumServer%s.log' % (log_path, port), 'w'))
            log.logger.info("Appium日志信息存储地址: %s/AppiumServer%s.log" % (log_path, port))
    def check_port(self, port):
        """
        检查端口占用情况
        :param port:
        :return:
        """
        try:
            host = local_read_config.get_commend("host")
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            log.logger.info(s.connect((host, port)))
            s.shutdown(2)
        except OSError:
            log.logger.info("端口:%s 可用" % str(port))
            return True
        else:
            log.logger.info("端口:%s 已被占用" % str(port))
            return False

以上代码,会在启动appium服务之前,通过socket检查本地端口是否被占用,若被占用,则先释放端口,然后再启动服务,否则直接启动服务。

至此,服务启动完成,接下来就可以开始连接测试设备。

三、连接测试设备

当我们启动好appium服务后,就可以开始链接测试设备了。因为我们要同时支持Android和iOS的设备,所以我们先来定义一个Driver类,用来封装一些共有属性及方法,然后让Android和iOS分别继承它。

appium对于设备的连接,官方给我们提供了详细的方法事例:

# Android environment
from appium import webdriver
desired_caps = dict(
    platformName='Android',
    platformVersion='10',
    automationName='uiautomator2',
    deviceName='Android Emulator',
    app=PATH('../../../apps/selendroid-test-app.apk')
)
self.driver = webdriver.Remote('http://localhost:4723/wd/hub', desired_caps)
el = self.driver.find_element_by_accessibility_id('item')
el.click()
# iOS environment
from appium import webdriver
desired_caps = dict(
    platformName='iOS',
    platformVersion='13.4',
    automationName='xcuitest',
    deviceName='iPhone Simulator',
    app=PATH('../../apps/UICatalog.app.zip')
)
self.driver = webdriver.Remote('http://localhost:4723/wd/hub', desired_caps)
el = self.driver.find_element_by_accessibility_id('item')
el.click()

在以上两个示例中,我们发现,链接设备使用的都是同一个方法,但不同的设备需要传入不同的参数,

下面便是链接的关键: 

driver = webdriver.Remote('http://localhost:4723/wd/hub', desired_caps)

既然我们找到了共性,那么就可以对该部分内容进行一番改造,让它来自动完成一些它可以完成的事情。那么首先,我们来看一下,再链接设备的过程中,我们到底做了些什么。

从上面的代码不难看出,每台设备连接都可以看成两步:第一步配置连接参数、第二步请求连接。

那么我们就可以封装一些类和方法,来完成我们想要分端操作的想法了。其实并不困难,我们可以分别写两个类AndroidDriver和IOSDriver,都继承自Driver,然后实现设备连接的方法。

具体实现可参考下面的内容:

from Driver import Driver
class AndroidDriver(Driver):
    def __init__(self):
        self.driver = None

    def get_desired_caps(self):
        """
        实现继承的抽象类方法;获取链接设备的配置信息
        返回设备配置信息
        :return:desired_caps
        """
        desired_list = []
        package = local_read_config.get_value("ANDROID", "package")
        activity = local_read_config.get_value("ANDROID", "activity")
        devices_info = self.update_devices_info()
        for i in range(len(devices_info)):
            udid = devices_info[i].get("udid")
            device_name = devices_info[i].get("devices_name")
            platform_version = devices_info[i].get("version")
            system_port1 = 8200 + 2 * i
            desired_caps = {
                "platformName": "Android",
                "platformVersion": platform_version,
                "appPackage": package,
                "appActivity": activity,
                "deviceName": device_name,
                "automationName": "uiautomator2",
                "udid": udid,
                "systemPort": system_port1,
                "newCommandTimeout": 3000,
                # "adbExecTimeout": 50000
            }
            desired_list.append(desired_caps)
        return desired_list
    def connect_appium(self, port, n):
        """
        根据传入的port,启动appium服务
        :param port:
        :param n:
        :return:
        """
        set_adb_path()
        desired_caps = self.get_desired_caps()
        try:
            self.driver = webdriver.Remote("%s:%s/wd/hub" % (super()._remote_url, str(port)), desired_caps[n])
            return self.driver
        except WebDriverException:
            raise WebDriverException
        except ConnectionError:
            raise ConnectionError

上面的方法主要做了两件事情,首先收集连接设备需要的desired_caps信息,然后是连接设备。需要注意的是,因为我们这个框架是支持多个测试设备同时连接的,所有这里我们把收集到的每台测试设备的desired_caps信息放到了一个数组中,并且在连接设备的时候,我们通过appium服务的端口号和数组下标两个值,来确定,每台测试设备连接的appium服务。

小提示:一个appium服务无法同时连接多个手机,但是我们希望能同时连接多个测试手机,并且同时在这连接的多个手机上进行测试,所以我们这里启动了多个appium服务,并指定了每个启动的服务端口号。因此我们只需要将端口号和设备信息对应上即可。

至此,启动服务和测试设备连接的实现就结束了,接下来就是对元素的操作了。那么我们一起来看一下,关于Element的那些事情。

四、元素封装

众所周知,元素的操作依赖于元素查找。

举个常见的例子:我想百度搜索一个关键词,那么我首先要找到搜索框,才能输入关键词,然后找到搜索按钮,并点击搜索。这就是我们要做的。

常见的定位元素的方法有:ID、XPATH、CLASSNAME、NAME、PREDICATE等,selenium提供了对应的方法,我们这里也不做过多的封装,大家可以直接使用,也可以像我这样,把一些常见的定位方式封装成一个统一的方法,实现如下:

def get_element(self, element_id):
        """
        获取指定页面的元素路径数据
        :param element_id: 元素ID
        :return: 获取的元素对象
        """
        element_type = self.page.get(element_id).get("pathType")
        element_value = self.page.get(element_id).get("pathValue")
        element = None
        if element_type == "ID":
            element = self.driver.find_element_by_id(element_value)
        elif element_type == "CLASSNAME":
            element = self.driver.find_element_by_class_name(element_value)
        elif element_type == "XPATH":
            element = self.driver.find_element_by_xpath(element_value)
        elif element_type == "NAME":
            element = self.driver.find_element_by_name(element_value)
        elif element_type == "ACB_ID":
            element = self.driver.find_element_by_accessibility_id(element_value)
        elif element_type == "PREDICATE":
            element = self.driver.find_element_by_ios_predicate(element_value)
        return element

大家自己选择是否进行封装,正常调用selenium的方法也是OK哒。

同样的道理,我们还可以封装一些常用的操作,比如滑动屏幕,键盘操作等。

分端元素操作

因为我们分别接入了Android和iOS,那么它们的操作,各有不同之处,我们可以将各自的特色操作分别集中到一个单独的AndroidElement类和iOSElement类中,这样在后面使用的时候,我们直接继承这两个类就可以,并且从结构上看,也比较清晰。

比如同样是滑动屏幕,swipe在Android和iOS系统上的表现就不一致,因此我们就选择了其他方法:

AndroidElement:

def swipe_to_up(self):
        """
        向上划,页面滚动到最下方
        :return:
        """
        width = self.driver.get_window_size()["width"]
        height = self.driver.get_window_size()["height"]
        self.driver.swipe(width / 2, height * 3 / 5, width / 2, height / 5, duration=500)

iOSElement:

def swipe_to_up(self):
        """
        向上滑动
        :return:
        """
        self.driver.execute_script('mobile: swipe', {'direction': 'up'})

以上只是一个小例子,只是想说明,如果有这样的操作差异,我们可以将它们分开处理,这样会显得逻辑更清晰。

有了上面的实现,我们就只需要写测试的脚步就可以。写脚本部分的内容就先略过,不做详细描述,毕竟不同的业务需求场景,都有其独特的脚本逻辑。凡事万变不离其宗,元素还是那个元素,操作还是那些操作,就让大家自己去尽情发挥吧。

那么,一切准备就绪,就差让我们的程序跑起来了。接下来就让我们来看看,如何让我们的测试同时在多个连接的测试设备上进行测试。

五、运行

因为我们的测试是通过pytest来执行的,所以pytest的所有执行参数都是可以正常使用的。而我们,也只是利用pytest的main函数来完成本次执行。唯一不同的是,为了满足不同设备同时进行测试,我们为每一台设备的测试,都创建了一个进程。每一个进程都包含了上述完整的流程。选择进程而非线程的原因也很简单,相信大家也都知道,进程和线程的关系吧,在同一个进程中的线程资源是共享的。而在我们看来,每一台设备的测试都应该是独立的、互不干扰的,所以我们选择进程而非线程。

具体实现如下:

from multiprocessing import Process
import pytest
import time
import os, re
import subprocess
from appiums.common import read_files
from appiums.driver.iOSDriver import IOSDriver
from driver.androidDriver import AndroidDriver
from driver import Driver
from elements import Element

class Run(Process):
    def __init__(self, name, args):
        super(Run, self).__init__()
        self.name = name
        self.args = args
        self.root_path = os.getcwd()
        self.device_name = re.sub('[\']', '', str(args[2].get("deviceName")).replace(" ", "_"))
    def run_test(self):
        """
        执行测试用例
        :return:
        """
        pytest.main([
                     '--alluredir', '%s/result/data/%s' % (self.root_path, self.device_name)])
        time.sleep(2)
    def generate_report(self):
        """
        整合测试报告到项目根目录下的result/report目录下
        :return: none
        """
        cmd = "allure generate %s/result/data/%s -o %s/result/report/%s --clean" \
              % (self.root_path, self.device_name, self.root_path, self.device_name)
        stdout = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, text=True)
        log.logger.info("测试报告查看路径:%s" % str(stdout.stdout.readlines()[0]).split(" ")[-1][:-1])
    def get_environment_info(self):
        """
        获取测试环境的信息
        :return:
        """
        env = {
            "测试平台": self.args[2].get("platformName"),
            "设备名称": self.device_name,
            "设备系统版本": self.args[2].get("platformVersion"),
            "设备udid": self.args[2].get("udid"),
            "应用名称": self.args[2].get("bundleId") if str(self.args[2].get("platformName")).lower() == 'ios' else self.args[2].get("appPackage"),
        }
        return env
    def run(self):
        """
        执行线程中的任务
        :return:
        """
        Driver.Driver().start_appium(self.args[0])
        time.sleep(5)
        self.set_driver()
        time.sleep(1)
        self.run_test()
        time.sleep(1)
        read_files.set_environment(self.device_name, self.get_environment_info())
        time.sleep(1)
        self.generate_report()

def main(desired_caps):
    """
    开启测试进程执行测试
    """
    list_p = []
    process_num = len(desired_caps)
    if process_num > 0:
        for a in range(process_num):
            port1 = 4723 + 2 * a
            p = Run('测试进程-%s' % str(port1), args=(port1, a, desired_caps[a]))
            p.start()
            log.logger.info("设备%s在进程 %s 上进行测试, 进程ID:%s" % (desired_caps[a].get("deviceName"), p.name, p.pid))
            list_p.append(p)
        for b in list_p:
            b.join()
        Driver.Driver().kill_appium()
    else:
        log.logger.error("没有设备可进行测试,请重新连接设备后尝试!")
        exit(-1)

def android_run():
    caps = AndroidDriver().get_desired_caps()
    main(caps)

def ios_run():
    caps = IOSDriver().get_desired_caps()
    main(caps)

到此这篇关于python和Appium移动端多设备自动化测试框架实现的文章就介绍到这了!

Python 相关文章推荐
Python 学习笔记
Dec 27 Python
详解Django缓存处理中Vary头部的使用
Jul 24 Python
说一说Python logging
Apr 15 Python
python中print的不换行即时输出的快速解决方法
Jul 20 Python
Python实现SMTP发送邮件详细教程
Mar 02 Python
python模拟登录并且保持cookie的方法详解
Apr 04 Python
python中 * 的用法详解
Jul 10 Python
修改Pandas的行或列的名字(重命名)
Dec 18 Python
python将图片转base64,实现前端显示
Jan 09 Python
python实现堆排序的实例讲解
Feb 21 Python
Python实现UDP程序通信过程图解
May 15 Python
pycharm 使用anaconda为默认环境的操作
Feb 05 Python
Python查找算法的实现 (线性、二分,分块、插值查找算法)
Python 装饰器(decorator)常用的创建方式及解析
Apr 24 #Python
解决IDEA翻译插件Translation报错更新TTK失败不能使用
python使用BeautifulSoup 解析HTML
Apr 24 #Python
Python中npy和mat文件的保存与读取
Apr 24 #Python
python小型的音频操作库mp3Play
Apr 24 #Python
5个pandas调用函数的方法让数据处理更加灵活自如
Apr 24 #Python
You might like
PHPMYADMIN 简明安装教程 推荐
2010/03/07 PHP
浅析php变量作用域的一些问题
2013/08/08 PHP
PHP实现取得HTTP请求的原文
2014/08/18 PHP
redirect_uri参数错误的解决方法(必看)
2017/02/16 PHP
用javascript实现在小方框中浏览大图的代码
2007/08/14 Javascript
别了 JavaScript中的isXX系列
2012/08/01 Javascript
JavaScript中使用Object.prototype.toString判断是否为数组
2015/04/01 Javascript
js获取隐藏元素宽高的实现方法
2016/05/19 Javascript
关于js函数解释(包括内嵌,对象等)
2016/11/20 Javascript
JS控制鼠标拒绝点击某一按钮的实例
2017/12/29 Javascript
让网站自动生成章节目录索引的多个js代码
2018/01/07 Javascript
在Mac下彻底卸载node和npm的方法
2018/05/16 Javascript
浅谈Vue.js路由管理器 Vue Router
2018/08/16 Javascript
详解Node.js异步处理的各种写法
2019/06/09 Javascript
详解Vue串联过滤器的使用场景
2020/04/30 Javascript
ajax jquery实现页面某一个div的刷新效果
2021/03/04 jQuery
Python中logging模块的用法实例
2014/09/29 Python
Python字符串匹配算法KMP实例
2015/07/18 Python
Python实现更改图片尺寸大小的方法(基于Pillow包)
2016/09/19 Python
在Django同1个页面中的多表单处理详解
2017/01/25 Python
python matlibplot绘制多条曲线图
2021/02/19 Python
Python从单元素字典中获取key和value的实例
2018/12/31 Python
Python3的高阶函数map,reduce,filter的示例详解
2019/07/23 Python
Python 多线程搜索txt文件的内容,并写入搜到的内容(Lock)方法
2019/08/23 Python
Python切图九宫格的实现方法
2019/10/10 Python
Python图像处理库PIL的ImageFont模块使用介绍
2020/02/26 Python
使用AJAX和Django获取数据的方法实例
2020/10/25 Python
初一家长会邀请函
2014/01/31 职场文书
治安消防安全责任书
2014/07/23 职场文书
幼儿园园长新年寄语
2015/08/17 职场文书
Python深度学习之实现卷积神经网络
2021/06/05 Python
python实现剪贴板的操作
2021/07/01 Python
python编程学习使用管道Pipe编写优化代码
2021/11/20 Python
Redis+Lua脚本实现计数器接口防刷功能(升级版)
2022/02/12 Redis
我去timi了,一起去timi是什么意思?
2022/04/13 杂记
详解Go语言中配置文件使用与日志配置
2022/06/01 Golang