自动化工具 使用 uiautomator2+pytest+allure 进行 Android 的 UI 自动化测试

xinxi · 2018年12月14日 · 最后由 能吃就多吃点 回复于 2021年10月09日 · 11705 次阅读
本帖已被设为精华帖!

前言

最近正在学习 python 的 pytest 框架,和之前使用的 unittest 框架有很大区别.刚使用 pytest 框架也非常不习惯,主要是和原来的编程思想有很多区别,后来写了一些代码加上练习多了就习惯了.

本文主要讲解使用 uiautomator2+pytest+allure 进行 Android 的 UI 自动化测试,其实主要目的是写一些实战的脚本来更深入学习 pytest 框架.

另外也顺便介绍一下 uiautomator2 这款自动化框架,在使用上也是非常的顺畅.

之前我已经使用 appium+testng 写了一套自动化脚本了并且在公司实际使用了.这次就不用公司的 app 测试了,使用上家公司 58 同城的 app 进行自动化测试.

介绍

做 UI 自动化肯定需要选择一种适合的测试框架,比如 java 的 testng、python 的 unittest,主要目的是让代码的层级明确、简洁、复用性强,本次介绍下 python 的 pytest 框架.

pytest

pytest 官方:https://docs.pytest.org/en/latest/

The pytest framework makes it easy to write small tests, yet scales to support complex functional testing for applications and libraries.

官方的一段介绍,简单来说就是让写测试代码更容易并且没有那么多约束.当然这块不重点介绍 pytest 为什么好、怎么好,只需要记住 pytest 就是一个测试框架就够了.

uiautomator2

github 地址:https://github.com/openatx/uiautomator2

uiautomator2 是一个 Android UI 自动化框架,支持 Python 编写测试脚本对设备进行自动.底层基于 Google uiautomator,隶属于最近比较火热的 openatx 项目中.

下图是运行示意图:

image

设备中需要安装 atx-agent 作为 uiautomator2 的服务端,解析收到的请求,并转化成 uiautomator2 的代码.总体看来交互过程没有那么繁琐,在实际使用上的确比 appium 快不少.

allure

allure 是一款测试报告,炫酷的页面加上多种数据统计,比 HTMLTestRunner 报告强百倍,当然也支持多语言.

官方地址:http://allure.qatools.ru

环境搭建

使用 mac 电脑搭建环境

pytest

最新版本出到 4.0 了,但是实际使用 4.0 和 allure 有些不兼容.
所以推荐使用 3.7 版本的 pytest

pip install pytest==3.7

uiautomator2

uiautomator2 也是 python 的一个类库,用 pip 安装即可.

pip install uiautomator2

allure

brew install allure
pip install pytest-allure-adaptor

有了测试框架、自动化框架、测试报告,基本上就能 coding 了.

pytest 插件

pytest 插件可以实现失败重试、打印进度、指定顺序

pip install pytest-sugar # 打印进度

pip install pytest-rerunfailures # 失败重试

pip install pytest-ordering # 执行顺序

当然插件还有很多,这里就不一一介绍了.

实例

初始化 driver

做 UI 自动化都需要初始化一个 driver 对象,这个 driver 对象可以点击事件、滑动、双击等操作

uiautomator2 的初始化 driver 方式

相比 appium 配置很少,同时可以设置全局隐式等待元素时间

import uiautomator2  as ut2
def init_driver(self,device_name):
    '''
    初始化driver
    :return:driver
    '''
    try:
        logger.info(device_name)
        d = ut2.connect(device_name)
        #logger.info("设备信息:{}".format(d.info))
        # 设置全局寻找元素超时时间
        d.wait_timeout = wait_timeout  # default 20.0
        # 设置点击元素延迟时间
        d.click_post_delay = click_post_delay
        #d.service("uiautomator").stop()
        # 停止uiautomator 可能和atx agent冲突
        logger.info("连接设备:{}".format(device_name))
        return d
    except Exception as e:
        logger.info("初始化driver异常!{}".format(e))

fixture 机制

unittest 框架有 setup 和 teardown 方法,用来做初始化和结束测试操作.pytest 是用@pytest.fixture方法来实现 setup 和 teardown.

下面这段代码就是定义一个 driver_setup 方法,来初始化和结束.

# 当设置autouse为True时,
# 在一个session内的所有的test都会自动调用这个fixture
@pytest.fixture()
def driver_setup(request):
    logger.info("自动化测试开始!")
    request.instance.driver = Driver().init_driver(device_name)
    logger.info("driver初始化")
    request.instance.driver.app_start(pck_name, lanuch_activity, stop=True)
    time.sleep(lanuch_time)
    allow(request.instance.driver)
    def driver_teardown():
        logger.info("自动化测试结束!")
        request.instance.driver.app_stop(pck_name)
    request.addfinalizer(driver_teardown)

另外还有一种方式实现,可以理解为 setup 和 teardown 在一个方法内,通过 yield 关键字停顿.

@pytest.fixture()
def init(self,scope="class"):
    self.home = Home(self.driver)
    self.home.news_tab()
    self.news = News(self.driver)
    logger.info("初始化消息模块")
    yield self.news
    logger.info("结束消息模块")

yield 关键字是在 python 语法生成器和迭代器中使用,用来节省内存.
比如 for 循环一个大列表,一次性都循环出来非常浪费性能.
所以通过 yield 关键字来控制循环.

下面演示下 yield:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

def yt():
    print "第一次打印"
    yield 0
    print("第二次打印")

if __name__ == '__main__':
    a = yt()
    print next(a)
    print next(a)

如果直接调用 yt 函数会发现啥也打印不出来,因为此时只是声明了 yt 函数并没有真正的使用.

使用 next 方法调用第一次,输入结果如下:

yield 在此时相当于 return 0,此时不会输出"第二次打印",会在这块停住.

第一次打印
0

使用 next 方法调用第二次,输入结果如下:

第二次打印

再来回顾下上面那个例子:

在 yield 之前完成了 setup 操作并且返回 self.news 对象

在 yied 之后完成了 teardown 操作

@pytest.fixture()
def init(self,scope="class"):
    self.home = Home(self.driver)
    self.home.news_tab()
    self.news = News(self.driver)
    logger.info("初始化消息模块")
    yield self.news
    logger.info("结束消息模块")

数据共享

在 pytest 中只需要写 conftest.py 类,可以实现数据共享,不需要 import 就能自动找到一些配置.

刚才讲到的初始化 driver_setup 函数,就可以定在 conftest.py 类中,此时这个函数是全局可以函数,在测试类中使用如下:

使用@pytest.mark.usefixtures 装饰器就能引用到 driver_setup 函数

@allure.feature("测试发布")
@pytest.mark.usefixtures('driver_setup')
class TestNews:

    @pytest.fixture(params=item)
    def item(self, request):
        return request.param

测试类

pytest 检测如果是 test 开头或者 test 结尾的类,都认为是可以执行测试类.

在测试类中写 test 开头的测试方法

@allure.story('测试首页搜索')
def test_home_search(self,init):
    init.home_search()

参数化

假设场景是首页搜索多个词,需要借助参数化来完成

使用@pytest.mark.parametrize

@pytest.mark.parametrize(('kewords'), [(u"司机"), (u"老师"), (u"公寓")])
def test_home_moresearch(self, init,kewords):
    init.home_more_search(kewords)

指定顺序

假设发布用例,需要先登录才可以.可以通过用例排序的方式先登录,再发布

使用@pytest.mark.run,odrer 从小到大优先执行

@pytest.mark.usefixtures('driver_setup')
@pytest.mark.run(order=1)
# 指定login先执行
class TestLogin:

运行指定级别

假设已经写了很多用例,有些用例是冒烟用例,可以指定级别运行.

使用@pytest.mark.P0

@allure.story('测试首页更多')
@pytest.mark.P0
def test_home_more(self, init):
    init.home_more()

命令行执行: pytest -v -m "P0", 会执行所有 P0 级别的用例

重试

这个时候需要借助 pytest-rerunfailures 插件,用法如下:

@pytest.mark.flaky(reruns=5, reruns_delay=2)
@allure.story('测试精选活动')
def test_news_good(self,init):
    init.news_good()

当然这种方法是指定某个 case 失败重试

还可以全局设置用户如下:

pytest --reruns 2 --reruns_delay 2

reruns:重试次数

reruns_delay:重试的间隔时间

hook 函数

在 conftest.py 文件中定义@pytest.hookimpl函数,这个函数可以 hook 住 pytest 运行的状况

@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    '''
    hook pytest失败
    :param item:
    :param call:
    :return:
    '''
    # execute all other hooks to obtain the report object
    outcome = yield
    rep = outcome.get_result()
    # we only look at actual failing test calls, not setup/teardown
    if rep.when == "call" and rep.failed:
        mode = "a" if os.path.exists("failures") else "w"
        with open("failures", mode) as f:
            # let's also access a fixture for the fun of it
            if "tmpdir" in item.fixturenames:
                extra = " (%s)" % item.funcargs["tmpdir"]
            else:
                extra = ""
            f.write(rep.nodeid + extra + "\n")

从代码中可以看出可以获取失败情况的相关信息,当时有了失败信息就可以搞事情了,比如当用例失败的时候截图或者记录失败数量做数据统计.

断言

在跑用例的时候最后一步都会断言一下,比如断言元素是否存在等

def assert_exited(self, element):
    '''
    断言当前页面存在要查找的元素,存在则判断成功
    :param driver:
    :return:
    '''
    if self.find_elements(element):
        logger.info("断言{}元素存在,成功!".format(element))
        assert True
    else:
        logger.info("断言{}元素存在,失败!".format(element))
        assert False

还可以这样优化代码:

def assert_exited(self, element):
  '''
  断言当前页面存在要查找的元素,存在则判断成功
  :param driver:
  :return:
  '''
  assert self.find_elements(element) == True,"断言{}元素存在,失败!".format(element)
  logger.info("断言{}元素存在,成功!".format(element))

assert 失败后会跑出 AssertionError 和定义的文案

AssertionError: 断言xxxxx元素存在,失败!

运行

介绍下几种常用命令行运行

运行某个文件夹下的用例

运行某个文件下的所有用例

pytest android/testcase

运行某个方法

类文件地址::方法名

pytest test_home.py::test_home_more

或者使用-k 参数 + 方法名

pytest -k test_home_more

运行某个类

有的时候需要调试正个测试类中所有测试方法

直接跟上类文件地址

pytest test_home.py

运行 P0 级

pytest -v -m "P0"

运行非 P0 级

pytest -v -m "not P0"

main 方式

在 run.py 中写如下代码,这种方式相当于把命令行参数封装到脚本中.

pytest.main(["-s","--reruns=2", "android/testcase","--alluredir=data"])

报告

测试代码写完了,还差一个非常好看的报告.以前我们一般都用 HTMLTestRunner 报告,但是 HTMLTestRunner 报告功能比较单一并且也不支持失败截图.

偶然在社区中看到了 allure 的帖子,看了展示报告简直是吊炸天,先附一张跑完用例的截图.

image

image

另外可以在代码中设置报告层次,用法如下:

@allure.feature("测试首页")
@pytest.mark.usefixtures('driver_setup')
class TestHome:

    @pytest.fixture()
    def init(self,scope="class"):
        self.home = Home(self.driver)
        logger.info("初始化首页模块")
        yield self.home
        logger.info("结束首页模块")


    @allure.story('测试首页搜索')
    def test_home_search(self,init):
        init.home_search()

设置 allure.feature 和 allure.story,相当于上下级关系.

失败详情

点击失败用例就能看到失败的相关信息

image

失败截图

在跑自动化的过程已经遇到失败情况,需要一张截图描述当时的情况.

在上面提到@pytest.hookimpl函数中,最后调用截图方法,使用
allure.attach 把截图加上.

需要注意的是 attach 中的第二个参数是图片的二进制信息.

@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    '''
    hook pytest失败
    :param item:
    :param call:
    :return:
    '''
    # execute all other hooks to obtain the report object
    outcome = yield
    rep = outcome.get_result()
    # we only look at actual failing test calls, not setup/teardown
    if rep.when == "call" and rep.failed:
        mode = "a" if os.path.exists("failures") else "w"
        with open("failures", mode) as f:
            # let's also access a fixture for the fun of it
            if "tmpdir" in item.fixturenames:
                extra = " (%s)" % item.funcargs["tmpdir"]
            else:
                extra = ""
            f.write(rep.nodeid + extra + "\n")
        pic_info = adb_screen_shot()
        with allure.step('添加失败截图...'):
            allure.attach("失败截图", pic_info, allure.attach_type.JPG)    

image

uiautomator2 基本操作

启动服务

执行如下命令:

python -m uiautomator2 init

会在手机上安装 atx-agent.apk 并且会在手机上启动服务

2018-12-14 18:03:50,691 - __main__.py:327 - INFO - Detect pluged devices: [u'a3f8ca3a']
2018-12-14 18:03:50,693 - __main__.py:343 - INFO - Device(a3f8ca3a) initialing ...
2018-12-14 18:03:51,154 - __main__.py:133 - INFO - install minicap
2018-12-14 18:03:51,314 - __main__.py:140 - INFO - install minitouch
2018-12-14 18:03:51,743 - __main__.py:168 - INFO - apk(1.1.7) already installed, skip
2018-12-14 18:03:51,744 - __main__.py:350 - INFO - atx-agent is already running, force stop
2018-12-14 18:03:52,308 - __main__.py:213 - INFO - atx-agent(0.5.0) already installed, skip
2018-12-14 18:03:52,490 - __main__.py:254 - INFO - launch atx-agent daemon
2018-12-14 18:03:54,568 - __main__.py:273 - INFO - atx-agent version: 0.5.0
atx-agent output: 2018/12/14 18:03:52 [INFO][github.com/openatx/atx-agent] main.go:508: atx-agent listening on 192.168.129.93:7912

监听的是手机上的 ip+ 默认 7921.

事件

事件类型比如点击和滑动等,介绍几个常用的.

点击

根据 id、xpath、text 定位元素,和 appium 使用上差别不大.

self.d(resourceId=element).click()
self.d.xpath(element).click()
self.d(text=element).click()

滑动

前 4 个参数是坐标,time 是控制滑动时间

self.d.drag(self.width / 2, self.height * 3 / 4, self.width / 2, self.height / 4, time)

监听

这个用于首次启动 app 点击权限或者开屏幕广告

when 方法就相当于 if 判断,满足条件才会点击,可以生去一大堆逻辑代码.

driver.watcher("允许").when(text="允许").click(text="允许")
driver.watcher("跳过 >").when(text="跳过 >").click(text="跳过 >")
driver.watcher("不要啦").when(text="不要啦").click(text="不要啦")

查看元素

安装

需要安装 weditor 库

pip install weditor

启动工具

python -m weditor

会在自动打开浏览器并且展示元素,相当于 web 版本的 uiautomatorviewer,使用起来比较方便.

image

无线运行

上边提到的手机 ip,有个这个手机 ip 就可以进行无线运行脚本

把 connect 中的方法替换成手机 ip 就可以了

# d = ut2.connect(device_name)
d = ut2.connect("192.168.129.93")

项目地址

写了一套 demo 代码,需要安装下 58 同城最新版 apk.

代码地址:https://github.com/xinxi1990/atxdemo.git

执行方式:

  • 在根目录运行 python run.py
  • 报告生成在:reports/index.html

结语

随着自动化技术的不断更新,可以选择的技术手段更多.但是要根据自己公司项目和技术成熟度多方面因素选择自动化框架.

共收到 50 条回复 时间 点赞
Mingway_Hu 将本帖设为了精华贴 12月14日 19:38
grily [该话题已被删除] 中提及了此贴 12月16日 20:20
ivy520 [该话题已被删除] 中提及了此贴 12月16日 22:55
zgxlz [该话题已被删除] 中提及了此贴 12月16日 23:09
ivy520 [该话题已被删除] 中提及了此贴 12月16日 23:53
9楼 已删除
仅楼主可见

值得学习

Ching-Ching [该话题已被删除] 中提及了此贴 12月20日 15:54
yeyang123 [该话题已被删除] 中提及了此贴 12月20日 20:07

真的是 666

想请教下断言的部分,怎么判断是否需要添加?添加的时机?添加什么样的断言?谢谢🙏

古雨辰 回复

断言实际在跳转到另外一个页面,断言是否跳转成功. 断言跳转的页面是否包含这个元素.

安涛 [该话题已被删除] 中提及了此贴 12月21日 16:29

给大佬点赞, 刚好最近觉得 HTMLTestRunner 的报告不太好

ivy520 [该话题已被删除] 中提及了此贴 12月24日 16:49

学到了一些新的 pytest 用法, 感谢!

ios 客户端有打算吗?会使用哪个测试套件呢?

xinxi #22 · 2018年12月26日 Author
雨雾恨 回复

可以用 atx,简单用了一下还可以,但是使用过程有点不稳定,我稍后再更新下 ios 端的代码.

xinxi 回复

atx 的 ios 执行效率会比 appium 高吗?现在安装 atx 需要一些其他依赖。android 执行时候需要使用 atx 吗

pytest4.0 哪里和 allure 不兼容呢,感觉 ids 就在 allure 上没有展示

请问遇到过MarkInfo objects are deprecated as they contain merged marks which are hard to deal with correctly.这种错误吗

 item = Function 'test_login_success[Mate10]'
 def labels_of(item):
        # FIXME: utils should not depend on structure, actually
      from allure.structure import TestLabel

 def get_marker_that_starts_with(item, name):
            """ get a list of marker object from item node that starts with given
            name or empty list if the node doesn't have a marker that starts with
            that name."""
            suitable_names = filter(lambda x: x.startswith(name), item.keywords.keys())
            markers = list()
            for suitable_name in suitable_names
                  markers.append(item.get_marker(suitable_name))

            return markers

          labels = []
          label_markers = get_marker_that_starts_with(item, Label.DEFAULT)
          for label_marker in label_markers:
          label_name = label_marker.name.split('.', 1)[-1]
E           RemovedInPytest4Warning: MarkInfo objects are deprecated as they contain merged marks which are hard to deal with correctly.
E           Please use node.get_closest_marker(name) or node.iter_markers(name).
E           Docs: https://docs.pytest.org/en/latest/mark.html#updating-code

/Library/Python/2.7/site-packages/allure/utils.py:92: RemovedInPytest4Warning

楼主你好,我想问下,我看到你有写 init 方法,pytest 不能使用 init 方法,具体是怎么可以使用 init 方法的呢?

雨雾恨 回复

你好请问你找到问题了吗 我也遇见这个问题了

仅楼主可见
xinxi #29 · 2019年01月11日 Author
回复

通过@pytest.mark.usefixtures('driver_setup') 的装饰器

xinxi #30 · 2019年01月11日 Author
秋了秋天 回复

我是在 case 中写的 init 方法,相当于 setup 和 teardown 方法的结合.

仅楼主可见

@xinxi 楼主,你好,有个问题请教下:atxdemo-master-->android-->module-->base.py-->18 行 sys.setdefaultencoding("utf-8") 报错,去掉这行中文会报错,环境变量设置了 export PYTHONIOENCODING="UTF-8" ,找了很多资料,都不管用。用的 Python3.7.1。

雨雾恨 回复

你的解决了吗?

Missy 回复

Python3.4 以上的版本 语法不一样

要将下面这两行代码
reload(sys)
sys.setdefaultencoding("utf-8")
改为
import importlib
importlib.reload(sys)

urnotdengly 回复

已经试过了 不行的 谢谢哈

xinxi #37 · 2019年01月18日 Author
Missy 回复

我用的 python2 有些语法和 3.6 不兼容

使用 3.6 版本遇到的问题

1、win10 python3.6 这两个包一直导入不了
安装 allure-python-commons ,已解决

2、截图问题,修改代码解决

3、想知道如果有 webview 要怎么切换

我觉得 allure 就是好看 但是不实用 推荐实用 pytest 自带的
HTMLreport

40楼 已删除
41楼 已删除

在试下试用过程中遇到这种问题 module ‘pytest’ has no attribute ‘allure’
百度了很久是,这个 pytest-allure-adaptor 插件有问题,
解决办法是卸载这个插件,安装 pip install allure-pytest


使用 58 同城的 demo 每次执行一个 PY 下的用例,都是重新初始化,如何只启动一次执行一个 PY 文件下的所有用例

nic 回复

初始化写在 conftest.py

@pytest.fixture(scope="class")
# @pytest.fixture(scope="function") -- 这里指定运行的范围
def interface_setup(request):
  pass

测试文件 类、方法直接引用

@user3res('interface_setup')
def test():
  pass

楼主的测试报告每一条 case 怎么是自己的本地地址,@allure.story() 都没有生效啊。怎么解决呢?

46楼 已删除

这个问题怎么解决?有遇到的吗?

request = >

@pytest.fixture()
def driver_setup(request):
logger.info("自动化测试开始!")
request.instance.driver = Driver().init_driver(device_name)
logger.info("driver 初始化")

request.instance.driver.app_start(pck_name, lanuch_activity, stop=True)
E AttributeError: 'NoneType' object has no attribute 'app_start'

conftest.py:30: AttributeError

pytest == 4.3.0
allure-pytest == 2.6.0

python3.6 环境,现在好像是 allure-pytest 来代替了之前的 pytest-allure-adaptor。
这里失败截图有坑,

作者您好:
这套测试代码不支持在 python2.7 运行吗?我运行过程太多安装找不到问题,谢谢

如何多手机并行呢

时隔一年再看该文章,依然有很多可以再学到的

codeskyblue 回复

好文不过时

雨雾恨 回复

请问该问题解决了,也遇到了这个问题

您好,小白想请问下 demo 根目录中的如 conftest.py 文件是怎么生成的呢。。😅

Hole 回复

手动创建

皆非 回复

大神。请问下 pytest 如果遇到了 bug,就会停下来。。怎么样让它恢复之前的 ui 环境继续跑下面的 case 啊。。就是比如从主菜单一个一个进入子菜单跑 case 如果出现 bug 停留在了一个子菜单中,怎么样让他继续出去跑下一个子菜单啊。。现在就直接接下来的 case 全部 timeout 了。。😅 麻烦您了 谢谢😅

Hole 回复
哈我是菜鸟级别的,也是干这行没多久哈。
  • 出现 bug 为什么会停? 我理解的是你这边是不是两条或者N条case启动了一次app?如果是的话,你这边的 timeout 应该是报错后某个元素超时了找不到报出来的吧,这样的话会影响到后面的case的
  • 你这种问题有两种解决方法: 两条或者N条case不要有互相影响的数据,每条case可以启动一次app,跑完单条用例后关闭app,下次case重复操作,这样假如前面的case报错了,不会影响到后面的case(目前我是这样做的,如果是web端可以借助pytest-xdisq启动多个浏览器) 如果你想启动一次app跑完所有的case,这种就要想到假如任何一条case出现异常了,还原场景,进入某个页面(比如主页),在重新跑新的case
  • 另外:报错分别两种,一种是流程里出现问题,比如查找元素、输入值等,这种就要做异常处理了,截个图录个视频啥的,这种也不会影响到流程。第二种,断言报错,这种输出的结果就是 fail,不影响其他流程
以上回答比较渣,有问题随时联系。

标记学习

Python 3.7.4, pytest-5.2.2 ,allure-pytest-2.8.6,没有生成 allure 报告,是版本兼容的问题么,我看有其他同学遇到了,有解决了这么问题的嘛?

请问下大佬,你这个 ios 运行失败截图放到报告里会报错,是不是需要换一种写法啊

nic 回复

大佬,ios 截图也会报错,有没有解决

多台手机并行测试,然后 allure 按手机的维度展示报告,有好的办法么

lijinzhou2016 回复

我最近也在搞多台设备并行,尝试使用 multiprocess 子进程同时跑多个设备但是会造成同时启动多个 pytest 并生成多个报告的情况

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册