原理
uiautomator2是用python编写的Android手机测试框架。Google在Android中提供了uiautomator框架用于手机的自动化测试,所以以前做Android的自动化测试,都是通过自己写一个测试APK。在测试APK的单元测试中使用uiautomator框架来写一些自动化测试用例,在调试测试APK的时候执行自动化测试用例,这样就可以做到Android手机的自动化测试。
但是这种测试方法有个缺点,就是每一次单独的自动化测试都需要单独写一个测试APK。而且调试自动化测试用例的时候也不太方便,因为单独执行某一条自动化测试用例也需要先执行测试APK的安装,再执行调试。所以我们可不可以先写一个测试APK,在这个测试APK中实现了所有uiautomator控制手机的方法,然后再给这个测试APK写一个socket服务。让这个测试APK在调试的时候先开启socket服务器,一直监听某个端口是否有操作手机的信息发送过来。这时我们就可以在其他软件上通过socket连接上测试APK的socket服务器,然后给测试APK发送操作手机的信息。例如我们发送一个滑动的信息,测试APK就执行相应的滑动方法,这样就实现了在测试APK外部使用测试脚本来控制手机的目的。我们就再也不用反复的去写测试APK了,也不用通过修改测试APK来修改自动化测试用例了。
python中的uiautomator2测试框架就类似这样的一种测试工具,通过安装在手机上的uiautomator测试APK在调试的时候开启http rpc服务,一直监听7912端口。我们在电脑端通过python脚本给手机的7912端口发送操作手机的信息,信息中包含操作手机使用的方法,以及执行方法时要传入的参数。看起来操作方法还挺复杂的,但是这些复杂的操作方式都已经被封装到了uiautomator2框架中,我们在写python脚本的时候只需要调用uiautomator2中的方法,就能轻松实现手机的控制了。下面我将为大家介绍uiautomator2测试框架。
uiautomator2测试框架
源项目地址:https://github.com/openatx/uiautomator2
安装uiautomator2库
安装指令:pip install --pre uiautomator2
等待uiautomator2的库文件下载完成,如果你缺少uiautomator2的依赖库,它还会自动下载依赖库。下载完成后,我们就可以在py文件中导入uiautomator2了。因为uiautomator2的名字太长,在导入后我们会给它取别名为u2。(import uiautomator2 as u2)
测试APK的安装
测试APK通常是自动安装,我们也可以手动安装。前提是手机通过USB线与电脑连接,并且手机打开了USB调试。用于测试的文件有6个,会自动推送到手机的/data/local/tmp目录下,其中两个APK会安装到手机上。
测试APK是下面两个:
app-uiautomator.apk 包名:com.github.uiautomator
下载地址:https://tool.appetizer.io/openatx/android-uiautomator-server/releases/download/2.3.3/app-uiautomator.apk
app-uiautomator-test.apk 包名:com.github.uiautomator.test
下载地址:https://tool.appetizer.io/openatx/android-uiautomator-server/releases/download/2.3.3/app-uiautomator-test.apk
测试APK守护进程文件:
atx-agent 防止测试APK被系统回收
下载地址:https://tool.appetizer.io/openatx/atx-agent/releases/download/0.10.0/atx-agent_0.10.0_linux_arm64.tar.gz
测试辅助文件是下面3个:
minicap 实时投屏或截屏
下载地址:https://tool.appetizer.io/openatx/stf-binaries/raw/0.3.0/node_modules/@devicefarmer/minicap-prebuilt/prebuilt/arm64-v8a/bin/minicap
minicap.so
下载地址:https://tool.appetizer.io/openatx/stf-binaries/raw/0.3.0/node_modules/@devicefarmer/minicap-prebuilt/prebuilt/arm64-v8a/lib/android-25/minicap.so
minitouch 精确定位UI界面元素信息
下载地址:https://tool.appetizer.io/openatx/stf-binaries/raw/0.3.0/node_modules/@devicefarmer/minitouch-prebuilt/prebuilt/arm64-v8a/bin/minitouch
自动安装
如果我们的电脑是连接了网络的,我们在执行uiautomator2中的方法时,它会查看7912端口的监听服务器是否存在,如果不存在,则检查服务是否启动或手机上是否有测试APK,如果没有安装APK就从在你电脑上的某个文件夹(一般在当前用户目录下有个隐藏的.uiautomator2文件夹)中去找,如果电脑上有测试APK就通过adb install给手机安装上;如果没有则通过网络到git上去下载,下载后再安装到手机上。
手动安装
安装指令:python -m uiautomator2 init
上面指令的意思就是初始化uiautomator2,效果就跟我们去执行uiautomator2中的方法时一样。
如果你的测试电脑没有连接网络,则提前去git上下载上面说的6个测试文件。把两个测试APK用adb install安装到手机上,4个测试文件用adb push推送到/data/local/tmp目录下。
连接手机
uiautomator2安装好了,就可以在py文件中调用uiautomator2中操作手机的方法。
第一步连接要操作的手机,有两种连接方式。一种是通过USB线来连接,另一种是通过WiFi来连接。
通过USB连接时,只需要用USB线连接电脑和手机,手机打开了USB调试,在电脑终端(Linux:bash,Windows:cmd)使用adb devices指令查看手机序列号,把手机序列号放到uiautomator2的connect_usb()函数中。具体代码如下:
import uiautomator2 as u2 # 导入uiautomator2库取别名为u2
device = u2.connect_usb("手机序列号") # 定义要连接的手机,初始化uiautomator2
通过wifi连接时,需要电脑和手机处于同一个WiFi网络中(局域网也行)。手机打开了USB调试,在手机设置中查看手机的IP地址,把手机的IP地址放到uiautomator2的connect_wifi()函数中。具体代码如下:
import uiautomator2 as u2 # 导入uiautomator2库取别名为u2
device = u2.connect_wifi("手机IP地址") # 定义要连接的手机,初始化uiautomator2
通过上面的两种方式,都可以连接到手机,并把uiautomator2的实例赋值给了变量device,下面我就接着使用公共变量device给大家展示操作手机的方法。
操作手机
操作手机的方法有3类,设备类操作方法、UI元素类操作方法、基础类操作方法(暂未公开)。
设备类方法
device_info
device_info()方法可以返回设备的信息,例如当前屏幕状态(竖屏或横屏,0 or 1)、当前页面显示的app包名、屏幕大小(高、宽)、系统SDK版本等信息。使用方式如下:
info = device.device_info
print(info)
window_size()
window_size()方法可以返回设备的屏幕大小(宽、高)。使用方式如下:
width, height = device.window_size()
print(height, width)
screenshot()
screenshot()方法可以截屏,返回当前屏幕图像,支持jpg和png格式。使用方式如下:
images = device.screenshot() # 截取当前屏幕图像
images.save('screen.jpg') # 保存为screen.jpg在当前目录下
implicitly_wait()
implicitly_wait()方法可以设置元素查找等待时间,默认是20秒,修改它会影响所有跟元素相关的方法。使用方式如下:
device.implicitly_wait(10) # 设置元素查找等待时间为10秒,10秒后任未找到该元素则报错
touch
touch方法可以实现多点触控,触摸移动事件(手指触摸屏幕,到处移动,离开屏幕)。使用方式如下:
touch = device.touch # 定义一个触摸事件
touch.down(100, 100) # 在坐标100,100的位置触摸屏幕
touch.move(500, 500) # 触摸着移动到500,500的位置
touch.sleep(1) # 触摸着不动1秒钟
touch.move(100, 100) # 触摸着移动到100,100的位置
touch.up(500, 500) # 触摸着移动到500,500的位置并离开屏幕
我们可以定义多个触摸事件,实现屏幕的多点触控。
click()
click()方法可以点击屏幕上任意一点一次。使用方式如下:
device.click(100, 100) # 点击屏幕上坐标100,100的位置
double_click()
double_click()方法可以点击屏幕上任意一点两次,默认点击间隔为0.1秒,使用duration关键字参数可以修改点击间隔。使用方式如下:
device.double_click(100, 100, duration=0.5) # 点击坐标位置100,100两次,间隔0.5秒
long_click()
long_click()方法可以长按屏幕上任意一点,默认长按事件为0.5秒,使用duration关键字参数可以修改长按时间。使用方式如下:
device.long_click(100, 100, duration=1) # 长按坐标位置100,100,按住1秒钟
swipe()
swipe()方法可以在屏幕上滑动,从一个点滑动到另一个点,默认滑动步数为55。使用duration关键字参数可以修改滑动时间,step = duration X 200。使用关键字参数step可以修改滑动步数,但是duration和step同时设置时,只有step会生效。使用方法如下:
device.swipe(100, 100, 500, 500) # 从坐标位置100,100滑动到坐标位置500,500
swipe_points()
swipe_points()方法可以在屏幕上连续滑动,从点1滑动到点2...滑动到点n,默认滑动间隔为0.5秒,使用duration关键字参数可以修改滑动间隔。使用方式如下:
device.swipe_points([(100, 100), (500, 100), (100, 500), (500, 500)])
# 从坐标位置100,100滑动到500,100滑动到100,500滑动到500,500滑出一个Z字形轨迹
swipe_points()用在解除图案锁上非常方便。但我们在使用这种方式去解锁的时候不要傻傻的去使用真实的坐标,我们应该使用解锁图案元素的百分比来计算真实坐标,这样做不管在任何分辨率的手机上都能实现解锁。我们甚至可以封装一个函数,只要传入一个元素的ID属性信息,就能在这个元素上画出图形。
drag()
drag()方法跟swipe()函数具有相同的功能,也是从一个点滑动到另一个点,默认滑动时间为0.5秒,使用duration关键字参数可以修改滑动时间。使用方式如下:
device.drag(100, 100, 500, 500) # 从坐标位置100,100滑动到坐标位置500,500
press()
press()方法可以执行按键事件,可传入的键有home、back、left、right、up、down、center、menu、search、enter、delete(or del)、recent(recent apps)、volume_up、volume_down、volume_mute、camera、power。使用方式如下:
device.press("home") # 按home键
screen_on()
screen_on()方法可以设置屏幕为亮屏(唤醒屏幕)。使用方式如下:
device.screen_on()
screen_off()
screen_off()方法可以设置屏幕为灭屏(休眠屏幕)。使用方式如下:
device.screen_off()
orientation
orientation方法可以返回屏幕的旋转状态:left、right、natural、upsidedown,分别表示向右旋转、向左旋转、正常、上下颠倒。使用方式如下:
state = device.orientation
print(state)
set_orientation()
set_orientation()方法可以设置屏幕的旋转方向,正常状态可传入的值有(0, "natural", "n", 0);向左旋转可传入的值有 (1, "left", "l", 90);上下颠倒可传入的值有(2, "upsidedown", "u", 180);向右旋转可传入的值有(3, "right", "r", 270)。使用方式如下:
device.set_orientation('left') # 设置屏幕为向左旋转
freeze_rotation()
freeze_rotation()方法可以控制屏幕是否能旋转,传入True时冻结屏幕旋转,屏幕将无法旋转;传入False时解除冻结,屏幕可以旋转。使用方式如下:
device.freeze_rotation(True) # 冻结屏幕旋转,屏幕不能再进行旋转
open_notification()
open_notification()方法可以打开下拉通知栏。使用方式如下:
device.open_notification() # 打开下拉通知栏
open_quick_settings()
open_quick_settings()方法可以打开下拉菜单。使用方式如下:
device.open_quick_settings() # 打开下拉菜单
open_url()
open_url()方法可以打开默认浏览器访问指定的网页,使用的就是adb shell am start -a android.intent.action.VIEW -d指令。我们传入一个url地址,手机就会去访问相应的网页。使用方式如下:
device.open_url('http://www.baidu.com') # 打开默认浏览器访问百度网页
exists()
exists()方法可以查看界面上是否存在某个元素,如果存在返回True,不存在返回False。使用方式如下:
if device.exists(text='hello'): # 判断界面中是否存在文本hello
print('yes')
else:
print('no')
clipboard
clipboard方法可以得到粘贴板中的内容和设置粘贴板中的内容,用于复制粘贴操作。使用方法如下:
device.clipboard = "hello" # 设置内容
text = device.clipboard # 获取内容
print(text) # hello
set_clipboard()
set_clipboard()方法可以设置粘贴板中的内容。使用方式如下:
device.set_clipboard('hello') # 设置粘贴板中的内容为hello
keyevent()
keyevent()方法可以发送按键事件,使用的就是adb shell input keyevent指令。使用方式如下:
device.keyevent('3') # 发送按home键事件
serial
serial方法可以获取当前设备的手机序列号。使用方式如下:
device_number = device.serial
print(device_number)
show_float_window()
show_float_window()方法可以设置测试APK的悬浮窗,悬浮窗会增加测试APK的稳定性。当我们传入True时,开启悬浮窗;传入False时关闭悬浮窗。使用的就是adb shell am start -n com.github.uiautomator/.ToastActivity -e showFloatWindow指令。使用方式如下:
device.show_float_window(True) # 开启悬浮窗
toast
Toast是Android中的一种消息弹窗,在应用发生崩溃时,我们看到的应用未响应的弹窗就是Toast弹窗。我们先来定义一个toast,后面的介绍中都会使用这个公共变量toast。定义方式如下:
toast = device.toast # 定义一个toast
toast中有get_message()方法可以获得最近一段时间范围内的toast弹窗中的信息,使用关键字参数wait_timeout设置等待toast出现的时间,默认10秒;使用关键字参数cache_timeout设置上一次toast出现的时间范围,默认10秒;使用default关键字参数设置没有toast出现时返回的默认消息。使用方式如下:
toast.get_message(wait_timeout=5, cache_timeout=5, default='没有toast出现')
# 看前5秒时间范围内是否出现过toast,没有出现就再等5秒钟看会不会出现toast,5秒钟后还是没有toast出现就返回'没有toast出现'
toast中有reset()方法可以重置当前的toast(清除缓存),如果toast中保存了上一次toast弹窗中的信息,我们想清除掉信息就使用reset()方法。使用方式如下:
toast.reset() # 重置toast
toast中有show()方法可以在手机界面展示一个toast弹窗,我们传入什么消息就展示什么消息,可以使用关键字参数duration设置展示的时间,默认1秒。使用方式如下:
toast.show('hello world') # 在手机界面弹出hello world消息弹窗1秒钟
open_identify()
open_identify()方法可以打开识别,只能传入black和red。使用的就是adb shell am start -W -n com.github.uiautomator/.IdentifyActivity -e theme指令。使用方式如下:
device.open_identify('red')
set_fastinput_ime()
set_fastinput_ime()方法可以切换输入法为FastInputIME,传入True时打开FastInputIME输入法,传入False时关闭FastInputIME输入法。使用方式如下:
device.set_fastinput_ime(True)
send_keys()
send_keys()方法可以在输入框中输入字符串,通过广播的方式输入,使用的是adb shell am broadcast -a (ADB_SET_TEXT or ADB_INPUT_TEXT) --es text指令。传入什么字符串就输入什么字符串,还可以通过关键字参数clear决定是否清除前面的字符串(如果输入框中本来就有字符串的话),默认为False。clear=True时为清除前面的字符串,clear=False时不清除前面的字符串。使用方式如下:
device.send_keys('hello')
send_action()
send_action()方法可以发送一些输入法中的事件,比如在百度中输入要搜索的字符后,输入法中会有搜索按钮,发送短信的时候输入法中会有发送按钮等等。可以接受的值有go、search、send、next、done、previous。使用方式如下:
device.send_action('send') # 在微信聊天输入框中输入内容后点击输入法中的发送按钮
clear_text()
clear_text()方法可以清空输入框中的文本,使用的是adb shell am broadcast -a ADB_CLEAR_TEXT指令。使用方式如下:
device.clear_text()
wait_fastinput_ime()
wait_fastinput_ime()方法是用来等待FastInputIME输入法出现的,我们点击一个输入框以后一般间隔1秒后才会弹出输入法。默认的等待时间是5秒,可以自己设置。使用方式如下:
device.wait_fastinput_ime(10) # 等待FastInputIME输入法在10秒内弹出,10秒后未弹出会报错
current_ime()
current_ime()方法可以得到当前输入法的状态。使用方式如下:
state = device.current_ime()
print(state) # ("com.github.uiautomator/.FastInputIME", True)
app_current()
app_current()方法可以返回当前界面APP的包名和活动(package and activity)。使用方式如下:
now_app = device.app_current()
print(now_app)
app_install()
app_install()方法可以通过url安装APP。使用方式如下:
device.app_install('下载APP的网址')
wait_activity()
wait_activity()方法是等待APP启动的,当前的活动为APP的主活动时结束等待。需要传入主活动的名称,使用timeout参数可以修改等待时间,默认为10秒。使用方式如下:
# 等待当前活动为MainActivity,等待20秒,20秒后未出现MainActivity则返回False
device.wait_activity('MainActivity', 20)
app_start()
app_start()方法可以启动APP,必须传入APP的包名,可选择传入主活动名;当不传入主活动名时,会使用atx-agent解析出APP的主活动名。可使用wait参数控制是否等待APP启动完成,可使用stop参数控制是否在启动之前先关闭此APP,可使用use_monkey参数控制是否使用monkey命令启动APP。使用方式如下:
# 启动com.example.battery并在20秒内等待启动完成,启动之前如果com.example.battery存活先关闭它
device.app_start('com.example.battery', 'com.example.battery/com.example.battery.launcher', wait=True, stop=True)
app_wait()
app_wait()方法是等待APP启动的,当APP被启动后结束等待。需要传入APP包名,使用timeout参数可以修改等待时间,默认为20秒,使用front参数可以控制是否等待APP完全启动(主活动处于界面最上层时)。使用方式如下:
# 完全启动com.example.battery
device.app_wait('com.example.battery', front=True)
app_list()
app_list()方法可以获得APP包名列表,使用的就是adb shell pm list packages指令。可选择使用filter参数控制得到那种类型的APP包名,可接受的值有-f、-d、-e、-s、-3、-i、-u、--user USER_ID、FILTER。使用方式如下:
app_list = device.app_list('-3') # 得到第三方安装的APP
print(app_list)
app_list_running()
app_list_running()方法可以得到正在运行的APP列表,就是使用pm list packages和ps -A,把它们得到的结果使用正则表达式处理,再求交集。使用方式如下:
app_list = device.app_list_running() # 得到正在运行的APP列表
print(app_list)
app_stop()
app_stop()方法可以关闭APP,需要传入APP的包名,就是使用adb shell am force-stop指令。使用方式如下:
device.app_stop('com.example.battery') # 关闭com.example.battery
app_stop_all()
app_stop_all()方法可以关闭除了测试APP以外的所有正在运行的APP,可以使用excludes参数设置不想关闭的APP。使用方式如下:
# 关闭除了com.example.battery和com.example.reboot以外的APP
device.app_stop_all(['com.example.battery', 'com.example.reboot'])
app_clear()
app_clear()方法可以清空APP数据,需要传入APP的包名,就是使用adb shell pm clear指令。使用方式如下:
device.app_clear('com.example.battery')
app_uninstall()
app_uninstall()方法可以卸载APP,需要传入包名,就是使用adb shell pm uninstall指令。使用方式如下:
device.app_uninstall('com.example.battery')
app_uninstall_all()
app_uninstall_all()方法可以卸载除了测试APP以外的所有第三方APP,可以使用excludes参数设置不想卸载的APP,verbose参数可以控制是否打印卸载信息,默认不打印。使用方式如下:
# 卸载除了com.example.battery以外的所有第三方APP,并打印每个APP卸载信息
device.app_uninstall_all(excludes=['com.example.battery'], verbose=True)
app_info()
app_info()方法可以得到APP的信息,例如包名、标签、版本、大小等信息,需要传入包名。使用方式如下:
info = device.app_info('com.example.battery')
print(info)
app_icon()
app_icon()方法可以得到APP的图标。使用方法如下:
icon = device.app_icon('com.example.battery')
with open(r'battery.icon', "wb") as fi:
fi.write(icon)
settings
settings方法返回一个类似字典的类,可以获取和修改设置属性的值,settings中的get方法可以获取某个设置属性的值,使用给字典修改值的方式修改设置属性的值。使用方式如下:
setting = device.settings
print(setting)
# 打印出所有的设置属性
# {"wait_timeout": 20.0, # 查找元素等待时间
# "xpath_debug": False, # xpath debug开启状态
# "operation_delay": (0, 0), # 点击前后延时
# "operation_delay_methods": ["click", "swipe"], # 需要点击延时的函数
# "fallback_to_blank_screenshot": False} # 返回空白截图
# 获取属性wait_timeout的值
timeout = setting.get("wait_timeout")
print(timeout) # 20.0
# 修改属性xpath_debug的值
setting["xpath_debug"] = True
# 修改属性operation_delay_methods的值
setting["operation_delay_methods"] = ["click", "swipe", "long_lick", "press", "drag"]
watch_context()(不推荐)
watch_context()方法返回一个文本监听器,当界面中出现要监听的文本时可以选择点击,每2秒检查一次。autostart参数控制是否自启监听器,默认True;builtin参数控制是否使用内置监听,默认False。使用方式如下:
watcher = device.watch_context()
# 注册监听文本及点击事件
watcher.when("停止响应").when("OK").click() # 当"停止响应"和"OK"同时出现时点击"OK"
watcher.when("取消").click() # 当"取消"出现时点击"取消"
# 其他脚本逻辑语句
# 需要结束监听时
watcher.stop()
# 或者使用上下文管理的方式
with device.watch_context() as watcher:
# 注册监听文本及点击事件
watcher.when("停止响应").when("OK").click() # 当"停止响应"和"OK"同时出现时点击"OK"
watcher.when("取消").click() # 当"取消"出现时点击"取消"
# 其他脚本逻辑语句
watch_context是依赖threading库来执行多线程运行的,所以我们完全不需要使用watch_context。我们自己写一个监听方法,再用threading开一个子线程,可以比watch_context实现更多的功能。
watcher(不推荐)
watcher方法可以返回一个监听器,用法跟watch_context差不多,只是比watch_context多了remove功能,可以动态的选择性移除一些监听,多了running功能可以获取监听器当前运行状态,多了reset功能可以重置监听器(停止监听器并移除所有监听),多了函数回调功能可以指定元素出现时要触发的事件。使用方式如下:
# 匿名监听器
device.watcher.when("停止响应").when("OK").click() # 当"停止响应"和"OK"同时出现时点击"OK"
device.watcher.when("取消").click() # 当"取消"出现时点击"取消"
# 命名监听器
device.watcher('crash').when("停止响应").when("OK").click() # 当"停止响应"和"OK"同时出现时点击"OK"
device.watcher('pop up').when("取消").click() # 当"取消"出现时点击"取消"
# 使用回调函数,指定元素出现时要执行的函数
device.wacther('video').when("GO IT").call(lambda devices: devices.set_orientation('l')) # 使用匿名函数指定一个事件
# 开启监听器子线程
device.watcher.start(3) # 3秒监听一次,默认2秒
# 移除crash监听
device.watcher.remove('crash')
# 强制运行没有被移除的监听
device.watcher.run()
# 需要结束监听时
device.watcher.stop()
# 重置监听器,停止所有监听,移除所有监听
device.watcher.reset()
watcher和watch_context一样是通过threading实现子线程的,只不过watcher多了些选择,但最好的选择还是自己实现的方法,可以完全满足自己的需求。
UI类方法
使用UI类方法就是操作UI元素的方法,首先需要得到UI元素。
UI元素获取
UI元素的获取方式,可以使用AndroidSDK中的uiautomatorviewer元素获取工具,也可以使用WEditor(推荐)。
安装WEditor
WEditor是网页版UI元素获取工具,集成了uiautomator2的功能,WEditor在UI元素获取上表现出了比AndroidSDK中自带的元素获取工具uiautomatorviewer更强的能力。不管是静态还是动态元素,WEditor都能很好的获取。
WEditor安装指令:pip install --pre --upgrade weditor==0.6.4
网络不好翻墙困难可以加-i参数,使用清华源快速下载。
-i https://pypi.tuna.tsinghua.edu.cn/simple
因为目前0.6.5版本存在编码问题,所以我们指定版本0.6.4。WEditor安装完成后,我们可以在cmd窗口执行python -m weditor启动WEditor,也可以使用命令创建桌面快捷方式,创建快捷方式后直接双击快捷方式就能启动WEditor。
创建快捷方式指令:python -m weditor -shortcut
WEditor界面
启动WEditor后,我们可以看到如下界面:
我们需要把手机的序列号输入到Android后面的输入框中,然后点击Connect连接设备,再点击Dump Hierarchy按钮加载手机界面元素。上图就是加载出来后的样子,在界面的左边是手机的屏幕界面,我们可以用鼠标在界面中选择元素。元素的属性和值显示在中间部分,每个元素拥有的属性和对应的值都会显示出来。界面的右边显示了整个界面元素的路径分支,我们也可以在路径分支中选择界面元素。
元素类型
uiautomator2源码中设定的元素定位关键字参数如下:
__fields = {
"text": (0x01, None), # MASK_TEXT,
"textContains": (0x02, None), # MASK_TEXTCONTAINS,
"textMatches": (0x04, None), # MASK_TEXTMATCHES,
"textStartsWith": (0x08, None), # MASK_TEXTSTARTSWITH,
"className": (0x10, None), # MASK_CLASSNAME
"classNameMatches": (0x20, None), # MASK_CLASSNAMEMATCHES
"description": (0x40, None), # MASK_DESCRIPTION
"descriptionContains": (0x80, None), # MASK_DESCRIPTIONCONTAINS
"descriptionMatches": (0x0100, None), # MASK_DESCRIPTIONMATCHES
"descriptionStartsWith": (0x0200, None), # MASK_DESCRIPTIONSTARTSWITH
"checkable": (0x0400, False), # MASK_CHECKABLE
"checked": (0x0800, False), # MASK_CHECKED
"clickable": (0x1000, False), # MASK_CLICKABLE
"longClickable": (0x2000, False), # MASK_LONGCLICKABLE,
"scrollable": (0x4000, False), # MASK_SCROLLABLE,
"enabled": (0x8000, False), # MASK_ENABLED,
"focusable": (0x010000, False), # MASK_FOCUSABLE,
"focused": (0x020000, False), # MASK_FOCUSED,
"selected": (0x040000, False), # MASK_SELECTED,
"packageName": (0x080000, None), # MASK_PACKAGENAME,
"packageNameMatches": (0x100000, None), # MASK_PACKAGENAMEMATCHES,
"resourceId": (0x200000, None), # MASK_RESOURCEID,
"resourceIdMatches": (0x400000, None), # MASK_RESOURCEIDMATCHES,
"index": (0x800000, 0), # MASK_INDEX,
"instance": (0x01000000, 0) # MASK_INSTANCE,
}
我们需要使用这些关键字参数去定位元素,可以一次使用多个关键字参数去定位一个元素。
定位元素
我们可以通过这些关键字参数去定位一个元素,具体使用方式如下:
text
# 找到界面中text属性为hello的元素,如果不存在这个元素会报错
device(text='hello')
# 找到界面中text属性包含hello的元素,如果不存在这个元素会报错
device(textContains='hello')
# 使用正则表达式匹配text属性,如果不存在这个元素会报错
device(textMatches='h.+o')
# 匹配以hello开头的text属性,如果不存在这个元素会报错
device(textStartsWith='hello')
class
# 找到class属性为android.widget.Button的元素,如果元素不存在会报错
device(className='android.widget.Button')
# 使用正则表达式匹配class属性,如果元素不存在会报错
device(classNameMatches='android.+Button')
description
# 找到description属性为hello的元素,如果元素不存在时会报错
device(description='hello')
# 找到description属性包含hello的元素,如果元素不存在时会报错
device(descriptionContains='hello')
# 使用正则表达式匹配description属性,如果元素不存在时会报错
device(descriptionMatches='h.+o')
# 找到description属性以hello开头的元素,如果元素不存在时会报错
device(descriptionStartsWith='hello')
checkable
# 找到有check属性的元素,找不到会报错
device(checkable=True)
# 找到没有check属性的元素,找不到会报错
device(checkable=False)
checked
# 找到有check属性且已被勾选的元素,找不到会报错
device(checked=True)
# 找到有check属性且未被勾选的元素,找不到会报错
device(checked=False)
clickable
# 找到有click属性的元素,找不到会报错
device(clickable=True)
# 找到没有click属性的元素,找不到会报错
device(clickable=False)
longClickable
# 找到有longClick属性的元素,找不到会报错
device(longClickable=True)
# 找到没有longClick属性的元素,找不到会报错
device(longClickable=False)
scrollable
# 找到有scroll属性的元素,找不到会报错
device(scrollable=True)
# 找到没有scroll属性的元素,找不到会报错
device(scrollable=False)
enabled
# 找到可操作的元素,找不到会报错
device(enabled=True)
# 找到不可操作的元素,找不到会报错
device(enabled=False)
focusable
# 找到有focus属性的元素,找不到会报错
device(focusable=True)
# 找到没有focus属性的元素,找不到会报错
device(focusable=False)
focused
# 找到有focus属性且已被聚焦的元素,找不到会报错
device(focused=True)
# 找到有focus属性且未被聚焦的元素,找不到会报错
device(focused=False)
selected
# 找到有select属性且已被选择的元素,找不到会报错
device(selected=True)
# 找到有select属性且未被选择的元素,找不到会报错
device(selected=False)
package
# 找到package属性为com.android.systemui的元素,找不到会报错
device(packageName='com.android.systemui')
# 使用正则表达式匹配package属性,找不到会报错
device(packageNameMatches='com.+systemui')
resourceId
# 找到id属性为com.android.systemui:id/frame的元素,找不到会报错
device(resourceId='com.android.systemui:id/frame')
# 使用正则表达式匹配id属性,找不到会报错
device(resourceIdMatches='com.+frame')
index
# 找到index属性为1的元素,找不到会报错
device(index=1)
instance
# 找到UI界面中text属性为hello的第3个元素
device(text='hello', instance=2)
instance和index用法差不多,它们的区别是:index是兄弟元素之间的排布顺序,instance是整个界面中某个属性相同的所有元素的排布顺序。但我们一般不使用instance,因为我们有更简单的方式,如下:
# 找到UI界面中text属性为hello的第3个元素
device(text='hello')[2]
多种属性组合定位
当一个元素的属性有唯一性时,我们可以单独使用一个属性去定位元素,否则我们应该使用多种属性的组合去定位一个元素。例如:
device(text='hello', description='hello', enabled=True)
device(packageName='com.android.systemui', index=1)
正是以为uiautomator2拥有多种属性组合定位的定位方式,所以我在定位元素时从来不使用x-path路径定位。通过上面这些属性的各种组合,已经足以定位到我们想要的元素了。如果还是不能准确定位,我们下面还有通过父子关系、兄弟关系定位的方法(child and sibling),上下左右方向定位的方法(up、down、left and right)。
clone()
clone()方法可以克隆当前定位的元素。使用方式如下:
button = device(text='hello') # 定位到了一个名为hello的按钮赋值给button
button_s = button.clone() # 克隆button
child()
child()方法可以定位到当前元素的子级元素(如果当前元素存在子级元素的话),child()方法中也是使用元素的属性定位。使用方式如下:
device(text='hello').child(resourceId="com.android.launcher3:id/icon")
sibling()
sibling()方法可以定位到当前元素的兄弟元素(如果当前元素存在兄弟元素的话),sibling()方法也是使用元素的属性定位。使用方式如下:
device(text='hello').sibling(resourceId="com.android.launcher3:id/icon")
update_instance()
update_instance()方法可以修改当前元素instance的值,如果当前元素存在子元素或兄弟元素时,修改的是子元素或兄弟元素instance的值。使用方式如下:
device(text='hello').update_instance(1) # 把当前元素instance的值改为1
wait_timeout
wait_timeout方法可以得到元素查找等待时间。使用方式如下:
timeout = device().wait_timeout
print(timeout)
exists()
exists()方法可以判断当前元素是否存在,存在返回True,不存在返回False。可以选择传入参数timeout来设置判断时间。使用方式如下:
device(text='hello').exists(timeout=5) # 判断5秒内界面中是否会出现text属性为hello的元素
info
info方法可以得到当前元素的信息,例如元素各种属性的值、坐标位置等。使用方式如下:
info = device(text='hello').info
print(info)
screenshot()
screenshot()方法可以截取当前元素的图像,使用的是区域截图。使用方法如下:
images = device(text='hello').screenshot()
images.save('hello.jpg')
click()
click()方法可以点击当前元素,默认是点击元素的中间位置,可以使用offset参数修改点击位置,可以使用timeout参数设置等待元素出现的时间。使用方法如下:
# 点击text属性为hello的元素宽的中间高的十分之一位置,如果1秒内该元素未出现抛出超时错误
device(text='hello').click(timeout=1, offset=(0.5, 0.1))
bounds()
bounds()方法可以返回当前元素左上角和右下角的坐标,通过bounds方法我们就可以知道元素在屏幕中的位置,以及元素的高度和宽度。使用方法如下:
lx, ly, rx, ry = device(text='hello').bounds()
print(f'左上角坐标({lx}, {ly}),右下角坐标({rx}, {ry})')
center()
center()方法可以返回元素中心点的坐标,其实就是使用bounds()方法得到坐标后,经过一通加减乘的运算得到中心点的坐标。使用方式如下:
x, y = device(text='hello').center()
click_gone()
click_gone()方法可以一直点击当前元素直到当前元素消失,可以使用maxretry参数控制点击的时间,默认10秒,可以使用interval参数控制每次点击间隔,默认1秒。使用方法如下:
# 在10秒钟内每隔1秒点击一次text属性为hello的元素,直到元素消失时停止,如果10秒后元素还未消失也会停止点击
device(text='hello').click_gone()
click_exists()
click_exists()方法可以点击当前元素(如果当前元素存在的话),可以使用timeout参数控制等待当前元素出现的时间,默认为0。使用方式如下:
# 如果在1秒钟内出现了text属性为hello的元素,点击该元素
device(text='hello').click_exists(timeout=1)
drag_to()
drag_to()方法可以把当前元素拖动到屏幕的某一个坐标,可以使用关键字参数duration设置拖动时间,可以使用关键字参数timeout设置查找元素等待时间。使用方式如下:
swipe()
swipe()方法可以在当前元素上滑动,以当前元素的中心为起点,可以选择向上下左右4个方向滑动。使用direction参数设置滑动方向,可以接受的值为"left"、"right"、"up"、"down"。可以使用steps参数设置滑动的步数,默认为10步。使用方式如下:
# 从text属性为hello的元素中心向该元素的左边界滑动
device(text='hello').swipe('left')
gesture()
gesture()方法可以执行两指手势,用于图片的放大、缩小等情况,需要按顺序传入手指1的起点做标、手指2的起点坐标、手指1的终点坐标、手指2的终点坐标。可以使用steps参数设置步数,默认100。使用方式如下:
device(text='images').gesture((300, 500), (300, 500), (100, 500), (500, 500))
pinch_in()
pinch_in()方法可以缩小当前元素,使用percent参数可以设置缩小的百分比,默认100,可以使用steps参数设置步数,默认50。使用方式如下:
device(text='images').pinch_in(50) # 把text属性为images的元素缩小百分之50
pinch_out()
pinch_out()方法可以放大当前元素,使用percent参数可以设置放大的百分比,默认100,可以使用steps参数设置步数,默认50。使用方式如下:
device(text='images').pinch_out(50) # 把text属性为images的元素放大百分之50
wait()
wait()方法可以等待当前元素出现或消失,可以使用exists参数设置元素的出现或消失,True为出现(存在)、False为消失,默认为True。可以使用timeout参数设置查找元素的等待时间,默认为20秒。使用方式如下:
device(text='hello').wait(exists=False, timeout=10) # 等待text属性为hello的元素消失,等待时间10秒
wait_gone()
wait_gone()方法可以等待当前元素消失,使用的就是wait()方法。可以使用timeout参数设置查找元素等待时间, 默认为20秒。使用方式如下:
device(text='hello').wait_gone(30)
must_wait()
must_wait()方法可以等待当前元素出现或消失,如果当前元素在一定时间内未出现或消失,就会抛出错误。可以使用exists参数设置元素的出现或消失,True为出现(存在)、False为消失,默认为True。可以使用timeout参数设置查找元素的等待时间,默认为20秒。使用方式如下:
device(text='hello').must_wait(timeout=30) # 等待text属性为hello的元素在30秒内出现,30秒后未出现会报错
set_text()
set_text()方法可以在当前元素中输入文本内容(前提是当前元素是一个输入框),需要传入要输入的文本,可以使用timeout参数设置查找元素等待时间,默认20秒。使用方式如下:
device(description='input').set_text('hello', timeout=10)
send_keys()
send_keys()方法可以在当前元素中输入文本内容,使用的就是set_text()方法,需要传入要输入的文本。使用方式如下:
device(description='input').send_keys('hello')
get_text()
get_text()方法可以获取当前元素的文本信息,可以使用timeout参数设置查找元素等待时间。使用方式如下:
text = device(text='hello').get_text(timeout=5)
print(text) # hello
clear_text()
clear_text()方法可以清空当前元素中的输入文本(前提是当前元素是一个输入框),使用的就是set_text()方法,可以使用timeout参数设置查找元素等待时间。使用方式如下:
device(description='input').clear_text()
child()
child()方法可以定位到当前元素的子级元素,child()方法同样是使用上面的各种属性来定位子级元素。使用方式如下:
device(text='hello').child(text='爆笑蛙')
sibling()
sibling()方法定位到当前元素的兄弟元素,sibling()方法同样是使用上面的各种属性来定位兄弟元素。使用方式如下:
device(text='hello').sibling(text='爆笑蛙')
child_by_text()
child_by_text()方法可以通过text属性值定位到当前元素的子级元素,可以选择使用关键字参数allow_scroll_search设置是否通过滑动查找子级元素。使用方式如下:
device(text='hello').child_by_text('爆笑蛙')
child_by_description()
child_by_description()方法可以通过description属性值定位到当前元素的子级元素,可以选择使用关键字参数allow_scroll_search设置是否通过滑动查找子级元素。使用方式如下:
device(text='hello').child_by_description('input')
child_by_instance()
child_by_instance()方法可以通过instance的值定位到当前元素的子级元素。使用方式如下:
device(text='hello').child_by_instance(2)
还不如就用child(instance=2)方法。
parent()
parent()方法暂时不能使用,只会返回NotImplementedError的错误。
count()
count()方法可以统计跟当前元素相似的元素有几个。使用方式如下:
device(text='hello').count() # 统计界面中有几个text属性为hello的元素
right()
right()方法可以定位到当前元素右边的元素,right()方法同样是使用上面的各种属性来定位右边的元素。使用方式如下:
# 找到text属性为hello元素右边的可选择元素,而且可选择元素已被勾选
device(text='hello').right(checked=True)
left()
left()方法可以定位到当前元素左边的元素,left()方法同样是使用上面的各种属性来定位左边的元素。使用方式如下:
# 找到text属性为hello元素左边的图标元素
device(text='hello').left(description='icon')
up()
up()方法可以定位到当前元素上方的元素,up()方法同样是使用上面的各种属性来定位上方的元素。使用方式如下:
device(text='hello').up(description='title')
down()
down()方法可以定位到当前元素下方的元素,down()方法同样是使用上面的各种属性来定位下方的元素。使用方式如下:
device(text='hello').down(description='summary')
fling()
fling()方法可以滚动屏幕,默认垂直方向滚动。使用垂直方向滚动的方法有vert、vertically、vertical,使用水平方向滚动的方法有horiz、horizental、horizentally。控制滚动到那个位置的方法有forward、backward、toBeginning、toEnd。forward方法为(垂直或水平方向)向前滚动一下;backward方法为(垂直或水平方向)向后滚动一下;toBeginning方法为(垂直或水平方向)滚动到最前方;toEnd方法为(垂直或水平方向)滚动到最后方。使用方式如下:
# 在拥有scrollable的元素上水平滚动,滚动到最后方
device(scrollable=True).filing.horiz.toEnd()
scroll()
scroll()方法可以滚动屏幕,默认垂直方向滚动。使用垂直方向滚动的方法有vert、vertically、vertical,使用水平方向滚动的方法有horiz、horizental、horizentally。控制滚动到那个位置的方法有forward、backward、toBeginning、toEnd、to。forward方法为(垂直或水平方向)向前滚动一下;backward方法为(垂直或水平方向)向后滚动一下;toBeginning方法为(垂直或水平方向)滚动到最前方;toEnd方法为(垂直或水平方向)滚动到最后方;to方法为滚动到某个元素的位置,to()方法同样是使用上面的各种属性来定位元素。使用方式如下:
# 在拥有scrollable属性的元素上垂直滚动,滚动到text属性为hello的元素位置
device(scrollable=True).scroll.to(text='hello')
xpath()
xpath()方法可以使用xpath路径定位元素,需要在WEditor中找到元素xpath的值。要注意的是xpath()返回的不是一个元素,而是一个XPath类的实例对象。XPath类中有一套自己操作元素和监听元素的方法,但实际上使用的还是上面介绍的这些方法,只是换个皮肤(名称)而已。我不想多解释XPath中的方法,因为我从来不用它。使用方式如下:
device.xpath('//*[@resource-id="com.android.systemui:id/center_group"]').click(timeout=3)
基础类方法
sleep()
sleep()方法可以暂停一段时间,使用的就是time.sleep()。使用方式如下:
device.sleep(1)
shell()
shell()方法可以执行shell指令,可以接受字符串或者字符串列表(连续执行多个指令时)。使用方式如下:
device.shell(['cd /sdcard', 'ls']) # 进入/sdcard目录,查看文件
device.shell('input keyevent 3') # 发送按键事件,按home键
device.shell('reboot') # 发送重启指令,重启手机
http
http方法可以返回测试服务,整个测试服务都是建立在此基础上的。http返回_AgentRequestSession(self),_AgentRequestSession继承于TimeoutRequestsSession,TimeoutRequestsSession又继承与requests.Session。requests做过接口测试的应该很熟悉吧,就是用来发请求的。http就可以发送get、post、delete请求,通过close关闭服务等。就不展示使用方式了,我们尽量别乱用http方法以免出现BUG,除非你对测试服务非常了解。
info
info方法可以返回设备的信息。使用方式如下:
info = device.info
print(info)
wlan_ip
wlan_ip方法可以返回服务的IP地址。使用方式如下:
ip = device.wlan_ip
print(ip)
uiautomator
uiautomator方法可以返回uiautomator测试服务,可以使用start()方法启动服务,可以使用stop()方法停止服务,可以使用running获取服务运行状态。使用方式如下:
server = device.uiautomator
server.start() # 启动服务
server.stop() # 停止服务
state = server.running() # 获取服务状态
reset_uiautomator()
reset_uiautomator()方法可以重启uiautomator测试服务。使用方式如下:
device.reset_uiautomator()
push()
push()方法可以推送文件到手机,需要传入要推送文件的路径和存放文件的地址。使用方式如下:
device.push('D:\file', '/sdcard')
pull()
pull()方法可以拉取手机文件,需要传入要拉取的文件路径和存放文件的地址。使用方式如下:
device.pull('/sdcard/file', 'D:\')
在unittest中使用
我们搭建测试框架的时候,首先应该写一个基础的测试方法类,其他的测试用例类都应继承基础测试方法类。
BaseTest.py
import logging
import os
import time
import unittest
import uiautomator2 as u2
from functools import wraps
class BaseTest(unittest.TestCase):
"""
基础测试类
继承unittest.TestCase
使用uiautomator2中的方法完成基本测试方法的设计
"""
device_number = "123456789" # 如果要通过UI界面控制,需要在其他py文件中提供类似get_device()的接口函数
@classmethod
def setUpClass(cls) -> None:
"""测试开始前定义测试设备"""
cls.device = u2.connect_usb(cls.device_number)
def setUp(self) -> None:
"""每条测试用例开始前要做的初始化动作,确保用例能正常执行"""
if not self.device.info.get('screenOn'): # 判断屏幕状态
self.device.screen_on() # 亮屏
self.device.sleep(1)
if self.device(resourceId="com.android.systemui:id/lock_icon", packageName="com.android.systemui"
).exists(timeout=1): # 判断是否处于锁屏界面
device_info = self.device.info # 获取设备信息
w, h = int(device_info['displayWidth']), int(device_info['displayHeight']) # 从设备信息中得到屏幕的高宽
x1 = w / 2
y1 = h - 10
self.device.swipe(x1, y1, x1, 10) # 滑动解锁
self.device.sleep(1)
# 如果存在图案锁、密码锁等需要添加额外逻辑
self.device.press("home") # 回到主界面
def tearDown(self) -> None:
"""每条测试用例执行结束后要做的收尾动作"""
self.device.press("home") # 回到主界面
@classmethod
def tearDownClass(cls) -> None:
"""测试结束后要做的动作,例如吧测试结果填到excel中,根据自己的需求写"""
def open_menu(self):
"""打开主菜单"""
device_info = self.device.info
w, h = int(device_info['displayWidth']), int(device_info['displayHeight'])
x1 = w - 10
y1 = h / 2
x2 = 10
self.device.swipe(x1, y1, x2, y1) # 向上滑动打开主菜单
def scroll_click(self, name):
"""
滚动找到文本并点击该文本
:param name: 文本内容
:return:
"""
if self.device(text=name).exists(timeout=1):
self.device(text=name).click()
else:
self.device(scrollable=True).scroll.to(text=name)
self.device(text=name).click()
def open_app(self, name):
"""
打开app
:param name: APP名称
:return:
"""
self.device.press('home')
self.device.sleep(1)
self.open_menu()
self.device.sleep(1)
if self.device(text=name).exists:
self.device(text=name).click()
else:
self.device(scrollable=True).scroll.toEnd()
self.scroll_click(name)
def add_img(self):
"""手机截图保存到images文件夹"""
now_time = time.strftime('%Y_%m_%d_%H_%M_%S')
with open(r'%s\images\test_%s.jpg' % (os.getcwd(), now_time), "wb") as fi:
fi.write(self.device.screenshot(format='raw'))
logging.error("查看截图: test_%s.jpg" % now_time)
def clear_all(self):
"""清空后台运行的软件"""
win_size = self.device.window_size()
x1 = win_size[0]
y1 = win_size[1] / 2
self.device.press("recent")
for i in range(10):
if self.device(text="CLEAR ALL").exists(timeout=1):
self.device(text="CLEAR ALL").click()
break
elif self.device(text="Clear all").exists(timeout=1):
self.device(text="Clear all").click()
break
elif self.device(description="No recent items").exists(timeout=1) or \
self.device(text="No recent items").exists(timeout=1):
self.device.press("home")
break
self.device.swipe(20, y1, x1, y1, 0.1)
else:
self.device.press("home")
def catch_err(self=BaseTest):
"""如果代码出错截图清理后台进程并抛出错误"""
def receive_func(func):
@wraps(func)
def dispose_func(*args, **kwargs):
try:
func(*args, **kwargs)
except Exception as e:
self.add_img(*args)
self.clear_all(*args)
raise e
return dispose_func
return receive_func
我们在BaseTest文件中定义一些基础的测试方法和测试用例的装饰器,我这里只是举例,你可以根据你的需求再写一些,其他的测试用例类全都继承基础测试类。
MyTest.py
from BaseTest import BaseTest, catch_err
import unittest
import os
import time
import HTMLTestRunner
class TestCase(BaseTest):
@catch_err()
def test_1(self):
"""xxx测试用例"""
@catch_err()
def test_2(self):
"""xxx测试用例"""
if __name__ == '__main__':
suite = unittest.TestSuite()
suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestCase))
di_r = r"%s\report\test_%s.html" % (os.getcwd(), time.strftime('%Y_%m_%d_%H_%M_%S')) # 定义测试报告文件
filename = open(di_r, "wb")
runner = HTMLTestRunner.HTMLTestRunner(stream=filename, title="xx测试报告", description=u"测试用例明细")
runner.run(suite)
filename.close()
我们在测试用例中直接使用基本测试类中的方法,来完成对测试用例的编写,如果你认为有的基础测试方法不适用,还可以重写此方法。我们还可以做一个UI界面,在UI界面中选择性的执行测试用例。