uiautomator2 测试和分析报告 - 移动应用测试

uiautomator2 测试和分析报告

Part 1 测试工具部署

1.1 安装依赖库+初始化设备

  • 这里我直接在anaconda命令行里安装了
# 安装uiautomator2
pip install -e uiautomator2
# 安装weditor
pip install -U weditor
# init 所有的已经连接到电脑的设备
python -m uiautomator2 init

1.2 尝试连接Android Studio ADV

  • 我的设备信息:
    在这里插入图片描述
  • 先cmd里查询一下设备名adb devices,结果为emulator-5554
  • 编写连接测试代码:
import uiautomator2 as u2
d = u2.connect('emulator-5554') # connect to device
print(d.info)
# {'currentPackageName': 'com.android.chrome', 'displayHeight': 2701, 'displayRotation': 0, 'displaySizeDpX': 411, 'displaySizeDpY': 869, 'displayWidth': 1440, 'productName': 'sdk_gphone_x86_arm', 'screenOn': True, 'sdkInt': 30, 'naturalOrientation': True}
# 连接成功啦
  • 开启weditor,方便查看元素对应的xpath
python -m weditor
# 自动跳转到 http://localhost:17310/
  • 至此我们实现了库的部署和试运行

Part 2 测试设计

我们选择了网易云音乐作为测试的对象

2.1 用例场景

  • 安装卸载
  • UI测试:
    • 静态:按钮,对话框,列表,窗口
    • 动态:列表页,提示框
    • 弹出/系统交互

2.2 测试用例

  • 游客进入APP
    • 应用授权
    • 用户协议
    • 进入
    • 弹窗处理
  • 导航栏:五个元素是否都能进入对应页面:
    • 发现,断言检测:存在’每日推荐’字样
    • 播客,断言检测:存在’我的播客’字样
    • 我的,断言检测:存在’我的好友’字样
    • k歌,断言检测:存在’广场’字样
    • 云村,断言检测:存在’音乐人’字样

Part 3 测试过程

3.1 准备工作

首先使用weditor进行app界面元素的查看和记录~

3.1.1 弹窗

上来就得处理弹窗,麻了。
在这里插入图片描述
在这里插入图片描述
可以看到我们选择的元素都可以找到对应的xpath或者resourceId,记录一下方便我们之后selector的编写。

3.1.2 Layout

登陆成功以后还有悬浮框,这个layout就更麻了,打算之后直接检测到之后就随便点一下。
在这里插入图片描述

3.1.3 Other

其实知道了resourceId和xpath之后,再加上text的直接寻找,大致就能定位我们所有需要的元素了。
接触过爬虫的同学可能觉得和爬虫分析网页结构的过程很相似,笑。

3.2 测试脚本

  • 启动应用->授权->游客登入->处理弹窗
import uiautomator2 as u2
import unittest
from logzero import logger

d = u2.connect('emulator-5554')


class MusicTestCase(unittest.TestCase):
    def setUp(self):
        self.package_name = "com.netease.cloudmusic"
        d.xpath.global_set(key="timeout",value=100) # xpath最长等待时间

    def tearDown(self):
        d.set_fastinput_ime(False)
        #d.app_stop(self.package_name)
        #d.screen_off()

    def runTest(self):
        logger.info("runTest")
        d.app_stop(self.package_name)
        d.app_clear(self.package_name)
        s = d.session(self.package_name)
        s.set_fastinput_ime(True)
        # 处理弹窗
        with d.watch_context() as ctx:
            ctx.when("Ok").click()
            ctx.when("授权").click()
            ctx.when("Allow").click()
            # 上面三行代码是立即执行完的,不会有什么等待
            ctx.wait_stable()  # 开启弹窗监控,并等待界面稳定(两个弹窗检查周期内没有弹窗代表稳定)

        d.wait_activity("com.netease.cloudmusic:id/agreeCheckbox") # 等待登录界面出现,耗时比较长
        with d.watch_context() as ctx_2:
            d(resourceId="com.netease.cloudmusic:id/agreeCheckbox").click()
            ctx_2.when('Guest').click()
            ctx_2.wait_stable()
        d.wait_activity('//*[@resource-id="android:id/content"]/android.widget.ImageView[1]')
        with d.watch_context() as ctx_3:
            d.click(0.767, 0.157)
            search_bar_flag=True if d(resourceId="com.netease.cloudmusic:id/searchBar").get_text() else False
            self.assertEqual(search_bar_flag,True)

if __name__ == "__main__":
    unittest.main()
  • 点击导航栏测试(五个元素分别测试)
import uiautomator2 as u2
import unittest
from logzero import logger

d = u2.connect('emulator-5554')


class MusicTestCase(unittest.TestCase):
    def setUp(self):
        self.package_name = "com.netease.cloudmusic"
        d.xpath.global_set(key="timeout",value=100) # xpath最长等待时间

    def tearDown(self):
        d.set_fastinput_ime(False)
        #d.app_stop(self.package_name)
        #d.screen_off()

    def runTest(self):
        logger.info("runTest")
        d.app_stop(self.package_name)
        d.app_clear(self.package_name)
        s = d.session(self.package_name)
        s.set_fastinput_ime(True)
        # 处理弹窗
        with d.watch_context() as ctx:
            ctx.when("Ok").click()
            ctx.when("授权").click()
            ctx.when("Allow").click()
            # 上面三行代码是立即执行完的,不会有什么等待
            ctx.wait_stable()  # 开启弹窗监控,并等待界面稳定(两个弹窗检查周期内没有弹窗代表稳定)

        d.wait_activity("com.netease.cloudmusic:id/agreeCheckbox") # 等待登录界面出现,耗时比较长
        with d.watch_context() as ctx_2:
            d(resourceId="com.netease.cloudmusic:id/agreeCheckbox").click()
            ctx_2.when('Guest').click()
            ctx_2.wait_stable()
        d.wait_activity('//*[@resource-id="android:id/content"]/android.widget.ImageView[1]')
        with d.watch_context() as ctx_3:
            d.click(0.767, 0.157)
            search_bar_flag=True if d(resourceId="com.netease.cloudmusic:id/searchBar").get_text() else False
            self.assertEqual(search_bar_flag,True)

if __name__ == "__main__":
    unittest.main()
  • 点击导航栏测试(五个元素分别测试)
import uiautomator2 as u2
import unittest
from logzero import logger

d = u2.connect('emulator-5554')
package_name = "com.netease.cloudmusic"
d.xpath.global_set(key="timeout", value=100)
logger.info("setUp unlock-screen")
logger.info("runTest")
d.app_stop(package_name)
d.app_clear(package_name)
s = d.session(package_name)
test_dict = {
    
    
    '//*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[2]/android.widget.ImageView[1]': '我的播客',
    '//*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[3]/android.widget.ImageView[1]': '我的好友',
    '//*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[4]/android.widget.ImageView[1]': '广场',
    '//*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[5]/android.widget.ImageView[1]': '音乐人',
    '//*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[1]/android.widget.ImageView[1]': '每日推荐'}


def init_env():
    try:

        with d.watch_context() as ctx:
            ctx.when("同意").click()
            ctx.when("确定").click()
            ctx.when("Ok").click()
            ctx.when("授权").click()
            ctx.when("Allow").click()
            ctx.wait_stable(timeout=10)
        d.wait_activity("com.netease.cloudmusic:id/agreeCheckbox")
        with d.watch_context() as ctx_2:
            d(resourceId="com.netease.cloudmusic:id/agreeCheckbox").click()
            ctx_2.when('Guest').click()
            ctx_2.wait_stable()
        # d.wait_activity('//*[@resource-id="android:id/content"]/android.widget.ImageView[1]', timeout=10)
        # d.press('back')
        d.click(0.289, 0.426)
    except Exception as e:
        print(e)


class BaseCase(unittest.TestCase):
    def _test_base(self, _path):
        with d.watch_context() as cxt:
            cxt.when('CANCEL').click()
            path = _path
            logger.info(path)
            d.xpath(path).click()
            _text = d(text=test_dict[path]).get_text()
            logger.info(_text)
            self.assertEqual(True if _text else False, True)
            cxt.wait_stable()


class MusicTestCase(BaseCase):
    @classmethod
    def setUpClass(cls) -> None:
        init_env()

    def setUp(self):
        d.set_fastinput_ime(True)
        # 处理弹窗

    def tearDown(self):
        d.set_fastinput_ime(False)
        # d.app_stop(self.package_name)
        # d.screen_off()

    def test_page_boke(self):
        super(MusicTestCase, self)._test_base(
            '//*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[{}]/android.widget.ImageView[1]'.format(
                '2'))

    def test_page_wode(self):
        super(MusicTestCase, self)._test_base(
            '//*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[{}]/android.widget.ImageView[1]'.format(
                '3'))

    def test_page_kge(self):
        super(MusicTestCase, self)._test_base(
            '//*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[{}]/android.widget.ImageView[1]'.format(
                '4'))

    def test_page_yuncun(self):
        super(MusicTestCase, self)._test_base(
            '//*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[{}]/android.widget.ImageView[1]'.format(
                '5'))

    def test_page_faxian(self):
        super(MusicTestCase, self)._test_base(
            '//*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[{}]/android.widget.ImageView[1]'.format(
                '1'))


if __name__ == "__main__":
    unittest.main()

3.3 执行测试

[I 210512 09:06:01 main_test:10] setUp unlock-screen
[I 210512 09:06:01 main_test:11] runTest
[D 210512 09:06:05 watcher:90] watch check
[D 210512 09:06:05 watcher:101] match: Ok
[D 210512 09:06:05 watcher:104] watchContext xpath matched: ('Ok',)
[D 210512 09:06:07 watcher:90] watch check
[D 210512 09:06:07 watcher:101] match: 授权
[D 210512 09:06:07 watcher:104] watchContext xpath matched: ('授权',)
[D 210512 09:06:09 watcher:90] watch check
[D 210512 09:06:09 watcher:101] match: Allow
[D 210512 09:06:09 watcher:104] watchContext xpath matched: ('Allow',)
[D 210512 09:06:11 watcher:90] watch check
[D 210512 09:06:13 watcher:90] watch check
[I 210512 09:06:14 watcher:142] context closed
[D 210512 09:06:26 watcher:90] watch check
[D 210512 09:06:29 watcher:90] watch check
[D 210512 09:06:29 watcher:101] match: Guest
[D 210512 09:06:29 watcher:104] watchContext xpath matched: ('Guest',)
[D 210512 09:06:31 watcher:90] watch check
[D 210512 09:06:35 watcher:101] match: Guest
[D 210512 09:06:35 watcher:104] watchContext xpath matched: ('Guest',)
[D 210512 09:06:38 watcher:90] watch check
[D 210512 09:06:41 watcher:90] watch check
[I 210512 09:06:42 watcher:142] context closed

Process finished with exit code 0
[D 210512 09:06:43 watcher:90] watch check
[I 210512 09:06:43 main_test:50] //*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[2]/android.widget.ImageView[1]
[D 210512 09:06:46 watcher:90] watch check
[I 210512 09:06:46 main_test:53] 我的播客
[D 210512 09:06:48 watcher:90] watch check
[D 210512 09:06:48 watcher:101] match: CANCEL
[D 210512 09:06:48 watcher:104] watchContext xpath matched: ('CANCEL',)
[D 210512 09:06:50 watcher:90] watch check
[D 210512 09:06:53 watcher:90] watch check
[I 210512 09:06:54 watcher:142] context closed
[D 210512 09:06:55 watcher:90] watch check
[I 210512 09:06:55 main_test:50] //*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[1]/android.widget.ImageView[1]
[I 210512 09:06:56 main_test:53] 每日推荐
[D 210512 09:06:57 watcher:90] watch check
[D 210512 09:06:59 watcher:90] watch check
[I 210512 09:07:00 watcher:142] context closed
[D 210512 09:07:02 watcher:90] watch check
[I 210512 09:07:02 main_test:50] //*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[4]/android.widget.ImageView[1]
[I 210512 09:07:03 main_test:53] 广场
[D 210512 09:07:04 watcher:90] watch check
[D 210512 09:07:07 watcher:90] watch check
[I 210512 09:07:07 watcher:142] context closed
[D 210512 09:07:09 watcher:90] watch check
[I 210512 09:07:09 main_test:50] //*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[3]/android.widget.ImageView[1]
[I 210512 09:07:10 main_test:53] 我的好友
[D 210512 09:07:11 watcher:90] watch check
[D 210512 09:07:14 watcher:90] watch check
[I 210512 09:07:14 watcher:142] context closed
[D 210512 09:07:16 watcher:90] watch check
[I 210512 09:07:16 main_test:50] //*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[5]/android.widget.ImageView[1]
[I 210512 09:07:17 main_test:53] 音乐人
[D 210512 09:07:18 watcher:90] watch check
[D 210512 09:07:21 watcher:90] watch check
[I 210512 09:07:21 watcher:142] context closed


Ran 5 tests in 78.424s

OK

3.4 测试结果分析

  • 因为都使用了断言检测,所以测试用例通过的话说明都符合预期结果了。

Part 4 测试工具结构和源码关键流程分析

4.1结构

  • 移动端:atx-agent, minicap, minitouch
    • atx-agent这个项目的主要目的是为了屏蔽不同安卓机器的差异,然后提供统一的HTTP接口(GET / 接口名)供你使用供uiautomator2使用。项目最终会发布成一个二进制程序,运行在Android系统的后台,实际上是http rpc服务。
    • Minicap provides a socket interface for streaming realtime screen capture data out of Android devices.
    • Minitouch provides a socket interface for triggering multitouch events and gestures on Android devices.
  • 测试端:废话不多说直接上源码
# uiautomator2/init.py
class Initer():
    def __init__(self, device: adbutils.AdbDevice, loglevel=logging.INFO):
        d = self._device = device

        self.sdk = d.getprop('ro.build.version.sdk')
        self.abi = d.getprop('ro.product.cpu.abi')
        self.pre = d.getprop('ro.build.version.preview_sdk')
        self.arch = d.getprop('ro.arch')
        self.abis = (d.getprop('ro.product.cpu.abilist').strip()
                     or self.abi).split(",")
        
        self.__atx_listen_addr = "127.0.0.1:7912"
        self.logger = setup_logger(level=loglevel)
        # self.logger.debug("Initial device %s", device)
        self.logger.info("uiautomator2 version: %s", __version__)

    def set_atx_agent_addr(self, addr: str):
        assert ":" in addr
        self.__atx_listen_addr = addr
  • 那其实已经很明确了,客户端是通过监听__atx_listen_addr来通信的。
def connect(addr=None) -> Device:
    """
    Args:
        addr (str): uiautomator server address or serial number. default from env-var ANDROID_DEVICE_IP

    Returns:
        Device

    Raises:
        ConnectError

    Example:
        connect("10.0.0.1:7912")
        connect("10.0.0.1") # use default 7912 port
        connect("http://10.0.0.1")
        connect("http://10.0.0.1:7912")
        connect("cff1123ea")  # adb device serial number
    """
    if not addr or addr == '+':
        addr = os.getenv('ANDROID_DEVICE_IP') or os.getenv("ANDROID_SERIAL")
    wifi_addr = _fix_wifi_addr(addr)
    if wifi_addr:
        return connect_wifi(addr)
    return connect_usb(addr)


def connect_usb(serial: Optional[str] = None, init: bool = False) -> Device:
    """
    Args:
        serial (str): android device serial

    Returns:
        Device

    Raises:
        ConnectError
    """
    if init:
        logger.warning("connect_usb, args init=True is deprecated since 2.8.0")

    if not serial:
        device = adbutils.adb.device()
        serial = device.serial
    return Device(serial)
  • 实例化了adb的对象,同时做了一个端口转发将本地某个端口(lport)的tcp数据转发到手机上的7912端口上,而手机上监听这个端口的服务实际上就是atx-agent。
    同时判断agent是否已启动,若没有则手动拉起agent。
class Device(_Device, _AppMixIn, _PluginMixIn, _InputMethodMixIn, _DeprecatedMixIn):
    """ Device object """
  • 再往下深究的话就是几个基础类的封装了,溜之

4.2 比较有意思的设计 / 注意的点

  • watch_context相关
# uiautomator2/__init__.py
class _PluginMixIn:
    @cached_property
    def settings(self) -> Settings:
        return Settings(self)

    def watch_context(self, autostart: bool = True, builtin: bool = False) -> WatchContext:
        wc = WatchContext(self, builtin=builtin)
        if autostart:
            wc.start()
        return wc

    @cached_property
    def watcher(self) -> Watcher:
        return Watcher(self)

    @cached_property
    def xpath(self) -> xpath.XPath:
        return xpath.XPath(self)

class WatchContext:
    def __init__(self, d: "uiautomator2.Device", builtin: bool = False):
        self._d = d
        self._callbacks = OrderedDict()
        self.__xpath_list = []
        self.__lock = threading.Lock()
        self.__trigger_time = time.time()

    def wait_stable(self, seconds: float = 5.0, timeout: float = 60.0):
        """ wait until watches not triggered
        Args:
            seconds: stable seconds
            timeout: raise error when wait stable timeout
            
        Raises:
            TimeoutError
        """
        if not self.__started:
            self.start()

        deadline = time.time() + timeout
        while time.time() < deadline:
            with self.__lock:
                if time.time() - self.__trigger_time > seconds:
                    return True
            time.sleep(.2)
        raise TimeoutError("Unstable")

    def when(self, xpath: str):
        """ 当条件满足时,支持 .when(..).when(..) 的级联模式"""
        self.__xpath_list.append(xpath)
        return self

    def call(self, fn: typing.Callable):
        """
        Args:
            fn: support args (d: Device, el: Element)
                see _run_callback function for more details
        """
        xpath_list = tuple(self.__xpath_list)
        self.__xpath_list = []
        assert xpath_list, "when should be called before"

        self._callbacks[xpath_list] = fn

    def click(self):
        self.call(_callback_click)

    def _run(self) -> bool:
        logger.debug("watch check")
        source = self._d.dump_hierarchy()
        for xpaths, func in self._callbacks.items():
            ok = True
            last_match = None
            for xpath in xpaths:
                sel = self._d.xpath(xpath, source=source)
                if not sel.exists:
                    ok = False
                    break
                last_match = sel.get_last_match()
                logger.debug("match: %s", xpath)
            if ok:
                # 全部匹配
                logger.debug("watchContext xpath matched: %s", xpaths)
                self._run_callback(func, last_match)
                return True
        return False

    def _run_callback(self, func, element):
        inject_call(func, d=self._d, el=element)
        self.__trigger_time = time.time()

    def _run_forever(self, interval: float):
        try:
            while not self.__stop.is_set():
                with self.__lock:
                    self._run()
                time.sleep(interval)
        finally:
            self.__stopped.set()

    def start(self):
        if self.__started:
            return
        self.__started = True
        self.__stop.clear()
        self.__stopped.clear()
        interval = 2.0  # 检查周期
        threading.Thread(target=self._run_forever,
                         daemon=True,
                         args=(interval, )).start()

显然作者这里是用了线程操作和回调了,那就需要考虑一些同步问题了:

  • 第一,为了边界的明确性,使用watch_context()的时候可以用with来管理上下文,这也是with watch_context() as cxt这种写法的由来。
  • 第二,基于when的写法,不难看出是通过轮询+回调的方式来进行when的管理,匹配则进行callback,无匹配则不触发,所以同时使用多个when的情况下,他们都是并行的(虽然因为GIL锁的原因,实际是时间片轮转的伪并行)。
  • 第三,wait_stable()可以用于结束边界的管理,默认是2次轮询没有变化的话结束这个线程。
  • 我从我上面自己的测试代码里摘抄了一段出来供再次理解:
def test_page_faxian(self):
    with d.watch_context() as cxt: # 开启新线程
        cxt.when('CANCEL').click() # 注册弹窗检测+点击回调
        path='//*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[{}]/android.widget.ImageView[1]'.format('1')
        logger.info(path)
        d.xpath(path).click()
        _text = d(text=test_dict[path]).get_text() # 和上下文无关的检测,但是可以被线程的生命周期管理
        logger.info(_text)
        self.assertEqual(True if _text else False, True) # 断言
        cxt.wait_stable() # 结束检测器
atch_context() as cxt: # 开启新线程
        cxt.when('CANCEL').click() # 注册弹窗检测+点击回调
        path='//*[@resource-id="com.netease.cloudmusic:id/bottomNav"]/android.view.ViewGroup[{}]/android.widget.ImageView[1]'.format('1')
        logger.info(path)
        d.xpath(path).click()
        _text = d(text=test_dict[path]).get_text() # 和上下文无关的检测,但是可以被线程的生命周期管理
        logger.info(_text)
        self.assertEqual(True if _text else False, True) # 断言
        cxt.wait_stable() # 结束检测器

猜你喜欢

转载自blog.csdn.net/qq_42739587/article/details/116675489