Android Appium+python自动化框架

一直假装没有时间整理自动化的东西,想来这笔债不能总是拖。大概年前的时候组里说要尝试着进行自动化方面的工作,就做了相关方面知识的学习。当然对于一个普通的黑盒测试人员来说,我们选择了从UI自动化入手。

一、需求——确定框架

开始做Android自动化只为了解决“多台设备同时自动执行一套测试代码,并输出相关日志或者图片的log”。因为大家的代码能力都不高,关于使用哪种工具,并没有经过太多的探讨,选择了较容易入手的Appium + python unittest框架。

以此框架就确定了下来:以python unittest为基础,并对Appium进行封装。

后来加了临时需求,说既然要并发跑appium,那么也能用来并发运行monkey test。

以此对应的需求:UI test + monkey test

尝试了一些开源的框架,但不太适合我们产品和需求,因此只好自己写一个简单的框架出来。

二、框架——搭建

前面两篇文章已介绍了搭建过程以及环境问题,这里就不再冗余,具体请见:Android自动化 -- Appium环境搭建Mac OSX上的python环境

官网上比较仔细地介绍了Android并发测试,详见Android并发测试
)

三、模块介绍——所解决的问题

由于我们的产品在使用前必须登陆,需要保证每个设备登陆不同的账号。细细想来,我们需要解决的问题大概有如下几点:

1.即可运行appium又可运行monkey,但又不能同时运行这两个任务 --> 任务划分,区分monkey和appium服务
2.根据多设备启动多个appium --> Appium Server模块
  |-- 负责处理Appium Server启动,停止,监听等 --> server的模块  
  |-- 负责处理多设备信息的模块 --> Device Object
  |-- 负责处理多个登陆用户信息的模块 --> User Object
3.封装常用方法,统一放在一个地方进行 --> Tester Object模块
4.基于unittest管理testcase --> TestCaseManager模块
  |-- Testcase --> 可测试的用例
  |-- TestLoader --> 创建test suit
  |-- TextTestRunner --> RunTestManager模块
5.处理异常 --> 沿用unittest框架的处理方式
6.测试结果报告html形式输出 --> TestResult模块
7.优化初始化如安装、卸载、登陆、处理权限、拷贝测试图片等等准备工作 --> BaseDevicePreProcess模块
1. 任务划分,区分monkey和appium服务

这里是这么构思的,构建一个SimpleHTTPServer,每次执行任务前先请求Server,Server端判断当前是否有正在执行的任务,如果有正在执行的,就返回个错误信息;如果没有,就开始执行任务。

class HttpServerHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
    //省略好多内容
    def run(self, params):
        if share.get_if_run() == True:
            result_dict = {'code':1002,"data":{"message":"已经有一个任务在执行","taskid":"%s" % share.get_taskid()}}
            self.set_response(result_dict)
            return
        if params.has_key('mode') == False:
            result_dict = {'code':1003,"data":{"message":"缺少mode参数"}}
            self.set_response(result_dict)
            return
        elif params['mode'][0] != "monkey" and params['mode'][0] != 'autotest':
            self.set_response({'code':1004, "data":{"message":"mode参数错误"}})
            return

        try:
            set_run_manager(RunTestManager(params['mode'][0]))
            self.taskid = get_run_manager().task_id
            share.set_taskid(get_run_manager().task_id) #设置全局共享taskid
            share.set_if_run(True)
            thread = threading.Thread(target=get_run_manager().start_run)
            thread.start()
            result_dict = {'code':0,"data":{"taskid fuck":"%s" % self.taskid,"message":"开始执行%s任务" % params['mode']}}
            self.set_response(result_dict)
        except Exception, e:
            traceback.print_exc()
            get_run_manager().stop_run()
2. 根据多设备启动多个appium
// 一个例子
$ appium -p 4736 -bp 4836 -U b33aa57c --session-override
// -p Appium的主要端口
// -bp Appium bootstrap端口
// -U 设备id

启动多个设备需要运行时根据不同的端口进行appium配置。所以得先有个处理devices的模块。按照惯例,把安装包和设备的详细信息和用户登陆账号等以list方式写进config文件里,后面再读出来。

// congif.yaml 文件
NiceAPK: /Users/xxxxx/com.nice.main.apk    # 测试包的路径
Devices:
 - deviceid: 5HUC9S6599999999    # 设备识别adb devices的值
   devicename: OPPO_R9M    # 设备的名称,用于区分
   serverport: 4723    # -p Appium的主要端口,设备之间不能重复
   bootstrapport: 4823    # -bp Appium bootstrap端口,设备之间不能重复
   platformname: Android    # desired_caps
   platformversion: 5.1    # desired_caps
   server: 127.0.0.1     # 地址

 - deviceid: 7c404969
   devicename: OPPO_A33
   serverport: 4724
   bootstrapport: 4824
   platformname: Android
   platformversion: 5.1.1
   server: 127.0.0.1

Users:
 - uid: 33333333333
   username: test01
   mobile: 33333333333
   password: 333333

 - uid: 44444444444
   username: test02
   mobile: 44444444444
   password: 444444

然后分别创建Device object和User object(和Device object一致)

class Device(object):
    def __init__(self, deviceid):
        self._deviceid = deviceid
        self._devicename = ""
        self._platformversion = ""
        self._platformname = ""
        self._bootstrapport = ""
        self._serverport = ""
        self._server = ""

然后我们可以通过DataProvider来实例化设备和用户信息

class DataProvider(object):
    @classmethod
    def load_devices(cls):
        cls.devicenamelist = []
        for device in cls.config['Devices']:
            deviceobject = Device(device['deviceid'])
            deviceobject.devicename = device['devicename']
            deviceobject.serverport = device['serverport']
            deviceobject.bootstrapport = device['bootstrapport']
            deviceobject.platformname = device['platformname']
            deviceobject.platformversion = device['platformversion']
            deviceobject.server = device['server']
            cls.devices[deviceobject.deviceid] = deviceobject
            cls.devicenamelist.append(device['devicename'])
        Log.logger.info(u"配置列表中一共有 %s 台设备" % len(cls.devices))

    @classmethod
    def load_users(cls):
        for user in cls.config['Users']:
            userobject = User(user['uid'])
            userobject.username = user['username']
            userobject.mobile = user['mobile']
            userobject.password = user['password']
            cls.users.append(userobject)
        Log.logger.info(u"配置列表中一共有 %s 个用户信息" % len(cls.users))

有了devices和users,后面我们就可以创建个server类来处理appium server的启动、停止、监听设备等等功能。例如根据多设备来启动多个appium

class Server:
    def __init__(self, deviceobject):
        self.logger = Log.logger
        self._deviceobject = deviceobject
        self._cmd = "appium -p %s -bp %s -U %s --session-override" % (
        self._deviceobject.serverport, self._deviceobject.bootstrapport, self._deviceobject.deviceid)
3. 封装常用方法的Tester类

这里的tester用于存放driver、共用的封装方法,如点击、滑动、截视频、图像对比等等方法

class Tester(object):
    def __init__(self, driver):
        self._driver = driver
        self._user = None
        self._device = None
        self._logger = None
        self.action = TouchAction(self._driver)
        self._screenshot_path = ""
        self.device_width = self._driver.get_window_size()['width']
        self.device_height = self._driver.get_window_size()['height']
4. 管理TestCase的TestCaseManager类

因为是基于python unittest,我们沿用unittest的方式,这里只添加一个参数化功能,用来方便我们指定所需要测试的集合。

class BaseTestCase(unittest.TestCase):
    def __init__(self, methodName='runTest', tester=None):
        super(BaseTestCase, self).__init__(methodName)
        self.tester = tester

    @staticmethod
    def parametrize(testcase_klass, tester=None):
        testloader = unittest.TestLoader()
        testnames = testloader.getTestCaseNames(testcase_klass)
        suite = unittest.TestSuite()
        for name in testnames:
            suite.addTest(testcase_klass(name, tester=tester))
        return suite

然后创建个TestCaseManager来处理不同种类的测试类型

class TestCaseManager(object):

    def __init__(self, tester):
        self.compatibility_suite = unittest.TestSuite()
        self.testcase_class = []
        self.load_case()
        self.tester = tester

    def load_case(self):
        testcase_array = []
        testsuits = unittest.defaultTestLoader.discover('testcase/', pattern='test*.py')
        for testsuite in testsuits:
            for suite in testsuite._tests:
                for test in suite:
                    testcase_array.append(test.__class__)
        self.testcase_class = sorted(set(testcase_array), key=testcase_array.index)

    # 兼容性测试用例
    def compatibility_testsuite(self):
        for testcase in self.testcase_class:
            self.compatibility_suite.addTest(BaseTestCase.parametrize(testcase, tester=self.tester))
        return self.compatibility_suite

    # monkey自动化
    def monkey_android(self):
        self.tester.run_monkey(200,1000)

    # 功能性测试用例
    def functional_testsuite(self):
        pass

    # 单独运行一条指定的用例
    def signal_case_suit(self, test_myclass):
        suite = unittest.TestSuite()
        suite.addTest(BaseTestCase.parametrize(test_myclass, tester=self.tester))
        return suite

那么实际要运行的时候,我们在TextTestRunner传个参数来指定运行的suit就可以了

  suite = TestCaseManager(tester).compatibility_testsuite()    # 运行兼容集合
  // suite = TestCaseManager(tester).functional_testsuite()      # 运行功能测试集合
  // suite = TestCaseManager(tester).signal_case_suit(test_case_001)    # 运行单条测试用例
  unittest.TextTestRunner(verbosity=2, resultclass=TheTestResult).run(suite)

TextTestRunner的执行部分写在了RunTestManager里

class RunTestManager(object):
    def start_run(self):
      //判断执行的类型,并调用start_run_test方法
    def start_run_test(self):
      //初始化tester object,并调用run方法并传tester object参数
    def init_tester_data(self, device, which_user):
      //初始化tester object
    def run(self, tester):
      //预处理(登陆、权限等流程),并调用unittest.TextTestRunner开始执行
    def stop_run(self):
      //结束运行,置server flag为false,表示当前不在有任务运行
5. 处理异常

沿用unittest框架的处理方式,在TestResult中重写addError、addFailure、addSuccess、addSkip等等一系列方法来满足我们自己的需求。特别是对addFailure的处理,我们需要详尽的知道哪台设备的哪里出了错,并且能输出截图和log日志。

   def addFailure(self, test, err):
        info = '************      - %s -!(Fail)    ***************' % self.tester.device.devicename
        self.logger.warning(info)
        info = 'Fail device:%s Run TestCase %s, Fail info:%s' % (self.tester.device.devicename, test, err[1].message)
        self.logger.warning(info)
        info = '***********************************************'
        self.logger.warning(info)

        # 失败截图
        mytest = str(test)
        simplename = clean_brackets_from_str(mytest).replace(' ', '')
        myscr = "Failure_%s" % simplename
        self.tester.screenshot2(myscr)

        # 失败日志
        list = traceback.format_exception(err[0], err[1], err[2])
        list_fail = []  # 列表包含要输出的错误日志信息
        # list_fail[0]='error:'
        # list_fail[1]=list[2:3]
        # list_fail[2]=list[-1]
        list_fail.append(list[-1])
        list_fail.append(list[2])

        self.__class__.totalresults[self.deviceid]['failtestcase'] = self.__class__.totalresults[self.deviceid]['failtestcase'] + 1

        self.__class__.detailresults[self.deviceid][test]['result'] = 'Fail'
        self.__class__.detailresults[self.deviceid][test]['reason'] = list_fail
6. 测试结果报告html形式输出

同上面的异常处理,结果的输出也放在TestResult来执行。不知道当时怎么想的,输出处理这块用了pyh。所有的表格都是一点点画出来的,心很累,还抽空搞了下css和js,美化了一下样式。代码很长就不贴了,基本是一个div一个div写出来的。直接看源码就好了,这里不展开啦。



样式上还有bug。。。。因为一些设备意外退出导致的,这个暂时won't fix。。。。

7. 关于预处理部分,优化初始化过程

这里有很多的工作,比如安装,处理每台设备登陆过程,处理登陆界面的权限问题,拷贝测试图片等等。毕竟只有登陆了才能进行测试!!!
1)因为不想每次启动appium都要安装setting\unlock\ime等apk,所以修改了Appium源码,不让他自己安装。运行的时候,由我们自己的函数处理安装过程

// 干掉自动安装
文件: /usr/local/lib/node_modules/appium/node_modules/appium-android-driver/lib/driver.js,注释以下几句代码
await this.adb.uninstallApk(this.opts.appPackage);
await helpers.installApkRemotely(this.adb, this.opts);
await helpers.resetApp(this.adb, this.opts.app, this.opts.appPackage, this.opts.fastReset);
await this.checkPackagePresent();

文件:/usr/local/lib/node_modules/appium/node_modules/appium-android-driver/build/lib/driver.js 注释以下几句代码
return _regeneratorRuntime.awrap(_androidHelpers2['default'].resetApp(this.adb, this.opts.app, this.opts.appPackage, this.opts.fastReset));
return _regeneratorRuntime.awrap(this.adb.uninstallApk(this.opts.appPackage));
return _regeneratorRuntime.awrap(_androidHelpers2['default'].installApkRemotely(this.adb, this.opts));
return _regeneratorRuntime.awrap(this.checkPackagePresent());

文件:/usr/local/lib/node_modules/appium/node_modules/appium-android-driver/lib/android-helpers.js 注释以下几句代码
await adb.install(unicodeIMEPath, false);
await helpers.pushSettingsApp(adb);
await helpers.pushUnlock(adb);

文件 /usr/local/lib/node_modules/appium/node_modules/appium-android-driver/build/lib/android-helpers.js 替换以下几句代码
return _regeneratorRuntime.awrap(helpers.initUnicodeKeyboard(adb)) 替换为return context$1$0.abrupt('return', defaultIME);
return _regeneratorRuntime.awrap(helpers.pushSettingsApp(adb)); 替换为return context$1$0.abrupt('return', defaultIME);
return _regeneratorRuntime.awrap(helpers.pushUnlock(adb)); 替换为return context$1$0.abrupt('return', defaultIME);

2)由于不同设备的安装会有极大的不同,比如有的需要确认usb安装,有的设备会询问你是否安装;高API会弹授权提示,低API没有提示等等不协调的地方有很多。因此统一写个了PreProManager类来管理设备,目的是给每一台设备分配他自己的执行函数

class PreProManager(object):
    def __init__(self, tester):
        self.tester = tester
        self.deviceid = self.tester.device.deviceid

    def device(self):
        if self.deviceid == "5HUC9S6599999999":
            return OPPOR9PreProcess(self.tester)
        elif self.deviceid =="7c404969":
            return OPPOA33PreProcess(self.tester)

然后写个BaseDevicePreProcess基类描述预处理过程,上面的各个设备的执行函数直接继承这个基类,并复写里面的一些方法就行了

class BaseDevicePreProcess(object):
    def __init__(self, tester):
        self.tester = tester
        self.driver = self.tester.driver
        self.action = TouchAction(self.driver)
        self.user = self.tester.user

    # 开始预处理流程
    def pre_process(self):
      // 卸载、安装等等

    # 安装流程
    def install_app(self):
        self.driver.install_app(DataProvider.niceapk)

    # 版本升级
    def upgrade_app(self):
      // ...

    # 该流程包括处理安装及启动过程中的各种弹窗,一直到可以点击login按钮
    def install_process(self):
        pass
      // 由子类复写 

    # 该流程包括点击login按钮到达登录页面,并登录
    def login_process(self):
      // 处理登陆流程 

    # 该流程包括登录成功后,对各种自动弹出对话框进行处理
    def login_success_process(self):
        pass
      // 由子类复写 

    # 对所有需要的权限进行处理,例如:相机、录音
    def get_permission_process(self):
        pass
      // 由子类复写      

    def data_prepare(self):
      // 写入测试data

这里举例说明每个设备如何继承基类打造自己的专属处理流程

from BaseDevicePreProcess import *
class OPPOR9PreProcess(BaseDevicePreProcess):
    def __init__(self,tester):
        super(OPPOR9PreProcess, self).__init__(tester)  

    def install_process(self):
        // OPPOR9的专属登陆处理方法
        // 如果不需要复写,则直接用基类中的默认流程执行

    def login_success_process(self):
        // 处理登陆呦
        // 如果不需要复写,则直接用基类中的默认流程执行

    def get_permission_process(self):
        //  OPPOR9的专属处理授权问题方法呦
        // 如果不需要复写,则直接用基类中的默认流程执行

总结:

搭建框架的过程中,遇到了很多困难,不过很开心的是基本都解决了。现有的这些已经能在项目中run起来,但仍有诸多地方不够完善需要持续优化。
慢慢加油吧
代码已上传至github:
https://github.com/h080294/appium_python_android.git

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容