uiautomator
测试框架可以对Android
应用进行黑盒测试, 通过获得当前窗口视图(View
)体系结构中的组件,对指定的组件进行操作,可以跨APP进行测试.通常在写测试功能逻辑代码时, 可以利用uiautomatorviewer
工具获得待操作组件的属性,比如class name,
resource ID
或者text
等等,uiautomator可以使用这些属性获得对应的组件,以便对其进行操作.关于uiautomator具体的介绍,请参考Android官网https://developer.android.com/training/testing/ui-automator. 另外,uiautomatorviewer工具在Android SDK
的tools/bin
目录下可以找到.
下面说明如何使用该框架进行Android应用程序功能测试.https://github.com/xiaocong/uiautomator#uiautomator提供了对uiautomator框架API的Python
封装.本工程程序基于该封装API进行设计. 下面是工程的目录结构:
Python
可执行模块.本工程主要说明uiautomator
框架的使用,因为具体的测试代码是和待测试APP的功能相关的, 这里只通过一个测试用例来说明该测试框架的Python封装API的调用.
该测试用例来测试某APP首次启动页面, 点击不同国家进行资源装载:
首先是工程入口文件__main__.py的实现:
import os
import sys
import time
from com.autotest.adb_cmd import AdbCmd
from com.autotest.device_info import DeviceInfo
from com.autotest.func.start_page import StartPage
from com.autotest.pkg_info import PkgInfo
from com.autotest.utils import to_str, Timeout, dump
from uiautomator import device as d
def test(device, *, ostream=None):
StartPage.test(device, ostream=ostream)
def make_ready(adb, *, ostream=None):
_, err = d.server.adb.cmd(*adb.cmds["install"]).communicate()
if err:
err = to_str(err).strip()
# Note: err return Success if install successfully sometimes.
if "Success" not in err:
dump("[!] Install apk to device failed:\n{}".format(err), ostream=ostream, sys_exit=False)
return False
_, err = d.server.adb.cmd(*adb.cmds["startup"]).communicate()
if err:
err = to_str(err).strip()
dump("[!] Startup failed:\n{}".format(err), ostream=ostream, sys_exit=False)
return False
return True
if __name__ == '__main__':
root = os.path.realpath(".")
result_file_stream = open(os.path.join(root, "result.txt"), "w")
DeviceInfo.height = d.info.get("displayHeight")
DeviceInfo.width = d.info.get("displayWidth")
uninstall_after_test = False
for i, item in enumerate(sys.argv):
if item == "-c" and sys.argv[i + 1] == "true":
uninstall_after_test = True
if item == "-f":
PkgInfo.pkg_path = sys.argv[i + 1]
adb = AdbCmd()
rc = make_ready(adb, ostream=result_file_stream)
if rc:
try:
test(d, ostream=result_file_stream)
except SystemExit:
pass
if uninstall_after_test:
d.server.adb.cmd(*adb.cmds["uninstall"]).communicate()
dump(format("Test finished", "-^80"), ostream=result_file_stream, sys_exit=False)
result_file_stream.close()
首先导入uiautomator封装模块from uiautomator import device as d
, 其代表通过USB
连接的手机设备, 通过该对象可以执行adb
命令, 也可以对当前窗口中的组件进行各种操作.
工具类AdbCmd
封装了一些adb 命令, 比如APP的安装, 卸载,启动,清除用户数据等等.
函数make_ready将APK文件安装到手机,并调用adb命令将其启动起来, 然后调用test函数执行具体的测试用例.用例执行完成以后,可以删除安装到手机的APP.测试过程中的关键步骤和异常信息记录到log
文件中.
测试用例的实现文件start_page.py:
import time
from com.autotest.adb_cmd import AdbCmd
from com.autotest.utils import measure, check_crash, Timeout, dump, case_str
def set_tear(func):
def wrapper(*args, **kwargs):
func(*args, **kwargs)
adb_cmd = AdbCmd()
args[0].server.adb.cmd(*adb_cmd.cmds["uninstall"]).communicate()
args[0].server.adb.cmd(*adb_cmd.cmds["install"]).communicate()
args[0].server.adb.cmd(*adb_cmd.cmds["startup"]).communicate()
time.sleep(Timeout.long_pause)
return wrapper
class StartupAndLoading(object):
@staticmethod
def get_country_list_container_for_startup(d, *, ostream=None):
title = d(className="android.widget.TextView", text="Select country")
country_list_rv = d(className="android.support.v7.widget.RecyclerView", resourceId="com.your.app:id/choice_container")
shown = title.exists and country_list_rv.exists
if not shown:
err = "[!] Not launch to country list page."
dump(err, ostream=ostream, sys_exit=True)
return country_list_rv
@staticmethod
@set_tear
@check_crash
def load(d, item, *, ostream=None):
s = "{} Go to country:{}".format(case_str, item.text)
dump(s, ostream=ostream, sys_exit=False)
item.click()
time.sleep(Timeout.long_pause)
scrolly(d, ostream=ostream)
time.sleep(Timeout.short_pause)
@staticmethod
@measure("Start page")
def test(d, *, ostream=None):
counts = d(className="android.widget.TextView", resourceId="com.your.app:id/spinner_popup_item").count
s = "Try to test country list. counts:{}".format(counts)
dump(s, ostream=ostream, sys_exit=False)
for i in range(counts):
country_list_rv = get_country_list_container_for_startup(d, ostream=ostream)
item = country_list_rv.child_by_instance(i)
StartupAndLoading.load(d, item, ostream=ostream)
该测试用例获得需要点击的组件列表, 依次点击.
下面是一些辅助类的实现.
文件device_info.py记录设备信息,其实现为:
class DeviceInfo(object):
"""Record height and width of connected device. """
pass
可以通过封装的uiautomator接口获得设备的宽和高.
文件pkg_info.py是APK文件基本信息,其实现为:
class PkgInfo(object):
"""Package name and the apk path to test."""
pkg_name = "com.your.app"
记录应用的包名和APK文件的路径.
文件adb_cmd.py是一些adb shell命令,其实现为:
from com.autotest.pkg_inf import PkgInfo
class AdbCmd(object):
def __init__(self):
self.cmds = {}
self.cmds["install"] = ["install", "-g", "{}".format(PkgInfo.pkg_path)]
self.cmds["uninstall"] = ["uninstall", "{}".format(PkgInfo.pkg_name)]
self.cmds["startup"] = ["shell", "am", "start", "{}/com.your.app.MainActivity".format(PkgInfo.pkg_name)]
self.cmds["force_stop"] = ["shell", "am", "force-stop", "{}".format(PkgInfo.pkg_name)]
self.cmds["clear_data"] = ["shell", "pm", "clear", "{}".format(PkgInfo.pkg_name)]
文件utils.py中是一些工具代码,其实现为:
import sys
from time import time
from com.autotest.pkg_info import PkgInfo
def to_str(bytes_or_str):
if isinstance(bytes_or_str, bytes):
value = bytes_or_str.decode('utf-8')
else:
value = bytes_or_str
return value
def measure(tag):
def wrapper(f):
def func(*args, **kwargs):
ostream = kwargs.get("ostream")
str_start = "{} test start".format(tag)
dump(format(str_start, ">^80"), ostream=ostream, sys_exit=False)
start = time()
f(*args, **kwargs)
end = time()
elapsed = "{} test elapsed {}".format(tag, get_readable_time(end-start))
dump(format(elapsed, "<^80"), ostream=ostream, sys_exit=False)
return func
return wrapper
def check_crash(func):
def wrapper(*args, **kwargs):
pid_last = get_pid(args[0])
func(*args, **kwargs)
pid_now = get_pid(args[0])
if not pid_last.intersection(pid_now):
err = "[!] Crashed. Pid last {} NOT equal to pid now {}.\n".format(pid_last, pid_now)
ostream = kwargs.get("ostream")
dump(err, ostream=ostream, sys_exit=True)
return wrapper
def get_pid(d):
pids = set()
args = ["shell", "dumpsys", "activity", "processes", "{}".format(PkgInfo.pkg_name)]
out, _ = d.server.adb.cmd(*args).communicate()
if out:
lines = to_str(out).strip().split("\n")
for line in lines:
if "pid=" in line:
kv = line.strip().split()[0]
pid = kv.strip().split("=")[1]
pids.add(pid)
return pids
@check_crash
def scrolly(d, *, ostream=None, direct=None):
if direct is None:
direct = random.choice([1, -1])
step = random.randint(10, 30)
dy = random.randint(DeviceInfo.height / 2, DeviceInfo.height * 2)
d.swipe(DeviceInfo.width / 2, DeviceInfo.height / 2, DeviceInfo.width / 2, direct * dy, steps=step)
dump("Scroll vertical to refresh.")
time.sleep(Timeout.short_pause)
def scrolly_up(d, *, ostream=None):
scrolly(d, ostream=ostream, direct=1)
def scrolly_down(d, *, ostream=None):
scrolly(d, ostream=ostream, direct=-1)
def dump(s, *, ostream=None, sys_exit=None):
print(s)
if ostream:
print(s, file=ostream)
if sys_exit:
sys.exit(1)
def get_readable_time(t):
ft = ""
h = 0
i = int(t)
m = i // 60
s = i % 60 + round(t - i, 3)
if m >= 60:
h = m // 60
m = m % 60
if h:
ft = "{} hours ".format(h)
if m:
ft += "{} minutes ".format(m)
ft += "{} seconds".format(s)
return ft
class Timeout(object):
load_view = 0.2
human_react = 1
short_pause = 3
long_pause = 10
case_str = "Case:"
测试用例执行完,要能对其结果进行检查,知晓是否符合设计预期. 程序和人的区别是, 程序有时候很难判断检查结果,比如组件显示的是否正确,显示是否流畅等等.使用程序测试时, 有时候为了检查资源加载也必须设定超时时间, 而这些超时检查可能会影响程序的判断和下一步的处理流程.这些结果检查逻辑依赖于APP的功能,需要根据具体场景继续处理,但是,也可以做基本的检查, 比如检查操作过程中是否发生了崩溃, 可以根据APP运行时进程的PID
是否发生变化来对比.
总结:
通过Python的装饰器机制,实现测试过程中的log记录, 测试时长, 以及setup
和teardown
机制对用例预制测试条件.