Android自动化测试框架实现

背景介绍

        最近打算梳理一下不同产品领域的自动化测试实现方案,如:Android终端、Web、服务端、智能硬件等,就先从Android终端产品开始梳理吧。本文主要介绍UI自动化测试的实现,因为这类测试解决方案比较通用,Android系统层、内核层的自动化测试解决方案可能要根据公司的具体业务来定了。

开源工具

        目前已经有比较不错的一些开源工具,如:Appnium、Python-UiAutomator,功能基本差不多,实现原理也是一样的,这里不做过多描述,有兴趣的同学可以查阅相关资料学习。

自研工具详述

        既然已经有开源的工具了,为什么还要自研?

        1、根据公司业务需要,满足定制化需求

        2、结合公司自动化框架,打造更加归一化、集成化的平台

        3、降低维护成本、提升易用性

一、实现原理概述

        我们先讲一下PC怎么跟Android终端通信,进而实现对它的控制,方式有两种:有线方式和无线方式。

有线方式:PC跟终端通过USB线连接,这种方式其实是通过adb作为中间媒介来实现通信的,如下图所示:

Android自动化测试框架实现_第1张图片

上图提到了Adb Client、Adb Server、Adb Daemon三个关键要素,下面简单讲一下三者的作用:

Adb Client:这部分就是需要我们自研开发的socket客户端

Adb Server:其实就是通过adb start-server命令在PC上启动的一个socket服务

Adb Daemon:是在终端上后台运行的一个守护进程,开机自启动,而且即使被杀掉,系统也会重新启动该进程,主要作用是跟Adb Server进行连接通信

注意:Adb Client与Adb Server通信遵循adb协议,具体协议内容可以百度查一下,相关资料很多

        通过以上内容大家应该清楚了PC怎么连接终端并且跟终端进行通信,那么重点来了,想要自研UI自动化测试工具就必须开发一个应用软件实现对uiautomator的封装,并在终端上作为Server运行起来,那么Adb Client跟我们开发的终端应用软件是如何进行通信的呢?如下图所示:

Android自动化测试框架实现_第2张图片

         本质上就是通过adb forward进行端口转发,关于这部分的内容不在这里做重点介绍,大家可以去查询adb协议。

无线方式:PC跟终端都连接到了同一个网络,通过网络进行通信,如下图所示:

Android自动化测试框架实现_第3张图片

         可以看出,相比有线方式,无线方式更加简单直接,PC作为客户端、应用软件作为Server端,二者通过建立socket通信来实现数据交互,从而实现PC对终端的控制。

 总结:不管哪种通信方式,要实现自研工具,有两块要进行开发(PC侧的客户端和终端侧的应用软件)。

二、Adb Client代码示例

Adb Client代码示例:
 

# -*- coding:utf-8 -*-

import os
import subprocess
import socket
import whichcraft
from collections import namedtuple
from components.device.android.adb.errors import AdbError

_OKAY = "OKAY"
_FAIL = "FAIL"
_DENT = "DENT"  # Directory Entity
_DONE = "DONE"
ForwardItem = namedtuple("ForwardItem", ["serial", "local", "remote"])


def where_adb():
    adb_path = whichcraft.which('adb')
    if adb_path is None:
        raise EnvironmentError("Can't find adb,please install adb first.")
    return adb_path


class _AdbStreamConnect(object):

    """
            连接adb服务
            即通过adb start-server在PC侧ANDROID_ADB_SERVER_PORT端口起的服务
            具体可以发送哪些命令需要查看adb协议
    """

    def __init__(self, host=None, port=None):
        self.__host = host
        self.__port = port
        self.__conn = None

        self._connect()

    def _create_socket(self):
        adb_host = self.__host or os.environ.get('ANDROID_ADB_SERVER_HOST', '127.0.0.1')
        adb_port = self.__port or int(os.environ.get('ANDROID_ADB_SERVER_PORT', 7305))
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(5)
        try:
            s.connect((adb_host, adb_port))
            return s
        except:
            s.close()
            raise

    @property
    def conn(self):
        return self.__conn

    def _connect(self):
        try:
            self.__conn = self._create_socket()
        except Exception as e:
            subprocess.Popen('adb kill-server')
            subprocess.Popen('adb start-server')
            self.__conn = self._create_socket()

    def close(self):
        self.conn.close()

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()

    def send(self, cmd):
        # cmd: str表示期望cmd是字符串类型
        if not isinstance(cmd, str):
            cmd = str(cmd)
        self.conn.send("{:04x}{}".format(len(cmd), cmd).encode("utf-8"))

    def read(self, n):
        # -> str表示接口返回值为字符串
        return self.conn.recv(n).decode()

    def read_string(self):
        size = int(self.read(4), 16)
        return self.read(size)

    def read_until_close(self):
        content = ""
        while True:
            chunk = self.read(4096)
            if not chunk:
                break
            content += chunk
        return content

    def check_okay(self):
        # 前四位是状态码
        data = self.read(4)
        if data == _FAIL:
            raise AdbError(self.read_string())
        elif data == _OKAY:
            return
        raise AdbError("Unknown data: %s" % data)


class AdbClient(object):

    def __init__(self, host=None, port=None):
        self.__host = host
        self.__port = port

    def server_version(self):
        """
        @summary:获取adb版本号
        @return :版本号1.0.41中的41
        """
        with self._connect() as c:
            c.send("host:version")
            c.check_okay()
            return int(c.read_string(), 16)

    def _connect(self):
        return _AdbStreamConnect(self.__host, self.__port)

    def forward(self, serial, local, remote, norebind=False):
        """
        @summary:给adb服务端发送host-serial::forward:tcp:;tcp:进行端口转发
        @param serial:手机sn号,sn为None时按默认连接一部手机处理
        @param local:PC侧socket客户端端口
        @param remote:手机侧socket服务端端口
        @param norebind:fail if already forwarded when set to true
        @attention :PC跟手机通过USB方式通信
        """
        with self._connect() as c:
            cmds = ["host", "forward"]
            if serial:
                cmds = ["host-serial", serial, "forward"]
            if norebind:
                cmds.append("norebind")
            cmds.append("tcp:%s;tcp:%s" % (local, remote))
            print(cmds)
            c.send(":".join(cmds))
            c.check_okay()

    def forward_list(self, serial=None):
        """
        @summary:查看端口转发是否成功
        @param serial:手机sn号
        @attention :PC跟手机通过USB方式通信
        """
        with self._connect() as c:
            list_cmd = "host:list-forward"
            if serial:
                list_cmd = "host-serial:{}:list-forward".format(serial)
            c.send(list_cmd)
            c.check_okay()
            content = c.read_string()
            for line in content.splitlines():
                parts = line.split()
                if len(parts) != 3:
                    continue
                if serial and parts[0] != serial:
                    continue
                yield ForwardItem(*parts)

    def shell(self, serial, command):
        """
        @summary:执行shell命令
        @param serial:手机sn号
        @param command:要执行的命令
        @attention :只能执行adb shell命令
        """
        with self._connect() as c:
            c.send("host:transport:" + serial)
            c.check_okay()
            c.send("shell:" + command)
            c.check_okay()
            return c.read_until_close()

    def device_list(self):
        """
        @summary:获取手机列表
        @attention :
        """
        device_list = []
        with self._connect() as c:
            c.send("host:devices")
            c.check_okay()
            output = c.read_string()
            for line in output.splitlines():
                parts = line.strip().split("\t")
                if len(parts) != 2:
                    continue
                if parts[1] == 'device':
                    device_list.append(parts[0])
        return device_list

    def must_one_device(self, serial):
        device_list = self.device_list()
        if len(device_list) == 0:
            raise RuntimeError(
                "Can't find any android device/emulator"
            )
        elif serial is None and len(device_list) > 1:
            raise RuntimeError(
                "more than one device/emulator, please specify the serial number"
            )

uiautomator.py代码实现:

# -*- coding: utf-8 -*-
# @Time    : 2023/2/5 20:24
# @Author  : 十年
# @Site    : https://gitee.com/chshao/aiplotest
# @CSDN    : https://blog.csdn.net/m0_37576542?type=blog
# @File    : uiautomator.py
# @Description  : 对这个模块文件的总体描述
import re
import time
import json
import socket
from typing import Union
from aitest.buildin.AiDecorator import singleton
from aitest.components.android.adb import AdbClient

TAG_UI = "UiAutomator"
TAG_WIFI = "WifiManager"
TAG_STUB = "Stub"

PORT10086 = 10086
PORT12306 = 12306
ip_pattern = re.compile('\d+.\d+.\d+.\d+')


@singleton
class UiClient(object):
    gSocket = None

    def connect(self, addr: Union[None, str, tuple]):
        """
        @summary:PC连接手机
        @param addr:手机sn号或者(ip,port)
        @attention :
        """
        if isinstance(addr, tuple):
            self._connect_wifi(addr[0], addr[1])
        self._connect_usb(addr)

    def _connect_usb(self, serial):
        adb_client = AdbClient()
        adb_client.must_one_device(serial)
        self._check_and_forward(adb_client, serial)

    def _check_and_forward(self, adb_client: AdbClient, serial=None):
        forward_list = adb_client.forward_list(serial)
        for forwardItem in forward_list:
            if forwardItem.serial == serial:
                port = forwardItem.local.split(':')[-1]
                self._connect_wifi('127.0.0.1', port)
        # 通过adb在pc上创建一个server监听10086端口,并将10086端口收到的数据转发给手机server的12306端口
        adb_client.forward(serial, PORT10086, PORT12306)
        # 创建一个socket客户端连接10086端口
        self._connect_wifi('127.0.0.1', PORT10086)

    def _connect_wifi(self, host, port):
        self.gSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.gSocket.connect((host, port))
        self.gSocket.settimeout(2)

    def send_cmd(self, cmd):
        cmd_bytes = json.dumps(cmd).encode('utf-8')
        cmd_len = len(cmd_bytes).to_bytes(4, "little")
        self.gSocket.send(cmd_len + cmd_bytes)

    def recv_ack(self, timeout=10):
        endTime = time.time() + timeout
        while time.time() < endTime:
            # 取前4位包头,为数据内容长度
            dataLenBytes = self.gSocket.recv(4)
            if dataLenBytes != b"":
                dataLen = int.from_bytes(dataLenBytes, "little")
                # 取出数据内容并返回
                return self.gSocket.recvData(dataLen)
        return b""

    def close(self):
        if self.gSocket:
            self.gSocket.close()


class Device(object):

    def __init__(self, addr=None):
        """
        @param addr: None/手机sn/IP
        """
        self.ui = UiClient()
        if addr is not None:
            if re.match(ip_pattern, addr):
                addr = (addr, PORT12306)
        self.ui.connect(addr)

    def command(self, clsName, method, args=None):
        cmd = {"class": clsName,
               "method": method,
               "args": args,
               "requestId": "request_" + str(round(time.time(), 3))}
        return cmd

    def executeCmd(self, clsName, method, args=None):
        self.ui.send_cmd(self.command(clsName, method, args))
        ack = self.ui.recv_ack()
        if ack:
            return json.loads(ack.decode('utf-8')).get("result")
        return ""

    def __checkResult(self, ack, expect):
        if ack == expect:
            return True
        return False

    def click(self, x, y):
        args = [{"k": "int", "v": x}, {"k": "int", "v": y}]
        ack = self.executeCmd(TAG_UI, "click", args)
        return self.__checkResult(ack, 'true')

    def clickById(self, id):
        args = [{"k": "string", "v": id}]
        ack = self.executeCmd(TAG_UI, "clickById", args)
        return self.__checkResult(ack, 'true')

    def clickByText(self, text):
        args = [{"k": "string", "v": text}]
        ack = self.executeCmd(TAG_UI, "clickByText", args)
        return self.__checkResult(ack, 'true')

    def clickByImage(self, image):
        pass

    def isScreenOn(self):
        ack = self.executeCmd(TAG_UI, "isScreenOn", [])
        if ack == "":
            return "unknown"
        return self.__checkResult(ack, 'true')

    def screenOn(self):
        self.executeCmd(TAG_UI, "wakeUp", [])
        return self.isScreenOn()

    def screenOff(self):
        self.executeCmd(TAG_UI, "sleep", [])
        return not self.isScreenOn()

    def goBack(self):
        ack = self.executeCmd(TAG_UI, "pressBack", [])
        return self.__checkResult(ack, 'true')

    def goHome(self):
        ack = self.executeCmd(TAG_UI, "pressHome", [])
        return self.__checkResult(ack, 'true')

    def getTextById(self, id):
        args = [{"k": "string", "v": id}]
        return self.executeCmd(TAG_UI, "getTextById", args)

三、应用软件(uiautomator封装)

        首先,打开Androdi Studio新建一个Android工程,然后在app文件夹下的build.gradle文件添加依赖库,如下图:

    androidTestImplementation 'androidx.test:runner:1.3.0'
    androidTestImplementation 'androidx.test:rules:1.3.0'
    androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
    androidTestImplementation 'androidx.core:core:1.3.0'
    androidTestImplementation 'androidx.annotation:annotation:1.1.0'

AndroidManifest.xml中添加如下权限:

    
    
    
    
    
    
    
    
    
    
    
    
    
    

开发完成后,需要打包编译两个APP:app-debug.apk、app-debug-androidTest.apk

app-debug.apk为待测apk,实际上没什么作用,但是需要安装,只要保证能正常运行就行

app-debug-androidTest.apk为测试apk,uiautomator的封装实现就在这个apk里面

启动测试服务:adb shell am instrument -w -r -e debug false -e class com.aiplot.wetest.Stub com.aiplot.wetest.test/androidx.test.runner.AndroidJUnitRunner

Android源码地址

链接:http://aospxref.com/

你可能感兴趣的:(APP自动化测试,android,java,python)