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

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

最近因项目需要重新拾起了 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 #22 · 2018年10月26日 Author
TD 回复

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

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 回复

你最后成功没呢?

仅楼主可见
38楼 已删除
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()
45楼 已删除

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

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

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

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

不支持

51楼 已删除
52楼 已删除

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

有人遇过这种情况吗



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

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

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

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

xinufo #57 · 2019年04月23日 Author

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

借问高手

  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 回复

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

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