使用uiautomator的python封裝進行android的UI測試

最近項目中有個需求要在至少100臺手機上對應用進行兼容性測試,首先想到的就是自動化來操做,不想一臺臺的操做相同的重複操做python

基本的需求是這樣的,安裝被測試的應用,啓動並退出,而後安裝測試樣本,檢測是否有相應的彈窗攔截android

考慮到市面上的各類測試框架與自已熟悉的編程語言,最後選擇了google自家的uiautomator來搞,藉助於前人對其進行了python封裝,因此一開始仍是挺順利的,可是整個過程當中仍是有不少須要注意的地方git

https://github.com/xiaocong/uiautomator   這個是xiaocong對其進行的python封裝,也是這個小測試用例使用的,膜拜下github

準備:python27,不能使用python26,安裝urllib3與uiautomator,可使用easy_install命令,安裝android SDK,配置好adb的環境變量,這些應該都是做爲android測試人員最基本的環境配置,要測試的應用是360手機急救箱,能夠從http://jijiu.360.cn/ 這個網址下載shell

下面是基本的測試流程編程

# 須要配置好adb 環境變量
# 1.先肯定有幾臺手機
# 2.再肯定有多少個應用
# 3.先安裝mkiller,啓動mkiller
# 4.再安裝測試的樣本
# 5.檢查是否有取消安裝的按鈕出現,出現說明測試經過,沒出現說明測試失敗多線程

 

既然要採用自動化,就不能手機測試那樣,一臺一臺的跑,應該能夠同時跑多臺手機,個人想法就是啓用多線程來跑,每一個手機用一個線程來跑app

肯定有幾臺手機,我封了一個方法框架

def finddevices():
    rst = util.exccmd('adb devices')
    devices = re.findall(r'(.*?)\s+device',rst)
    if len(devices) > 1:
        deviceIds = devices[1:]
        logger.info('共找到%s個手機'%str(len(devices)-1))
        for i in deviceIds:            
            logger.info('ID爲%s'%i)
        return deviceIds
    else:
        logger.error('沒有找到手機,請檢查')
        return

下面來講說uiautomator在python中的使用,其實github中的readme.md寫的挺清楚,可是實踐起來仍是有一些問題編程語言

uiautomator在使用的時候都要初始化一個d對象,單個手機能夠經過

from uiautomator import device as d

 

多臺手機能夠

from uiautomator import Device

而後經過 d=Device(Serial)的方式初始化d對象,之後的操做基本上都是操做這個d對象,能夠想象每一個d對應着一臺手機

我以爲這個設計有點不大好,我如今還常常在device的大小寫上犯迷糊

基本的點擊操做

# press home key
d.press.home()
# press back key
d.press.back()
# the normal way to press back key
d.press("back")
# press keycode 0x07('0') with META ALT(0x02) on
d.press(0x07, 0x02)

首先安裝啓動應用,安裝採用adb install 命令,啓動採用adb shell am start 命令

手機急救箱的launchable-activity是'com.qihoo.mkiller.ui.index.AppEnterActivity',第一次啓動會彈出使用協議要用戶來點擊」贊成並使用」

dump_3164510212585030182

我這裏採用了watcher來監視而且點擊,基本的watcher方法是

d.watcher('agree').when(text=u'贊成並使用').click(text=u'贊成並使用')

先給watcher起一個名字,隨便起,我這裏叫agree,when裏面寫條件,我這裏就是當text爲’贊成並使用’,後面寫當符合這些條件的時候進行的操做,我這裏就是click(text=u'贊成並使用'),這裏有一個坑,我以前寫watcher的時候,就直接寫click() 我覺得裏面不寫內容默認就會點擊前面找到的元素,可是後來發現這樣是不行的,必需要寫上要點擊哪一個對象

其實對於這種只出現一次的view能夠不用寫在watcher裏,能夠直接寫d(text=u'贊成並使用').click(),可是考慮到這個界面出現以前會有一些延遲,各類手機的性能不一樣,也很差加time.sleep()時間,因此我建議像這種一概寫到watcher裏,何時出現就何時點擊。

因爲這個應用會請求root權限,因此有時第三方的root工具會彈相應的受權提示框,我想大部分的root工具應該都是有」容許」這個按鈕的,因而我就加了一個watcher

d.watcher('allowroot').when(text=u'容許').click(text=u'容許')

點擊贊成後會再彈一個開啓超強模式的彈框,這裏我要點擊的是取消

dump_3893862848196469772

d.watcher('cancel').when(text=u'取消').click(text=u'取消')

以後要點擊一下back鍵,這時又會彈一個是否退出的框,此次我要點擊「確認」

dump_8250787869838979823

這個確認我是後面單獨處理的,其實也能夠放在watcher裏,只是個人考慮是有時點擊back鍵的時候不必定會彈出來這個框,因此我會嘗試多點擊幾回,直到這個框出來

但如今就有一個問題了,剛纔寫了一個d.watcher('cancel').when(text=u'取消').click(text=u'取消'),這時當彈出這個框的時候,watcher就要起做用了,就會先去點擊取消,這不是我想要的,因此我將以前點擊取消的加了一個限制條件

d.watcher('cancel').when(text=u'取消').when(textContains=u'超強防禦可以極大提升').click(text=u'取消')

textContains的意思就是和包含裏面的文字,上面的意思就是當界面中text是「取消」的同時還要有一個view的text中要包含u'超強防禦可以極大提升',這樣的話就限制的點擊「取消」的條件,再遇到退出時的提示框就不會再會點擊」取消」了

儘量的想到可能出現的彈框,比較在小米手機中安裝應用會彈一個小米的安裝確認界面,使用下面的watcher來進行監測點擊

d.watcher('install').when(text=u'安裝').when(textContains=u'是否要安裝該應用程序').click(text=u'安裝',className='android.widget.Button')

總的watcher就是下面的樣子

d.watcher('allowroot').when(text=u'容許').click(text=u'容許')
        d.watcher('install').when(text=u'安裝').when(textContains=u'是否要安裝該應用程序').click(text=u'安裝',className='android.widget.Button') #專門爲小米彈出的安裝攔截
        d.watcher('cancel').when(text=u'取消').when(textContains=u'超強防禦可以極大提升').click(text=u'取消')
        d.watcher('confirm').when(text=u'確認').when(textContains=u'應用程序許可').click(text=u'確認')
        d.watcher('agree').when(text=u'贊成並使用').click(text=u'贊成並使用')
        d.watcher('weishiuninstall').when(textContains=u'暫不處理').click(textContains=u'暫不處理')

而後使用d.watchers.run()來啓動watcher

可是在實際的watcher中,我發現這個watcher並無想象的那樣好用,有時常常是明明有相應的view可是就是點擊不上,通過屢次嘗試,我發現,當界面已經出現的時候,這時我再強行的使用run()方法來啓動watchers,這時它就能很好的點擊了,因此基於此,我寫了一個循環來來無限的調用run方法,times限制了次數,根據項目的實際進行調整吧,sleep時間也能夠相應的調整

def runwatch(d,data):
    times = 120
    while True:
        if data == 1:                
            return True
        # d.watchers.reset()
        d.watchers.run()        
        times -= 1
        if times == 0:
            break
        else:
            time.sleep(0.5)

監視的時候又不能只跑監視程序,還要跑相應的測試步驟,因此這裏我把這個runwatch方法放到一個線程中去跑,起一個線程用做監視,腳本的測試方法放在另外的線程上跑

線程函數

#線程函數
class FuncThread(threading.Thread):
    def __init__(self, func, *params, **paramMap):
        threading.Thread.__init__(self)
        self.func = func
        self.params = params
        self.paramMap = paramMap
        self.rst = None
        self.finished = False

    def run(self):
        self.rst = self.func(*self.params, **self.paramMap)
        self.finished = True

    def getResult(self):
        return self.rst

    def isFinished(self):
        return self.finished

def doInThread(func, *params, **paramMap):
    t_setDaemon = None
    if 't_setDaemon' in paramMap:
        t_setDaemon = paramMap['t_setDaemon']
        del paramMap['t_setDaemon']
    ft = FuncThread(func, *params, **paramMap)
    if t_setDaemon != None:
        ft.setDaemon(t_setDaemon)
    ft.start()
    return ft

因此這裏啓動線程來跑runwatcher的調用就是

data = 0

doInThread(runwatch,d,data,t_setDaemon=True)

基本的思路就是這樣,這樣當腳本都寫完了之後在單個手機上運行很好,可是一旦插入多個手機就會出現一個問題,全部watcher只在一臺手機上有效,另外的手機就只能傻傻的不知道點擊,這個問題困擾了好久,我在github上也給做者發issue,可是後來我自已找到了解決的辦法,就是在d=Device(Serial)的時候加上local_port端口號,讓每臺手機使用不一樣的local_port端口號,這樣各自運行各自的,都很無缺

如下了測試腳本的代碼

mkiller.py,主測試腳本文件

#coding:gbk

import os,sys,time,re,csv
import log
import util
from uiautomator import Device
import traceback
import log,logging
import multiprocessing



optpath = os.getcwd()                      #獲取當前操做目錄
imgpath = os.path.join(optpath,'img')      #截圖目錄

def cleanEnv():
    os.system('adb kill-server')
    needClean = ['log.log','img','rst']
    pwd = os.getcwd()
    for i in needClean:
        delpath = os.path.join(pwd,i)
        if os.path.isfile(delpath):
            cmd = 'del /f/s/q "%s"'% delpath
            os.system(cmd)
        elif os.path.isdir(delpath):
            cmd = 'rd /s/q "%s"' %delpath
            os.system(cmd)
    if not os.path.isdir('rst'):
        os.mkdir('rst')
        
def runwatch(d,data):
    times = 120
    while True:
        if data == 1:                
            return True
        # d.watchers.reset()
        d.watchers.run()        
        times -= 1
        if times == 0:
            break
        else:
            time.sleep(0.5)
            
def installapk(apklist,d,device):
    sucapp = []
    errapp = []
    # d = Device(device)
    #初始化一個結果文件
    d.screen.on()
    rstlogger = log.Logger('rst/%s.log'%device,clevel = logging.DEBUG,Flevel = logging.INFO)
    #先安裝mkiller
    mkillerpath = os.path.join(os.getcwd(),'MKiller_1001.apk')
    cmd = 'adb -s %s install -r %s'% (device,mkillerpath)
    util.exccmd(cmd)
    def checkcancel(d,sucapp,errapp):
        times = 10
        while(times):
            if d(textContains = u'取消安裝').count:
                print d(textContains = u'取消安裝',className='android.widget.Button').info['text']
                d(textContains = u'取消安裝',className='android.widget.Button').click()
                rstlogger.info(device+'測試成功,有彈出取消安裝對話框')
                break
            else:
                time.sleep(1)
                times -= 1
                if times == 0:
                    rstlogger.error(device+'測試失敗,沒有彈出取消安裝對話框')
    try:        
        d.watcher('allowroot').when(text=u'容許').click(text=u'容許')
        d.watcher('install').when(text=u'安裝').when(textContains=u'是否要安裝該應用程序').click(text=u'安裝',className='android.widget.Button') #專門爲小米彈出的安裝攔截
        d.watcher('cancel').when(text=u'取消').when(textContains=u'超強防禦可以極大提升').click(text=u'取消')
        d.watcher('confirm').when(text=u'確認').when(textContains=u'應用程序許可').click(text=u'確認')
        d.watcher('agree').when(text=u'贊成並使用').click(text=u'贊成並使用')
        d.watcher('weishiuninstall').when(textContains=u'暫不處理').click(textContains=u'暫不處理')
        # d.watchers.run()
        data = 0
        util.doInThread(runwatch,d,data,t_setDaemon=True)
        #啓動急救箱並退出急救箱
        cmd = 'adb -s %s shell am start com.qihoo.mkiller/com.qihoo.mkiller.ui.index.AppEnterActivity'% device
        util.exccmd(cmd)
        time.sleep(5)
        times = 3
        while(times):
            d.press.back()
            if d(text=u'確認').count:
                d(text=u'確認').click()
                break
            else:
                time.sleep(1)
                times -=1
                
        for item in apklist:
            apkpath = item
            if not os.path.exists(apkpath):
                logger.error('%s的應用不存在,請檢查'%apkpath)
                continue 
            if not device:
                cmd = 'adb install -r "%s"' % apkpath
            else:
                cmd = 'adb -s %s install -r "%s"'%(device,apkpath)
            util.doInThread(checkcancel,d,sucapp,errapp)
            rst = util.exccmd(cmd)
    except Exception, e:
        logger.error(traceback.format_exc())
        data = 1
    data = 1
    return sucapp
            
def finddevices():
    rst = util.exccmd('adb devices')
    devices = re.findall(r'(.*?)\s+device',rst)
    if len(devices) > 1:
        deviceIds = devices[1:]
        logger.info('共找到%s個手機'%str(len(devices)-1))
        for i in deviceIds:            
            logger.info('ID爲%s'%i)
        return deviceIds
    else:
        logger.error('沒有找到手機,請檢查')
        return 
        
#needcount:須要安裝的apk數量,默認爲0,既安全部
#deviceids:手機的列表
#apklist:apk應用程序的列表
def doInstall(deviceids,apklist):
    count = len(deviceids)
    port_list = range(5555,5555+count)
    for i in range(len(deviceids)):
        d = Device(deviceids[i],port_list[i])
        util.doInThread(installapk,apklist,d,deviceids[i])
       
#結束應用
def uninstall(deviceid,packname,timeout=20):
    cmd = 'adb -s %s uninstall %s' %(deviceid,packname) 
    ft = util.doInThread(os.system,cmd,t_setDaemon=True)
    while True:
        if ft.isFinished():
            return True
        else:
            time.sleep(1)
            timeout -= 1
            if timeout == 0:
                return False
         

# 須要配置好adb 環境變量
# 1.先肯定有幾臺手機
# 2.再肯定有多少個應用
# 3.先安裝mkiller,啓動mkiller
# 4.再安裝測試的樣本
# 5.檢查是否有取消安裝的按鈕出現,出現說明測試經過,沒出現說明測試失敗


if __name__ == "__main__":
    cleanEnv()
    logger = util.logger
    devicelist = finddevices()
    if devicelist:       
        apkpath = os.path.join(os.getcwd(),'apk')
        apklist = util.listFile(apkpath)
        doInstall(devicelist,apklist) #每一個手機都要安裝apklist裏的apk

 

 

util.py 線程與執行cmd腳本函數文件

#coding:gbk
import os,sys
import log
import logging
import threading
import multiprocessing
import time

logger = log.Logger('log.log',clevel = logging.DEBUG,Flevel = logging.INFO)

def exccmd(cmd):
    try:
        return os.popen(cmd).read()
    except Exception:
        return None
    
#遍歷目錄內的文件列表
def listFile(path, isDeep=True):
    _list = []
    if isDeep:
        try:
            for root, dirs, files in os.walk(path):
                for fl in files:
                    _list.append('%s\%s' % (root, fl))
        except:
            pass
    else:
        for fn in glob.glob( path + os.sep + '*' ):
            if not os.path.isdir(fn):
                _list.append('%s' % path + os.sep + fn[fn.rfind('\\')+1:])
    return _list
    
#線程函數
class FuncThread(threading.Thread):
    def __init__(self, func, *params, **paramMap):
        threading.Thread.__init__(self)
        self.func = func
        self.params = params
        self.paramMap = paramMap
        self.rst = None
        self.finished = False

    def run(self):
        self.rst = self.func(*self.params, **self.paramMap)
        self.finished = True

    def getResult(self):
        return self.rst

    def isFinished(self):
        return self.finished

def doInThread(func, *params, **paramMap):
    t_setDaemon = None
    if 't_setDaemon' in paramMap:
        t_setDaemon = paramMap['t_setDaemon']
        del paramMap['t_setDaemon']
    ft = FuncThread(func, *params, **paramMap)
    if t_setDaemon != None:
        ft.setDaemon(t_setDaemon)
    ft.start()
    return ft

log.py log相應的函數文件

#coding=gbk
import logging,os
import ctypes

FOREGROUND_WHITE = 0x0007
FOREGROUND_BLUE = 0x01 # text color contains blue.
FOREGROUND_GREEN= 0x02 # text color contains green.
FOREGROUND_RED  = 0x04 # text color contains red.
FOREGROUND_YELLOW = FOREGROUND_RED | FOREGROUND_GREEN

STD_OUTPUT_HANDLE= -11
std_out_handle = ctypes.windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
def set_color(color, handle=std_out_handle):
    bool = ctypes.windll.kernel32.SetConsoleTextAttribute(handle, color)
    return bool


class Logger:
    def __init__(self, path,clevel = logging.DEBUG,Flevel = logging.DEBUG):
        self.logger = logging.getLogger(path)
        self.logger.setLevel(logging.DEBUG)
        fmt = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s', '%Y-%m-%d %H:%M:%S')
        #設置CMD日誌
        sh = logging.StreamHandler()
        sh.setFormatter(fmt)
        sh.setLevel(clevel)
        #設置文件日誌
        fh = logging.FileHandler(path)
        fh.setFormatter(fmt)
        fh.setLevel(Flevel)
        self.logger.addHandler(sh)
        self.logger.addHandler(fh)

    def debug(self,message):
        self.logger.debug(message)

    def info(self,message):
        self.logger.info(message)

    def war(self,message,color=FOREGROUND_YELLOW):
        set_color(color)
        self.logger.warn(message)
        set_color(FOREGROUND_WHITE)

    def error(self,message,color=FOREGROUND_RED):
        set_color(color)
        self.logger.error(message)
        set_color(FOREGROUND_WHITE)

    def cri(self,message):
        self.logger.critical(message)



if __name__ =='__main__':
    logyyx = Logger('yyx.log',logging.WARNING,logging.DEBUG)
    logyyx.debug('一個debug信息')
    logyyx.info('一個info信息')
    logyyx.war('一個warning信息')
    logyyx.error('一個error信息')
    logyyx.cri('一個致命critical信息')

這個小測試應用雖然比較簡單,可是因爲剛剛接觸uiautomator的python封裝,因此仍是遇到了一些麻煩,不過還好,最終的結果是很好的解決了相應的問題,這裏也算是拋磚引玉吧,這個uiautomator還有不少好玩的值得探索的地方,待之後慢慢發現~

相關文章
相關標籤/搜索