如何使用uiautomator测试框架的Python API对Android应用进行测试

uiautomator测试框架可以对Android应用进行黑盒测试, 通过获得当前窗口视图(View)体系结构中的组件,对指定的组件进行操作,可以跨APP进行测试.通常在写测试功能逻辑代码时, 可以利用uiautomatorviewer工具获得待操作组件的属性,比如class name, resource ID或者text等等,uiautomator可以使用这些属性获得对应的组件,以便对其进行操作.关于uiautomator具体的介绍,请参考Android官网https://developer.android.com/training/testing/ui-automator. 另外,uiautomatorviewer工具在Android SDKtools/bin目录下可以找到.

下面说明如何使用该框架进行Android应用程序功能测试.https://github.com/xiaocong/uiautomator#uiautomator提供了对uiautomator框架API的Python封装.本工程程序基于该封装API进行设计. 下面是工程的目录结构:
如何使用uiautomator测试框架的Python API对Android应用进行测试_第1张图片

  • uiautomator目录是Python封装的uiautomator框架库,可以从前文的github链接中下载.
  • 文件__main__.py是测试工程执行入口. 定义该文件名, 可以使工程根目录成为Python可执行模块.
  • com目录下是具体的测试程序.其目录结构为:
    如何使用uiautomator测试框架的Python API对Android应用进行测试_第2张图片
    func目录下是一个测试用例文件:
    如何使用uiautomator测试框架的Python API对Android应用进行测试_第3张图片

本工程主要说明uiautomator框架的使用,因为具体的测试代码是和待测试APP的功能相关的, 这里只通过一个测试用例来说明该测试框架的Python封装API的调用.

该测试用例来测试某APP首次启动页面, 点击不同国家进行资源装载:
如何使用uiautomator测试框架的Python API对Android应用进行测试_第4张图片
首先是工程入口文件__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记录, 测试时长, 以及setupteardown机制对用例预制测试条件.

你可能感兴趣的:(Android,Python)