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

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

前言:

很多小伙伴们,不管是做接口还是 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 条回复 时间 点赞

请问这个要怎么解决啊?

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

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

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

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

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

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

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