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() # 结束检测器