最近項目中有個需求要在至少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',第一次啓動會彈出使用協議要用戶來點擊」贊成並使用」
我這裏採用了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'容許')
點擊贊成後會再彈一個開啓超強模式的彈框,這裏我要點擊的是取消
d.watcher('cancel').when(text=u'取消').click(text=u'取消')
以後要點擊一下back鍵,這時又會彈一個是否退出的框,此次我要點擊「確認」
這個確認我是後面單獨處理的,其實也能夠放在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還有不少好玩的值得探索的地方,待之後慢慢發現~