自动化工具 使用 Pytest 运行 yaml 文件来驱动 Appium 自动化测试

YueChen · 2019年06月06日 · 最后由 jetty 回复于 2021年04月27日 · 6166 次阅读

前言:

很多小伙伴们,不管是做接口还是 UI 测试的时候,都想过或使用过 yaml,xlsx,sql 来维护用例吧~,但是不管是解析,还是测试用例的处理,报告的生成等等,都是相当的麻烦。

之前一篇文章介绍了一些 pytest+appium+allure 的一些基本用法
用 Pytest+Appium+Allure 做 UI 自动化的那些事

本文主要介绍一下 pytest hook 方法是怎么运行 yaml 文件做测试的 (喜欢其他方式也可以用 xlsx,sql 等),怎么与 Appium 结合,来驱动 Appium 做自动化测试。

该运行方式参照 pytest --doctest 参数运行的方法, 想深入了解的同学可以查看 _pytest/doctest.py 源码

本文 Demo 的代码地址:https://github.com/cpj1352/YamlAppium

获取 yaml 文件

使用 pytest_collect_file 钩子函数,在脚本运行之前过滤 .yml 文件

def pytest_collect_file(parent, path):
    # 获取文件.yml 文件
    if path.ext == ".yml" and path.basename.startswith("test"):
        return YamlFile(path, parent)

读取 yml 转成 json 移交至 YamlTest 类

class YamlFile(pytest.File):
    # 读取文件内容
    def collect(self):
        import yaml
        raw = yaml.safe_load(self.fspath.open(encoding='utf-8'))
        for name, values in raw.items():
            yield YamlTest(name, self, values)

YamlTest 测试类

下面就是测试类了,这里我们 Appium 还是使用单例初始化,方便所有测试用例继承一个 session

Appium 初始化

class Singleton(object):
    """单例 
    ElementActions 为自己封装操作类"""
    Action = None

    def __new__(cls, *args, **kw):
        if not hasattr(cls, '_instance'):
            desired_caps={}
            host = "http://localhost:4723/wd/hub"
            driver = webdriver.Remote(host, desired_caps)
            Action = ElementActions(driver, desired_caps)
            orig = super(Singleton, cls)
            cls._instance = orig.__new__(cls, *args, **kw)
            cls._instance.Action = Action
        return cls._instance

class DriverClient(Singleton):
    pass

Pytest 测试类

测试类初始化,这里要说一下,测试类一定要继承 pytest.Item 方法

class YamlTest(pytest.Item):
    def __init__(self, name, parent, values):
        super(YamlTest, self).__init__(name, parent)
        self.values = values
        self.Action = DriverClient().Action # 初始化 Appium
        self.locator = None

为了减少代码的逻辑,取出来的 yaml json 字符串怎么可以直接转化成可运行方法呢?

这里就要说到 class 的 _getattribute_ 内建属性的用法,下面举个简单例子

class TestExample:
    def test1(self):
        print('test1')

>>> TestExample().__getattribute__('test1')()
test1

现在我们就能直接读取 yaml 文件中的 method 字符串直接转化成 Appium api 运行了(method 对应自己封装或 Appium api 的方法)

自定义 runtest demo:

class YamlTest(pytest.Item):
    def __init__(self, name, parent, values):
        super(YamlTest, self).__init__(name, parent)
        self.values = values
        self.Action = DriverClient().Action
        self.locator = None

    def runtest(self):
        # 运行用例
        for self.locator in self.values:
            self.locator['time'] = 5
            if self.locator.get('element'):
                # 需要接收参数
                response = self.Action.__getattribute__(self.locator.get('method'))(self.locator)
            else:
                # 不需要参数
                response = self.Action.__getattribute__(self.locator.get('method'))()
            self.assert_response(response, self.locator)


这里将 Appium api 基本操作封装成了两类:

  • 需要接收元素的参数,例如:点击,查找,输入等
  • 不需要接收元素参数,例如:重启,滑动等

自定义错误输出

def repr_failure(self, excinfo):
    """自定义报错信息,如果没有定义则会默认打印错误堆栈信息,因为比较乱,所以这里自定义一下 """

    if isinstance(excinfo.value, Exception):
        return '测试用例名称:{} \n' \
               '步骤输入参数:{} \n' \
               '数据:{}'.format(self.name, self.locator, excinfo.value.args)

def reportinfo(self):
    return self.fspath, 0, "CaseName: %s" % self.name

下面就是完整的测试类了

class YamlTest(pytest.Item):
    def __init__(self, name, parent, values):
        super(YamlTest, self).__init__(name, parent)
        self.values = values
        self.Action = DriverClient().Action
        self.locator = None

    def runtest(self):
        # 运行用例
        for self.locator in self.values:
            self.locator['time'] = 5
            is_displayed = True
            if not self.locator.get('is_displayed'):
                is_displayed = False if str(self.locator.get('is_displayed')).lower() == 'false' else True
            try:
                if self.locator.get('element'):
                    response = self.Action.__getattribute__(self.locator.get('method'))(yamldict(self.locator))
                else:
                    response = self.Action.__getattribute__(self.locator.get('method'))()
                self.assert_response(response, self.locator)
            except Exception as E:
                if is_displayed:
                    raise E
                pass

    def repr_failure(self, excinfo):
        """自定义报错信息,如果没有定义则会打印堆栈错误信息,调试时可以注释该函数,便于问题查找 """
        if isinstance(excinfo.value, Exception):
            return '测试类名称:{} \n' \
                   '输入参数:{} \n' \
                   '错误信息:{}'.format(self.name, self.locator, excinfo.value.args)

    def assert_response(self, response, locator):
        if locator.get('assert_text'):
            assert locator['assert_text'] in response
        elif locator.get('assert_element'):
            assert response

    def reportinfo(self):
        return self.fspath, 0, "CaseName: %s" % self.name

这里我们主体 Pytest+yaml 测试框架就构建完成了,当然还有各种异常的捕获等钩子函数,自己封装的 Appium api 方法等,上篇文章讲过了,这里就不赘述了,自行选择添加更多功能

Yaml 使用方式规则

因为我们上面将接收方法分成了需要 element 参数和不需要 element 参数两类所以 yaml 格式如下

test_index:
  -
    method: launchApp # 启动 APP
  -
    method: 方法名称 例如:click (必填)
    element: 查找元素id,class等 (选填,配合 method 如需要点击元素,查找元素等必填)
    type: 元素类型 id,xpath,class  name,accessibility id (选填,会自动识别,如识别错误则自行填写)
    name: 测试步骤的名称 例如:点击搜索按钮 (选填)
    text: 需要输入或者查找的文本 (选填,配合部分 method 使用)
    time: 查找该元素需要的时间,默认 5s (选填)
    index: 页面有多个id,class时,不为空则查找元素数组下标 (选填)
    is_displayed: 默认 True ,当为 False 时元素未找到也不会抛异常(选填)

咱们用微博做个 demo 测试一下

test_index:
  -
    method: launchApp # 重启 APP
  -
    method: click
    element: click_ad_skip
    name: 广告跳过按钮
    is_displayed: False
  -
    method: click
    element: 发现
    name: 导航发现按钮
  -
    method: sleep
    element: 3
  -
    method: set_text
    element: com.sina.weibo:id/tv_search_keyword
    text: testerhome
    name: 搜索输入框
  -
    method: set_keycode_enter
  -
    method: screenshot_element
    element: //*[@resource-id="com.sina.weibo:id/lv_content"]/android.widget.RelativeLayout[1]
    name: 搜索内容截图

运行用例

pytest -v ./test_case/test_ranking.yml --alluredir /report/test

或者直接运行文件目录

使用方法和基本 pytest 用法没有太大区别

pytest -v ./test_case --alluredir /report/test

来查看下运行结果:

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 6 条回复 时间 点赞
仅楼主可见

这里的 parent 的作用感觉就像是一个全局的 session ,传递的时候,为了使下面的所有用例都继承这个 session,测试结果也保存在一个 session 里。

Pytest 有几种作用域 Session ,Module,Package 等等什么的

楼主,求助一下 Driver 单例的问题:
我第一个用例跑完用一个 driver 进行连接,也创建了一个 Action 单例,没问题,
但是我该怎么释放这个单例呢?我接下来跑第二个用例,需要重新用 desired_caps 启动 app 呀,那应该是一个新的 driver,而且也得用这个新 driver 创建新的 Action 单例。。。那这时候怎么析构掉上一个 Action 单例呢?
我这块儿我没有想明白,烦请楼主指教了。

response = self.Action.__getattribute__(self.locator.get('method'))(self.locator)

这里我用过 exec(string) 的方式逃过课,哈哈
相当于,

string = "response = self.Action.{}({})".format(self.locator.get('method'), self.locator)
exec("string")

exec 执行 string 的时候,已经拼接成正常的 python 代码了
不过楼主方法更优雅,更 python

如果用例有前置 fixture,请问需要在 runtest 中进行获取 fixture 的值吗?要如何获取?

请问这个要怎么解决啊?

C:\python36\python.exe "C:\Program Files\JetBrains\PyCharm 2018.3.3\helpers\pydev\pydevd.py" --multiproc --qt-support=auto --client 127.0.0.1 --port 3799 --file K:/workspace/YamlAppium-master/run.py
pydev debugger: process 5692 is connecting

Connected to pydev debugger (build 183.5153.39)
============================= test session starts =============================
platform win32 -- Python 3.8.2, pytest-5.4.2, py-1.8.1, pluggy-0.13.1 -- C:\python36\python.exe
cachedir: .pytest_cache
metadata: {'Python': '3.8.2', 'Platform': 'Windows-7-6.1.7601-SP1', 'Packages': {'pytest': '5.4.2', 'py': '1.8.1', 'pluggy': '0.13.1'}, 'Plugins': {'allure-pytest': '2.8.33', 'Faker': '4.14.0', 'html': '2.1.1', 'metadata': '1.10.0'}, 'JAVA_HOME': 'C:\jdk1.8'}
rootdir: K:\
plugins: allure-pytest-2.8.33, Faker-4.14.0, html-2.1.1, metadata-1.10.0
collecting ... collected 1 item

tests\test_case\test_search.yml::test_search ERROR [100%]

=================================== ERRORS ====================================
___________________ ERROR at setup of CaseName: test_search ___________________

cls =
func = . at 0x000000000DF8B5E0>
when = 'setup'
reraise = (, )

@classmethod
def from_call(cls, func, when, reraise=None) -> "CallInfo":
#: context of invocation: one of "setup", "call",
#: "teardown", "memocollect"
start = time()
excinfo = None
try:

result = func()

C:\python36\lib\site-packages_pytest\runner.py:244:


lambda: ihook(item=item, **kwds), when=when, reraise=reraise
)

C:\python36\lib\site-packages_pytest\runner.py:217:


self = <_HookCaller 'pytest_runtest_setup'>, args = ()
kwargs = {'item': }, notincall = set()

def call(self, *args, **kwargs):
if args:
raise TypeError("hook calling supports only keyword arguments")
assert not self.is_historic()
if self.spec and self.spec.argnames:
notincall = (
set(self.spec.argnames) - set(["multicall"]) - set(kwargs.keys())
)
if notincall:
warnings.warn(
"Argument(s) {} which are declared in the hookspec "
"can not be found in this hook call".format(tuple(notincall)),
stacklevel=2,
)

return self._hookexec(self, self.get_hookimpls(), kwargs)

C:\python36\lib\site-packages\pluggy\hooks.py:286:


self = <_pytest.config.PytestPluginManager object at 0x000000000B63E400>
hook = <_HookCaller 'pytest_runtest_setup'>
methods = [>, >]
kwargs = {'item': }

def _hookexec(self, hook, methods, kwargs):
# called from all hookcaller instances.
# enable_tracing will set its own wrapping function at self._inner_hookexec

return self._inner_hookexec(hook, methods, kwargs)

C:\python36\lib\site-packages\pluggy\manager.py:93:


hook = <_HookCaller 'pytest_runtest_setup'>
methods = [>, >]
kwargs = {'item': }

self._inner_hookexec = lambda hook, methods, kwargs: hook.multicall(
methods,
kwargs,
firstresult=hook.spec.opts.get("firstresult") if hook.spec else False,
)

C:\python36\lib\site-packages\pluggy\manager.py:84:


hook_impls = [>, >]
caller_kwargs = {'item': }, firstresult = False

def _multicall(hook_impls, caller_kwargs, firstresult=False):
"""Execute a call into multiple python functions/methods and return the
result(s).

caller_kwargs comes from HookCaller.call().
"""
__tracebackhide
_ = True
results = []
excinfo = None
try: # run impl and wrapper setup functions in a loop
teardowns = []
try:
for hook_impl in reversed(hook_impls):
try:
args = [caller_kwargs[argname] for argname in hook_impl.argnames]
except KeyError:
for argname in hook_impl.argnames:
if argname not in caller_kwargs:
raise HookCallError(
"hook call must provide argument %r" % (argname,)
)

if hook_impl.hookwrapper:
try:
gen = hook_impl.function(*args)
next(gen) # first yield
teardowns.append(gen)
except StopIteration:
_raise_wrapfail(gen, "did not yield")
else:
res = hook_impl.function(*args)
if res is not None:
results.append(res)
if firstresult: # halt further impl calls
break
except BaseException:
excinfo = sys.exc_info()
finally:
if firstresult: # first result hooks return a single value
outcome = _Result(results[0] if results else None, excinfo)
else:
outcome = _Result(results, excinfo)

# run all wrapper post-yield blocks
for gen in reversed(teardowns):
try:

gen.send(outcome)

C:\python36\lib\site-packages\pluggy\callers.py:203:


self =
item =

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_setup(self, item):
if not self._cache.get(item.nodeid):
uuid = self._cache.push(item.nodeid)
test_result = TestResult(name=item.name, uuid=uuid, start=now(), stop=now())
self.allure_logger.schedule_test(uuid, test_result)

yield

uuid = self._cache.get(item.nodeid)
test_result = self.allure_logger.get_test(uuid)

for fixturedef in _test_fixtures(item):

c:\python36\lib\site-packages\allure_pytest\listener.py:79:


item =

def _test_fixtures(item):
fixturemanager = item.session._fixturemanager
fixturedefs = []

if hasattr(item._request, "fixturenames"):
E AttributeError: 'YamlTest' object has no attribute '_request'

c:\python36\lib\site-packages\allure_pytest\listener.py:282: AttributeError
============================== warnings summary ===============================
tests\conftest.py:42
K:\workspace\YamlAppium-master\tests\conftest.py:42: PytestDeprecationWarning: direct construction of YamlFile has been deprecated, please use YamlFile.from_parent
return YamlFile(path, parent)

tests\conftest.py:51
K:\workspace\YamlAppium-master\tests\conftest.py:51: PytestDeprecationWarning: direct construction of YamlTest has been deprecated, please use YamlTest.from_parent
yield YamlTest(name, self, values)

-- Docs: https://docs.pytest.org/en/latest/warnings.html
=========================== short test summary info ===========================
ERROR tests\test_case\test_search.yml::test_search - AttributeError: 'YamlTes...
================== 2 warnings, 1 error in 103.70s (0:01:43) ===================

Process finished with exit code 0

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