Airtest Airtest 多脚本执行及报告聚合

xinufo · 2018年10月23日 · 最后由 TesterRoad 回复于 2021年04月23日 · 5170 次阅读

最近因项目需要重新拾起了 UI 自动化,正好试用了下 Airtest,感觉开发脚本的效率确实高了许多,调试起来也很方便,但是不支持脚本批量执行这就非常不得劲了,所以那句老话就来了:“自己动手,丰衣足食”。

需求

  1. 脚本批量执行
  2. 每个脚本执行日志分开存放
  3. 每个脚本单独生成一个 html 报告并在父文件夹生成一个聚合报告

Airtest 执行脚本流程分析

Airtest 执行脚本的核心代码位于airtest\cli\runner.py中,大体流程如下:

  1. 解析命令行参数
  2. 实例化 AirtestCase 并添加到 suite 中
  3. 控制权交给 unittest 执行测试
  4. 由 unittest 调用 AirtestCase 的 runTest 方法
  5. 读入 Python 脚本内容,通过 exec 函数动态执行该脚本

根据上面的流程分析一下,run_script方法肯定要修改以便加入多个用例;在每次用例执行前我们都需要修改工作目录,因此setUp也需要修改。既然修改点明确了,那么我们来梳理一下思路:

  1. run_script方法中先遍历目录获取出所有用例,然后统一添加都 suite 中
  2. setUp方法中调用auto_setup方法设置日志路径,实现结果分开存放
  3. 因为setUp方法中需要知道每个脚本的日志路径,所以要为 AirtestCase 添加一个属性保存日志路径并在run_script方法中为其设置对应的值

至此执行部分分析完成

Airtest 生成报告流程分析

Airtest 生成报告的核心代码位于airtest\report\report.py中,大体流程如下:

  1. 解析命令行参数
  2. 加载并解析 log.txt
  3. 使用 jinja2 渲染模板

因为我们的日志是分开存放的,要给每个日志都生成一个 html 报告只需在main方法里加个循环,遍历所有日志即可;生成聚合报告这里有点麻烦,因为需要获取 LogToHtml 内部的test_result属性,但是 Python 的动态特性允许我们使用 types 动态的为实例绑定方法,所以我们可以定义一个方法返回test_result属性,然后绑定给实例就可以了;最后自定义一个模板,把收集到的结果通过 jinja2 渲染进去,大功告成!

效果图

  • 项目目录结构
    项目目录结构

  • 生成的报告目录结构
    生成的报告目录结构

  • 聚合报告
    聚合报告

自行修改

核心的逻辑在 runner.py 中,可根据自己的实际情况进行修改,目前 runner.py 中的逻辑如下:

  1. 获取 runner.py 同目录下所有以 “用例集” 结尾的文件夹
  2. 遍历第一步获取的每个文件夹,找出其中的 py 文件(以.py结尾的文件,会忽略以 “__” 开头的缓存文件)添加到测试集
  3. 执行用例时会默认执行脚本中的runCase方法,所以需要将测试代码封装成runCase方法,签名如下runCase(self, vars)代码位置

欢迎大家多提宝贵意见,谢谢
就酱,上个链接,溜了溜了。。。(o゚v゚) ノ

我是链接

共收到 75 条回复 时间 点赞


请问一下这个是直接可以用的吗?为什么我把我的脚本放进去执行都是显示 0 个执行了

TD 回复

代码里加了过滤,文件夹必须以 “用例集” 结尾
代码链接

目录结构如下就能识别出来

case
├─report.py
├─runner.py
├─交易用例集
│      ├──交易失败.py
│      └──交易成功.py
└─登录用例集
        ├──登录失败.py
        └──登录成功.py

赞一个!回去试下

仅楼主可见
TD 回复

报错是因为需要将测试代码封装成一个 runCase 方法,像下面那样

def runCase(self, vars):
    # 业务逻辑代码
    pass

代码在这里 new_case 方法

之所以这么整是考虑后面要实现数据驱动,可以通过方法签名里那个 vars 参数来传递数据,这样看起来比直接在命名空间里获取数据稍微清晰一点

仅楼主可见
TD 回复

runner.py 改成下面的代码就不用封装方法了

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

import unittest
import os
import sys
import six
import traceback
import types
import time
from io import open
from airtest.cli.parser import runner_parser
from airtest.core.api import G, auto_setup, log
from airtest.core.settings import Settings as ST
from airtest.utils.compat import decode_path
from copy import copy

class MyAirtestCase(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.args = args

        setup_by_args(args)

        # setup script exec scope
        cls.scope = copy(globals())

    def setUp(self):
        if self.args.log and self.args.recording:
            for dev in G.DEVICE_LIST:
                try:
                    dev.start_recording()
                except:
                    traceback.print_exc()
        # 设置日志路径
        auto_setup(logdir=self._logdir)

    def tearDown(self):
        if self.args.log and self.args.recording:
            for k, dev in enumerate(G.DEVICE_LIST):
                try:
                    output = os.path.join(self.args.log, "recording_%d.mp4" % k)
                    dev.stop_recording(output)
                except:
                    traceback.print_exc()

    def runTest(self):
        try:
            # 调用脚本中的runCase方法并传递scope供脚本使用
            exec(self._code["code"], self._code["ns"])
        except Exception as err:
            tb = traceback.format_exc()
            log("Final Error", tb)
            six.reraise(*sys.exc_info())

    @property
    def logdir(self):
        return self._logdir

    @logdir.setter
    def logdir(self, value):
        self._logdir = value

    @property
    def code(self):
        return self._code

    @code.setter
    def code(self, value):
        self._code = value

def setup_by_args(args):
    # init devices
    if isinstance(args.device, list):
        devices = args.device
    elif args.device:
        devices = [args.device]
    else:
        devices = []
        print("do not connect device")

    # set base dir to find tpl
    args.script = decode_path(args.script)

    # set log dir
    if args.log is True:
        print("save log in %s/log" % args.script)
        args.log = os.path.join(args.script, "log")
    elif args.log:
        print("save log in '%s'" % args.log)
        args.log = decode_path(args.log)
    else:
        print("do not save log")

    # guess project_root to be basedir of current .air path
    project_root = os.path.dirname(args.script) if not ST.PROJECT_ROOT else None
    # 此处不设置日志路径,防止生成多余的log.txt
    auto_setup(args.script, devices, None, project_root)

def new_case(py, logdir):
    """实例化MyAirtestCase并绑定runCase方法"""
    with open(py, 'r', encoding="utf8") as f:
        code = f.read()
    obj = compile(code.encode("utf-8"), py, "exec")
    ns = {}
    ns["__file__"] = py

    case = MyAirtestCase()
    pyfilename = os.path.basename(py).replace(".py", "")
    # 设置属性以便在setUp中设置日志路径
    case.logdir = os.path.join(logdir, pyfilename)
    case.code = {"code": obj, "ns": ns}
    return case

def init_log_folder():
    """初始化日志根目录"""
    name = time.strftime("log_%Y%m%d_%H%M%S", time.localtime())
    if not os.path.exists(name):
        os.mkdir(name)
    return name

def run_script(parsed_args, testcase_cls=MyAirtestCase):
    global args  # make it global deliberately to be used in MyAirtestCase & test scripts
    args = parsed_args
    dir = os.path.dirname(os.path.realpath(__file__))
    suites = []
    pys = []

    # 获取所有用例集
    for f in os.listdir(dir):
        if f.endswith("air"):
            f = os.path.join(dir, f)
            if os.path.isdir(f):
                suites.append(f)

    # 获取所有脚本
    for s in suites:
        for f in os.listdir(s):
            if f.endswith(".py") and not f.startswith("__"):
                pys.append(os.path.join(s, f))

    logdir = os.path.join(dir, init_log_folder())
    args.log = logdir
    suite = unittest.TestSuite()

    # 添加脚本
    for py in pys:
        case = new_case(py, logdir)
        suite.addTest(case)

    result = unittest.TextTestRunner(verbosity=0).run(suite)
    if not result.wasSuccessful():
        sys.exit(-1)

if __name__ == "__main__":
    ap = runner_parser()
    args = ap.parse_args()
    run_script(args, MyAirtestCase)
xinufo 回复

你真是好人呀,谢谢啦👍 那报告生成是 python report.py 日志的文件夹 吗?

TD 回复



我现在 run 之后生成日志了,但是生成报告的时候报错了,这是啥问题啊

参考你的方案做了一个新的方法出来,感谢大牛分享

xinufo #11 · 2018年10月25日 Author
TD 回复

看信息应该是图片没找到,你看看生成日志文件夹里有对应的图片吗?正常情况文件夹里应该包含图片和 log.txt

xinufo #12 · 2018年10月25日 Author
cgt 回复

大牛不敢当,对你有帮助就好

TD · #13 · 2018年10月25日
仅楼主可见
xinufo #14 · 2018年10月25日 Author
TD 回复

方便上传下那个 log_20181025_101740 文件夹吗?看你截图分析不出来啥问题

xinufo 回复

附件可以上传的吗

xinufo #16 · 2018年10月25日 Author
TD 回复

传到网盘里分享一下

TD · #17 · 2018年10月25日
仅楼主可见
xinufo #18 · 2018年10月25日 Author
TD 回复

那个_analyse 方法我没改,直接调用的 airtest 框架原来的,要不你自己改下 airtest/aircv/aircv.py(就是异常堆栈里的最后一个文件),把那个 filename 变量输出出来看看到底是什么值

TD · #19 · 2018年10月25日
仅楼主可见
xinufo #20 · 2018年10月25日 Author
TD 回复

我看你文件夹里确实没有那个文件啊,而且命令行里输出的图片是 tpl 开头的,你的日志文件夹里的图片都没有 tpl 前缀并且是 jpg 格式的。这一块在我写的 report.py 里我并没有做修改而是直接调用的原来的逻辑,我也不知道图片名称为啥出现这种差异😥

TD · #21 · 2018年10月26日
仅楼主可见
xinufo 回复

好的,我再看看,谢谢啦

airtest 是什么? 好用么?我一直用 selenium

xinufo #25 · 2018年10月28日 Author

简单说就是基于图像识别的自动化框架,官网

大佬,想问一下和用命令行或者 shell 脚本批量运行有什么区别么?我现在是这么实现的
另外聚合报告这个挺好的,正缺这个😁

这个文件夹结构需要自己创建么,最好新建脚本的时候就能建好这个结构。。。这一步做了就比较方便了

仅楼主可见

请问下,该框架是否支持 window 应用的脚本呢?试过安卓 APP 脚本,是可以正常运行的,但执行 windows 应用时,报错了,报错信息如下:

xinufo #30 · 2018年11月16日 Author
zlp 回复

文件夹结构是手动创建的,当时考虑脚本维护性就没按照官方那个 air 文件夹的结构

xinufo #31 · 2018年11月16日 Author
CielLee 回复

需要手动调下结构,其实不调也能运行,需要把好多 air 的脚本和图片都放到一个目录里,这样很容易造成图片不知道是哪个脚本的,太乱;如果想要直接运行 air 脚本可以把 runner.py 里遍历文件的逻辑改下就行了

xinufo #32 · 2018年11月16日 Author
sandy 回复

Windows 的我没试过,但是案例来说应该是支持的,我只是修改了添加案例的逻辑,其他地方并没有做修改

仅楼主可见
xinufo #34 · 2018年11月19日 Author

大神不敢当,当时写代码是确实是往这方面考虑的,但是最近比较忙可能抽不出时间来完善,如果你有好的想法或实现欢迎 PR😀

仅楼主可见
TD 回复

你最后成功没呢?

仅楼主可见
xinufo #39 · 2019年01月15日 Author
HazelRunner 回复

runner.py 会自动查找跟它在同一目录下且名字以 “用例集” 结尾的文件夹,然后将找到的文件夹里的 py 文件加入都测试用例集中,你那找不到用例查一下是不是因为编码问题导致的

仅楼主可见




运行一直显示 case 为 0,我是用的 air 作为后缀查找的,麻烦大神帮看下

请教下 case 内的 py 脚本有啥要求

-- encoding=utf8 --

author = "qiuyunxia"

from airtest.core.api import *

auto_setup(file)

from poco.drivers.android.uiautomation import AndroidUiautomationPoco
poco = AndroidUiautomationPoco(use_airtest_input=True, screenshot_each_action=False)

dev=connect_device("android:///988bdc454835315257")

dev.stop_app("com.unity3d.ads.example.creative")
dev_list =device()
print(dev_list.list_app())
dev.start_app("com.unity3d.ads.example.creative")
poco("com.unity3d.ads.example.creative:id/unityads_example_initialize_button").click()

snapshot()
poco("com.unity3d.ads.example.creative:id/unityads_example_interstitial_button").click()
sleep(4.0)
snapshot()
snapshot()

wait(Template(r"tpl1547966723580.png", record_pos=(0.005, 0.828), resolution=(1080, 2220)))
touch(Template(r"tpl1547966742057.png", record_pos=(0.005, 0.847), resolution=(1080, 2220)))

sleep(1.0)
snapshot()

类似这样子不可以?

我用下面的 command 生成出 EXPORT
report airtest report xxx.air --export EXPORT
但是实际看到的 log.html 却只有纪录着某次跑只有一分钟的 behavior
我已经跑测试跑了一整天
有人知道为什么吗?

xinufo #44 · 2019年01月21日 Author

需要封装成runCase方法

# -*- coding: utf-8 -*-
author = "qiuyunxia"

from airtest.core.api import *
from poco.drivers.android.uiautomation import AndroidUiautomationPoco

def runCase(self, vars):
    auto_setup(file)

    poco = AndroidUiautomationPoco(use_airtest_input=True, screenshot_each_action=False)

    dev=connect_device("android:///988bdc454835315257")

    dev.stop_app("com.unity3d.ads.example.creative")
    dev_list =device()
    print(dev_list.list_app())
    dev.start_app("com.unity3d.ads.example.creative")
    poco("com.unity3d.ads.example.creative:id/unityads_example_initialize_button").click()

    snapshot()
    poco("com.unity3d.ads.example.creative:id/unityads_example_interstitial_button").click()
    sleep(4.0)
    snapshot()
    snapshot()

    wait(Template(r"tpl1547966723580.png", record_pos=(0.005, 0.828), resolution=(1080, 2220)))
    touch(Template(r"tpl1547966742057.png", record_pos=(0.005, 0.847), resolution=(1080, 2220)))

    sleep(1.0)
    snapshot()

你这个就只是修改了它原有的 runner 方法吧?

xinufo #47 · 2019年02月12日 Author
阿拉伯人 回复

Airtest/tests/test_cli.py 这个也行,基于 unittest

仅楼主可见
xinufo #50 · 2019年02月15日 Author
simon 回复

不支持

rpt.report("log_template.html", output_file=output_file)
在执行上面一句时,报错

有人遇过这种情况吗



请问下,我已经封装成了 runcase 方法,但是执行报错,是什么原因

xinufo #55 · 2019年04月19日 Author
lizhouquan 回复

报错是因为 args 找不到,这个是在 run_script 方法中定义的全局变量,直接把 runner.py 当主类启动

xinufo #57 · 2019年04月23日 Author

报错是因为 args 找不到,这个是在 run_script 方法中定义的全局变量,直接把 runner.py 当主类启动.

请问下大佬 , 在执行 runner 时报 NameError: name 'args' is not defined 的错

借问高手

  1. poco 有自带断言方法,如果不用 poco 的断言 (exist, equl),而是用 unittest 或者 pytest 的断言,对报告会不会有影响,比如判断不出执行的失败与否
horacexu 回复

我也遇到了,好像是因为 某个路径传错了 多了\ 但是不知道怎么改



日志下面有图片,格式也是正确的,但是在报告里面不显示截图,这个是什么情况呢?

柳锐神 回复

你的问题解决了吗?我也遇到了。。

bjxiehong 回复

首先我没用这里楼主的方法,而是百度的时候,发现其他大牛有不需要修改的源码的方法,大同小异。然后说这个问题,我调试半天发现路径里面 就是以\结尾,后来断点调试,发现是 airtest 的源码里面,对运行目录和脚本名字进行了拼接,而脚本名字又默认是空,导致拼接后就以\结尾了,这个路径系统是不认的,所以我尝试改了一下,直接不拼接了,,,没想到运行通过了……
具体就是 修改了源码 report 模块下面一行代码(不想修改源码,最后还是改了),具体截图如下

柳锐神 回复

嗯嗯,多谢大佬!

Rhett_ 回复

我的报告也没有图片,请问你的问题解决了吗?

sandy 回复

你解决了吗,我的 windows 也报错

xinufo #69 · 2019年06月11日 Author
Pactortester 回复

👍 👍 👍

bjxiehong 回复

你是在编译器打开的,编译器基于项目给一个 ip,不显示图片是因为浏览器禁止访问本地资源!
你只需在文件中双击打开此文件就会正常显示了!!!

直南瓜 airtest 多设备并发运行批量脚本 中提及了此贴 08月26日 16:14

优化了下测试报告

仅楼主可见
仅楼主可见

请问一下 airtest 升级后新报告样式有适配吗?目前新版本的报告聚合后图片显示不出来呢

airetest 并行的时候,如果对每个测试用例都调用 run_script,不同的测试用例参数不一样(比如脚本路径、日志路径等),而 run_script 中会设置 Settings 全局变量,这样难道不会有冲突吗

Pactortester 回复

请问一下,我聚合报告生成后,点击跳转到该用例报告,发现报告为空,这是哪里出了问题?

仅楼主可见
仅楼主可见
xinufo #22 · 2018年10月26日 Author
TD 回复

我刚拿 poco 试了一下是没问题,但是我用的是 Python 3.6.5,不知道你那报错是不是因为你用 Python 2.7 的原因

38楼 已删除
45楼 已删除
51楼 已删除
52楼 已删除

请问一下,为什么聚合后的报告没有图片?

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